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.
This commit is contained in:
can1357
2026-06-17 19:14:28 +02:00
parent 020dd5f821
commit 3588fd30f2
3 changed files with 72 additions and 8 deletions
@@ -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<string, unknown>) : 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) {
+1
View File
@@ -10,3 +10,4 @@ export * from "./status-line";
export * from "./tree-list";
export * from "./types";
export * from "./utils";
export * from "./width-aware-text";
@@ -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);
}
}