mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 17:02:11 +00:00
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:
parent
31f4a588fd
commit
7ad8a8c447
5 changed files with 163 additions and 58 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.26.1] - 2025-12-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "../../../core/tools/truncate.js";
|
} from "../../../core/tools/truncate.js";
|
||||||
import { theme } from "../theme/theme.js";
|
import { theme } from "../theme/theme.js";
|
||||||
import { DynamicBorder } from "./dynamic-border.js";
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
|
import { truncateToVisualLines } from "./visual-truncate.js";
|
||||||
|
|
||||||
// Preview line limit when not expanded (matches tool execution behavior)
|
// Preview line limit when not expanded (matches tool execution behavior)
|
||||||
const PREVIEW_LINES = 20;
|
const PREVIEW_LINES = 20;
|
||||||
|
|
@ -134,15 +135,15 @@ export class BashExecutionComponent extends Container {
|
||||||
const displayText = availableLines.map((line) => theme.fg("muted", line)).join("\n");
|
const displayText = availableLines.map((line) => theme.fg("muted", line)).join("\n");
|
||||||
this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
|
this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
|
||||||
} else {
|
} else {
|
||||||
// Render preview lines, then cap at PREVIEW_LINES visual lines
|
// Use shared visual truncation utility
|
||||||
const tempText = new Text(
|
const styledOutput = previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n");
|
||||||
`\n${previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n")}`,
|
const { visualLines } = truncateToVisualLines(
|
||||||
1,
|
`\n${styledOutput}`,
|
||||||
0,
|
PREVIEW_LINES,
|
||||||
|
this.ui.terminal.columns,
|
||||||
|
1, // padding
|
||||||
);
|
);
|
||||||
const visualLines = tempText.render(this.ui.terminal.columns);
|
this.contentContainer.addChild({ render: () => visualLines, invalidate: () => {} });
|
||||||
const truncatedVisualLines = visualLines.slice(-PREVIEW_LINES);
|
|
||||||
this.contentContainer.addChild({ render: () => truncatedVisualLines, invalidate: () => {} });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,17 @@ import {
|
||||||
imageFallback,
|
imageFallback,
|
||||||
Spacer,
|
Spacer,
|
||||||
Text,
|
Text,
|
||||||
|
type TUI,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import stripAnsi from "strip-ansi";
|
import stripAnsi from "strip-ansi";
|
||||||
import type { CustomAgentTool } from "../../../core/custom-tools/types.js";
|
import type { CustomAgentTool } from "../../../core/custom-tools/types.js";
|
||||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
|
||||||
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
|
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
|
||||||
import { renderDiff } from "./diff.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
|
* 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)
|
* Component that renders a tool call with its result (updateable)
|
||||||
*/
|
*/
|
||||||
export class ToolExecutionComponent extends Container {
|
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 contentText: Text; // For built-in tools (with its own padding/bg)
|
||||||
private imageComponents: Image[] = [];
|
private imageComponents: Image[] = [];
|
||||||
private imageSpacers: Spacer[] = [];
|
private imageSpacers: Spacer[] = [];
|
||||||
|
|
@ -51,29 +56,36 @@ export class ToolExecutionComponent extends Container {
|
||||||
private showImages: boolean;
|
private showImages: boolean;
|
||||||
private isPartial = true;
|
private isPartial = true;
|
||||||
private customTool?: CustomAgentTool;
|
private customTool?: CustomAgentTool;
|
||||||
|
private ui: TUI;
|
||||||
private result?: {
|
private result?: {
|
||||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
details?: any;
|
details?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(toolName: string, args: any, options: ToolExecutionOptions = {}, customTool?: CustomAgentTool) {
|
constructor(
|
||||||
|
toolName: string,
|
||||||
|
args: any,
|
||||||
|
options: ToolExecutionOptions = {},
|
||||||
|
customTool: CustomAgentTool | undefined,
|
||||||
|
ui: TUI,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
this.toolName = toolName;
|
this.toolName = toolName;
|
||||||
this.args = args;
|
this.args = args;
|
||||||
this.showImages = options.showImages ?? true;
|
this.showImages = options.showImages ?? true;
|
||||||
this.customTool = customTool;
|
this.customTool = customTool;
|
||||||
|
this.ui = ui;
|
||||||
|
|
||||||
this.addChild(new Spacer(1));
|
this.addChild(new Spacer(1));
|
||||||
|
|
||||||
if (customTool) {
|
// Always create both - contentBox for custom tools/bash, contentText for other built-ins
|
||||||
// Custom tools use Box for flexible component rendering
|
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
|
||||||
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.addChild(this.contentBox);
|
||||||
this.contentText = new Text("", 0, 0); // Fallback only
|
|
||||||
} else {
|
} 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);
|
this.addChild(this.contentText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,7 +129,7 @@ export class ToolExecutionComponent extends Container {
|
||||||
: (text: string) => theme.bg("toolSuccessBg", text);
|
: (text: string) => theme.bg("toolSuccessBg", text);
|
||||||
|
|
||||||
// Check for custom tool rendering
|
// Check for custom tool rendering
|
||||||
if (this.customTool && this.contentBox) {
|
if (this.customTool) {
|
||||||
// Custom tools use Box for flexible component rendering
|
// Custom tools use Box for flexible component rendering
|
||||||
this.contentBox.setBgFn(bgFn);
|
this.contentBox.setBgFn(bgFn);
|
||||||
this.contentBox.clear();
|
this.contentBox.clear();
|
||||||
|
|
@ -163,8 +175,13 @@ export class ToolExecutionComponent extends Container {
|
||||||
this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
|
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 {
|
} else {
|
||||||
// Built-in tools: use Text directly with caching
|
// Other built-in tools: use Text directly with caching
|
||||||
this.contentText.setCustomBgFn(bgFn);
|
this.contentText.setCustomBgFn(bgFn);
|
||||||
this.contentText.setText(this.formatToolExecution());
|
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 {
|
private getTextOutput(): string {
|
||||||
if (!this.result) return "";
|
if (!this.result) return "";
|
||||||
|
|
||||||
|
|
@ -233,46 +319,7 @@ export class ToolExecutionComponent extends Container {
|
||||||
private formatToolExecution(): string {
|
private formatToolExecution(): string {
|
||||||
let text = "";
|
let text = "";
|
||||||
|
|
||||||
if (this.toolName === "bash") {
|
if (this.toolName === "read") {
|
||||||
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") {
|
|
||||||
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
||||||
const offset = this.args?.offset;
|
const offset = this.args?.offset;
|
||||||
const limit = this.args?.limit;
|
const limit = this.args?.limit;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -815,6 +815,7 @@ export class InteractiveMode {
|
||||||
showImages: this.settingsManager.getShowImages(),
|
showImages: this.settingsManager.getShowImages(),
|
||||||
},
|
},
|
||||||
this.customTools.get(content.name)?.tool,
|
this.customTools.get(content.name)?.tool,
|
||||||
|
this.ui,
|
||||||
);
|
);
|
||||||
this.chatContainer.addChild(component);
|
this.chatContainer.addChild(component);
|
||||||
this.pendingTools.set(content.id, component);
|
this.pendingTools.set(content.id, component);
|
||||||
|
|
@ -862,6 +863,7 @@ export class InteractiveMode {
|
||||||
showImages: this.settingsManager.getShowImages(),
|
showImages: this.settingsManager.getShowImages(),
|
||||||
},
|
},
|
||||||
this.customTools.get(event.toolName)?.tool,
|
this.customTools.get(event.toolName)?.tool,
|
||||||
|
this.ui,
|
||||||
);
|
);
|
||||||
this.chatContainer.addChild(component);
|
this.chatContainer.addChild(component);
|
||||||
this.pendingTools.set(event.toolCallId, component);
|
this.pendingTools.set(event.toolCallId, component);
|
||||||
|
|
@ -1101,6 +1103,7 @@ export class InteractiveMode {
|
||||||
showImages: this.settingsManager.getShowImages(),
|
showImages: this.settingsManager.getShowImages(),
|
||||||
},
|
},
|
||||||
this.customTools.get(content.name)?.tool,
|
this.customTools.get(content.name)?.tool,
|
||||||
|
this.ui,
|
||||||
);
|
);
|
||||||
this.chatContainer.addChild(component);
|
this.chatContainer.addChild(component);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue