From 7ad8a8c447a8623ccbb9ecad6cf20bf0207832fd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 17:01:04 +0100 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 4 + .../interactive/components/bash-execution.ts | 17 +- .../interactive/components/tool-execution.ts | 147 ++++++++++++------ .../interactive/components/visual-truncate.ts | 50 ++++++ .../src/modes/interactive/interactive-mode.ts | 3 + 5 files changed, 163 insertions(+), 58 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/visual-truncate.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e0a473e5..ccf6c35d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **Bash tool visual line truncation**: Fixed bash tool output in collapsed mode to use visual line counting (accounting for line wrapping) instead of logical line counting. Now consistent with bash-execution.ts behavior. Extracted shared `truncateToVisualLines` utility. ([#275](https://github.com/badlogic/pi-mono/issues/275)) + ## [0.26.1] - 2025-12-22 ### Fixed diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index 106907ce..64a32af7 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -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: () => {} }); } } diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 0191ccd7..fe32a3b4 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts b/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts new file mode 100644 index 00000000..c681d2ba --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts @@ -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 }; +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 2e5638a4..0135fec2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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);