Fix bash tool visual line truncation

Use visual line counting (accounting for line wrapping) instead of logical
line counting for bash tool output in collapsed mode. Now consistent with
bash-execution.ts behavior.

- Add shared truncateToVisualLines utility
- Update tool-execution.ts to use Box for bash with visual truncation
- Update bash-execution.ts to use shared utility
- Pass TUI to ToolExecutionComponent for terminal width access

Fixes #275
This commit is contained in:
Mario Zechner 2025-12-22 17:01:04 +01:00
parent 31f4a588fd
commit 7ad8a8c447
5 changed files with 163 additions and 58 deletions

View file

@ -12,6 +12,7 @@ import {
} from "../../../core/tools/truncate.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { truncateToVisualLines } from "./visual-truncate.js";
// Preview line limit when not expanded (matches tool execution behavior)
const PREVIEW_LINES = 20;
@ -134,15 +135,15 @@ export class BashExecutionComponent extends Container {
const displayText = availableLines.map((line) => theme.fg("muted", line)).join("\n");
this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
} else {
// Render preview lines, then cap at PREVIEW_LINES visual lines
const tempText = new Text(
`\n${previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n")}`,
1,
0,
// Use shared visual truncation utility
const styledOutput = previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n");
const { visualLines } = truncateToVisualLines(
`\n${styledOutput}`,
PREVIEW_LINES,
this.ui.terminal.columns,
1, // padding
);
const visualLines = tempText.render(this.ui.terminal.columns);
const truncatedVisualLines = visualLines.slice(-PREVIEW_LINES);
this.contentContainer.addChild({ render: () => truncatedVisualLines, invalidate: () => {} });
this.contentContainer.addChild({ render: () => visualLines, invalidate: () => {} });
}
}

View file

@ -8,12 +8,17 @@ import {
imageFallback,
Spacer,
Text,
type TUI,
} from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import type { CustomAgentTool } from "../../../core/custom-tools/types.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
import { renderDiff } from "./diff.js";
import { truncateToVisualLines } from "./visual-truncate.js";
// Preview line limit for bash when not expanded
const BASH_PREVIEW_LINES = 5;
/**
* Convert absolute path to tilde notation if it's in home directory
@ -41,7 +46,7 @@ export interface ToolExecutionOptions {
* Component that renders a tool call with its result (updateable)
*/
export class ToolExecutionComponent extends Container {
private contentBox?: Box; // Only used for custom tools
private contentBox: Box; // Used for custom tools and bash visual truncation
private contentText: Text; // For built-in tools (with its own padding/bg)
private imageComponents: Image[] = [];
private imageSpacers: Spacer[] = [];
@ -51,29 +56,36 @@ export class ToolExecutionComponent extends Container {
private showImages: boolean;
private isPartial = true;
private customTool?: CustomAgentTool;
private ui: TUI;
private result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
isError: boolean;
details?: any;
};
constructor(toolName: string, args: any, options: ToolExecutionOptions = {}, customTool?: CustomAgentTool) {
constructor(
toolName: string,
args: any,
options: ToolExecutionOptions = {},
customTool: CustomAgentTool | undefined,
ui: TUI,
) {
super();
this.toolName = toolName;
this.args = args;
this.showImages = options.showImages ?? true;
this.customTool = customTool;
this.ui = ui;
this.addChild(new Spacer(1));
if (customTool) {
// Custom tools use Box for flexible component rendering
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
// Always create both - contentBox for custom tools/bash, contentText for other built-ins
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
if (customTool || toolName === "bash") {
this.addChild(this.contentBox);
this.contentText = new Text("", 0, 0); // Fallback only
} else {
// Built-in tools use Text directly (has caching, better perf)
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.addChild(this.contentText);
}
@ -117,7 +129,7 @@ export class ToolExecutionComponent extends Container {
: (text: string) => theme.bg("toolSuccessBg", text);
// Check for custom tool rendering
if (this.customTool && this.contentBox) {
if (this.customTool) {
// Custom tools use Box for flexible component rendering
this.contentBox.setBgFn(bgFn);
this.contentBox.clear();
@ -163,8 +175,13 @@ export class ToolExecutionComponent extends Container {
this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
}
}
} else if (this.toolName === "bash") {
// Bash uses Box with visual line truncation
this.contentBox.setBgFn(bgFn);
this.contentBox.clear();
this.renderBashContent();
} else {
// Built-in tools: use Text directly with caching
// Other built-in tools: use Text directly with caching
this.contentText.setCustomBgFn(bgFn);
this.contentText.setText(this.formatToolExecution());
}
@ -201,6 +218,75 @@ export class ToolExecutionComponent extends Container {
}
}
/**
* Render bash content using visual line truncation (like bash-execution.ts)
*/
private renderBashContent(): void {
const command = this.args?.command || "";
// Header
this.contentBox.addChild(
new Text(theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`)), 0, 0),
);
if (this.result) {
const output = this.getTextOutput().trim();
if (output) {
// Style each line for the output
const styledOutput = output
.split("\n")
.map((line) => theme.fg("toolOutput", line))
.join("\n");
if (this.expanded) {
// Show all lines when expanded
this.contentBox.addChild(new Text(`\n${styledOutput}`, 0, 0));
} else {
// Use visual line truncation when collapsed
// Box has paddingX=1, so content width = terminal.columns - 2
const { visualLines, skippedCount } = truncateToVisualLines(
`\n${styledOutput}`,
BASH_PREVIEW_LINES,
this.ui.terminal.columns - 2,
);
if (skippedCount > 0) {
this.contentBox.addChild(
new Text(theme.fg("toolOutput", `\n... (${skippedCount} earlier lines)`), 0, 0),
);
}
// Add pre-rendered visual lines as a raw component
this.contentBox.addChild({
render: () => visualLines,
invalidate: () => {},
});
}
}
// Truncation warnings
const truncation = this.result.details?.truncation;
const fullOutputPath = this.result.details?.fullOutputPath;
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {
warnings.push(`Full output: ${fullOutputPath}`);
}
if (truncation?.truncated) {
if (truncation.truncatedBy === "lines") {
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
} else {
warnings.push(
`Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
);
}
}
this.contentBox.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
}
}
}
private getTextOutput(): string {
if (!this.result) return "";
@ -233,46 +319,7 @@ export class ToolExecutionComponent extends Container {
private formatToolExecution(): string {
let text = "";
if (this.toolName === "bash") {
const command = this.args?.command || "";
text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`));
if (this.result) {
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 5;
const skipped = Math.max(0, lines.length - maxLines);
const displayLines = lines.slice(-maxLines);
if (skipped > 0) {
text += theme.fg("toolOutput", `\n\n... (${skipped} earlier lines)`);
}
text +=
(skipped > 0 ? "\n" : "\n\n") +
displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
}
const truncation = this.result.details?.truncation;
const fullOutputPath = this.result.details?.fullOutputPath;
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {
warnings.push(`Full output: ${fullOutputPath}`);
}
if (truncation?.truncated) {
if (truncation.truncatedBy === "lines") {
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
} else {
warnings.push(
`Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
);
}
}
text += `\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`;
}
}
} else if (this.toolName === "read") {
if (this.toolName === "read") {
const path = shortenPath(this.args?.file_path || this.args?.path || "");
const offset = this.args?.offset;
const limit = this.args?.limit;

View file

@ -0,0 +1,50 @@
/**
* Shared utility for truncating text to visual lines (accounting for line wrapping).
* Used by both tool-execution.ts and bash-execution.ts for consistent behavior.
*/
import { Text } from "@mariozechner/pi-tui";
export interface VisualTruncateResult {
/** The visual lines to display */
visualLines: string[];
/** Number of visual lines that were skipped (hidden) */
skippedCount: number;
}
/**
* Truncate text to a maximum number of visual lines (from the end).
* This accounts for line wrapping based on terminal width.
*
* @param text - The text content (may contain newlines)
* @param maxVisualLines - Maximum number of visual lines to show
* @param width - Terminal/render width
* @param paddingX - Horizontal padding for Text component (default 0).
* Use 0 when result will be placed in a Box (Box adds its own padding).
* Use 1 when result will be placed in a plain Container.
* @returns The truncated visual lines and count of skipped lines
*/
export function truncateToVisualLines(
text: string,
maxVisualLines: number,
width: number,
paddingX: number = 0,
): VisualTruncateResult {
if (!text) {
return { visualLines: [], skippedCount: 0 };
}
// Create a temporary Text component to render and get visual lines
const tempText = new Text(text, paddingX, 0);
const allVisualLines = tempText.render(width);
if (allVisualLines.length <= maxVisualLines) {
return { visualLines: allVisualLines, skippedCount: 0 };
}
// Take the last N visual lines
const truncatedLines = allVisualLines.slice(-maxVisualLines);
const skippedCount = allVisualLines.length - maxVisualLines;
return { visualLines: truncatedLines, skippedCount };
}

View file

@ -815,6 +815,7 @@ export class InteractiveMode {
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(content.name)?.tool,
this.ui,
);
this.chatContainer.addChild(component);
this.pendingTools.set(content.id, component);
@ -862,6 +863,7 @@ export class InteractiveMode {
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(event.toolName)?.tool,
this.ui,
);
this.chatContainer.addChild(component);
this.pendingTools.set(event.toolCallId, component);
@ -1101,6 +1103,7 @@ export class InteractiveMode {
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(content.name)?.tool,
this.ui,
);
this.chatContainer.addChild(component);