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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user