From 3588fd30f204350aed6315ea8a17af1d7ffcf98c Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 17 Jun 2026 19:14:28 +0200 Subject: [PATCH] feat(coding-agent/tui): adjusted tool output rendering to use terminal content width - Introduced a WidthAwareText component that formatted fallback content during render using the computed content width. - Updated ToolExecutionComponent to use WidthAwareText and invalidate cached text instead of a fixed-width Text path. - Reworked generic tool output formatting to derive inline and output truncation budgets from terminal width rather than hard-coded 70/80 columns. --- .../src/modes/components/tool-execution.ts | 21 ++++--- packages/coding-agent/src/tui/index.ts | 1 + .../coding-agent/src/tui/width-aware-text.ts | 58 +++++++++++++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/src/tui/width-aware-text.ts diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 927e67e9b..adf9d404b 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -34,7 +34,7 @@ import { import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils"; import { toolRenderers } from "../../tools/renderers"; import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo"; -import { isFramedBlockComponent, renderStatusLine } from "../../tui"; +import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui"; import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel"; import { renderDiff } from "./diff"; @@ -169,7 +169,7 @@ let toolExecutionInstanceSeq = 0; */ export class ToolExecutionComponent extends Container implements NativeScrollbackLiveRegion { #contentBox: Box; // Used for custom tools and bash visual truncation - #contentText: Text; // For built-in tools (with its own padding/bg) + #contentText: WidthAwareText; // Generic fallback (no custom/built-in renderer) #multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results #imageComponents: Image[] = []; #imageSpacers: Spacer[] = []; @@ -279,7 +279,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac // strips PLAIN-blank edges, so framed/minimal blocks (no bg set) drop these // lines and keep their tight spacing — only tinted lines survive. this.#contentBox = new Box(0, 1); - this.#contentText = new Text("", 1, 1); + this.#contentText = new WidthAwareText(contentWidth => this.#formatToolExecution(contentWidth), 1, 1); // Use Box for custom tools or built-in tools that have renderers const hasRenderer = toolName in toolRenderers; @@ -922,9 +922,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac } } } else { - // Other built-in tools: use Text directly with caching + // Generic fallback (no custom/built-in renderer). WidthAwareText + // reformats at render time so output fills the actual terminal width + // instead of a fixed column cap. this.#contentText.setCustomBgFn(stateBgFn); - this.#contentText.setText(this.#formatToolExecution()); + this.#contentText.invalidate(); } // Handle images (same for both custom and built-in) @@ -1076,14 +1078,17 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac /** * Format a generic tool execution (fallback for tools without custom renderers) */ - #formatToolExecution(): string { + #formatToolExecution(contentWidth: number): string { const lines: string[] = []; const icon = this.#isPartial ? "pending" : this.#result?.isError ? "error" : "done"; lines.push(renderStatusLine({ icon, title: this.#toolLabel }, theme)); const argsObject = this.#args && typeof this.#args === "object" ? (this.#args as Record) : null; if (!this.#expanded && argsObject && Object.keys(argsObject).length > 0) { - const preview = formatArgsInline(argsObject, 70); + // Budget the inline preview against the render width, leaving room for + // the ` └─ ` connector prefix instead of a fixed cap. + const inlineBudget = Math.max(20, contentWidth - Bun.stringWidth(theme.tree.last) - 2); + const preview = formatArgsInline(argsObject, inlineBudget); if (preview) { lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", preview)}`); } @@ -1143,7 +1148,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac const displayLines = outputLines.slice(0, maxOutputLines); for (const line of displayLines) { - lines.push(theme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))); + lines.push(theme.fg("toolOutput", truncateToWidth(replaceTabs(line), contentWidth))); } if (outputLines.length > maxOutputLines) { diff --git a/packages/coding-agent/src/tui/index.ts b/packages/coding-agent/src/tui/index.ts index be13f8e4d..035d2b63f 100644 --- a/packages/coding-agent/src/tui/index.ts +++ b/packages/coding-agent/src/tui/index.ts @@ -10,3 +10,4 @@ export * from "./status-line"; export * from "./tree-list"; export * from "./types"; export * from "./utils"; +export * from "./width-aware-text"; diff --git a/packages/coding-agent/src/tui/width-aware-text.ts b/packages/coding-agent/src/tui/width-aware-text.ts new file mode 100644 index 000000000..f4b69922e --- /dev/null +++ b/packages/coding-agent/src/tui/width-aware-text.ts @@ -0,0 +1,58 @@ +import { type Component, getPaddingX, Text } from "@oh-my-pi/pi-tui"; + +/** + * Text whose content is (re)formatted against the actual render width. + * + * A plain `Text` receives an already-formatted string and only wraps it at + * render time, so width-dependent layout (per-line truncation, inline previews) + * has to be decided before the width is known. Renderers used to cope by + * hard-capping output lines at a fixed column count (e.g. 80), which truncated + * to roughly a third of a wide terminal. This defers formatting to + * `render(width)`: it computes the same content width the inner `Text` uses + * (mirroring its tight-layout flag so the budget can't desync), hands that to + * the formatter, and delegates margins/background/vertical padding to the inner + * `Text`. Lines the formatter caps at `contentWidth` fit exactly and so never + * wrap. + */ +export class WidthAwareText implements Component { + #format: (contentWidth: number) => string; + readonly #paddingX: number; + #inner: Text; + #cachedContentWidth = -1; + #cachedText: string | undefined; + #ignoreTight = false; + + constructor(format: (contentWidth: number) => string, paddingX = 1, paddingY = 1) { + this.#format = format; + this.#paddingX = paddingX; + this.#inner = new Text("", paddingX, paddingY); + } + + setCustomBgFn(customBgFn?: (text: string) => string): void { + this.#inner.setCustomBgFn(customBgFn); + } + + setIgnoreTight(ignore: boolean): this { + this.#ignoreTight = ignore; + this.#inner.setIgnoreTight(ignore); + this.invalidate(); + return this; + } + + invalidate(): void { + this.#cachedContentWidth = -1; + this.#cachedText = undefined; + this.#inner.invalidate(); + } + + render(width: number): readonly string[] { + const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX); + const contentWidth = Math.max(1, width - paddingX * 2); + if (this.#cachedText === undefined || contentWidth !== this.#cachedContentWidth) { + this.#cachedContentWidth = contentWidth; + this.#cachedText = this.#format(contentWidth); + this.#inner.setText(this.#cachedText); + } + return this.#inner.render(width); + } +}