Skip to content

Commit ce08ddf

Browse files
committed
fix(tui): cap inline image height and preserve multiplexer scrollback
Inline images (screenshots from puppeteer, assistant images) rendered at unbounded height — a 1280x800 screenshot consumed ~32 terminal rows, causing excessive scrolling. In multiplexers (tmux, screen, zellij), this was compounded by fullRender clearing scrollback on every resize. Image height cap: - New setting tui.maxInlineImageRows (default 20) - Effective cap: min(setting, floor(viewport * 0.6)) - Shared resolveImageOptions() replaces per-site settings reads - Existing calculateImageFit() already handles maxHeightCells Multiplexer scrollback preservation: - Skip CSI 3J (clear scrollback) in tmux/screen/zellij - Skip fullRender(true) on height change in multiplexers - Cached isMultiplexer constant (TMUX, STY, ZELLIJ env vars)
1 parent bf22368 commit ce08ddf

File tree

5 files changed

+36
-6
lines changed

5 files changed

+36
-6
lines changed

packages/coding-agent/src/config/settings-schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,12 @@ export const SETTINGS_SCHEMA = {
372372
"Maximum width in terminal columns for inline images (default 100). Set to 0 for unlimited (bounded only by terminal width).",
373373
},
374374

375+
"tui.maxInlineImageRows": {
376+
type: "number",
377+
default: 20,
378+
description:
379+
"Maximum height in terminal rows for inline images (default 20). Set to 0 to use only the viewport-based limit (60% of terminal height).",
380+
},
375381
// Display rendering
376382
"display.tabWidth": {
377383
type: "number",

packages/coding-agent/src/modes/components/assistant-message.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { formatNumber, logger } from "@oh-my-pi/pi-utils";
44
import { settings } from "../../config/settings";
55
import { hasPendingMermaid, prerenderMermaid } from "../../modes/theme/mermaid-cache";
66
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
7+
import { resolveImageOptions } from "../../tools/render-utils";
78

89
/**
910
* Component that renders a complete assistant message
@@ -76,7 +77,7 @@ export class AssistantMessageComponent extends Container {
7677
image.data,
7778
image.mimeType,
7879
{ fallbackColor: (text: string) => theme.fg("toolOutput", text) },
79-
{ maxWidthCells: settings.get("tui.maxInlineImageColumns") },
80+
resolveImageOptions(),
8081
),
8182
);
8283
continue;

packages/coding-agent/src/modes/components/tool-execution.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
type TUI,
1515
} from "@oh-my-pi/pi-tui";
1616
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
17-
import { settings } from "../../config/settings";
1817
import type { Theme } from "../../modes/theme/theme";
1918
import { theme } from "../../modes/theme/theme";
2019
import { computeEditDiff, computeHashlineDiff, computePatchDiff, type DiffError, type DiffResult } from "../../patch";
@@ -31,7 +30,7 @@ import {
3130
stripInternalArgs,
3231
} from "../../tools/json-tree";
3332
import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python";
34-
import { formatExpandHint, replaceTabs, truncateToWidth } from "../../tools/render-utils";
33+
import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
3534
import { toolRenderers } from "../../tools/renderers";
3635
import { renderStatusLine } from "../../tui";
3736
import { convertToPng } from "../../utils/image-convert";
@@ -531,7 +530,7 @@ export class ToolExecutionComponent extends Container {
531530
imageData,
532531
imageMimeType,
533532
{ fallbackColor: (s: string) => theme.fg("toolOutput", s) },
534-
{ maxWidthCells: settings.get("tui.maxInlineImageColumns") },
533+
resolveImageOptions(),
535534
);
536535
this.#imageComponents.push(imageComponent);
537536
this.addChild(imageComponent);

packages/coding-agent/src/tools/render-utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import * as os from "node:os";
88
import { type Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui";
99
import { getIndentation, pluralize } from "@oh-my-pi/pi-utils";
10+
import { settings } from "../config/settings";
1011
import type { Theme } from "../modes/theme/theme";
1112
import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
1213

@@ -19,6 +20,25 @@ export function replaceTabs(text: string, file?: string): string {
1920
// Standardized Display Constants
2021
// =============================================================================
2122

23+
/** Resolve inline image dimension caps from settings and viewport. */
24+
export function resolveImageOptions(): { maxWidthCells: number; maxHeightCells?: number } {
25+
const maxWidthCells = settings.get("tui.maxInlineImageColumns");
26+
const rowSetting = Math.max(0, settings.get("tui.maxInlineImageRows"));
27+
const viewportRows = process.stdout.rows;
28+
const viewportFraction = viewportRows ? Math.floor(viewportRows * 0.6) : 0;
29+
let maxHeightCells: number | undefined;
30+
if (rowSetting === 0) {
31+
// No explicit cap — use viewport fraction as safety bound
32+
maxHeightCells = viewportFraction || undefined;
33+
} else if (viewportFraction > 0) {
34+
maxHeightCells = Math.min(rowSetting, viewportFraction);
35+
} else {
36+
// Viewport size unknown (transitional state) — honor explicit setting
37+
maxHeightCells = rowSetting;
38+
}
39+
return { maxWidthCells, maxHeightCells };
40+
}
41+
2242
/** Preview limits for collapsed/expanded views */
2343
export const PREVIEW_LIMITS = {
2444
/** Lines shown in collapsed view */

packages/tui/src/tui.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ function isTermuxSession(): boolean {
112112
return Boolean(process.env.TERMUX_VERSION);
113113
}
114114

115+
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
116+
const isMultiplexer = Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
117+
115118
/**
116119
* Options for overlay positioning and sizing.
117120
* Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -1007,7 +1010,8 @@ export class TUI extends Container {
10071010
const fullRender = (clear: boolean): void => {
10081011
this.#fullRedrawCount += 1;
10091012
let buffer = "\x1b[?2026h"; // Begin synchronized output
1010-
if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
1013+
// Skip clearing scrollback (3J) in multiplexers — users actively navigate scrollback history
1014+
if (clear) buffer += isMultiplexer ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
10111015
const reset = SEGMENT_RESET;
10121016
for (let i = 0; i < newLines.length; i++) {
10131017
if (i > 0) buffer += "\r\n";
@@ -1056,7 +1060,7 @@ export class TUI extends Container {
10561060
// Height changes normally need a full re-render to keep the visible viewport aligned,
10571061
// but Termux changes height when the software keyboard shows or hides.
10581062
// In that environment, a full redraw causes the entire history to replay on every toggle.
1059-
if (heightChanged && !isTermuxSession()) {
1063+
if (heightChanged && !isTermuxSession() && !isMultiplexer) {
10601064
logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
10611065
fullRender(true);
10621066
return;

0 commit comments

Comments
 (0)