Files
my-pi/packages/coding-agent/src/modes/interactive/components/tool-execution.ts
T
Mario Zechner 7ad8a8c447 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
2025-12-22 17:01:04 +01:00

574 lines
18 KiB
TypeScript

import * as os from "node:os";
import {
Box,
Container,
getCapabilities,
getImageDimensions,
Image,
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
*/
function shortenPath(path: string): string {
const home = os.homedir();
if (path.startsWith(home)) {
return `~${path.slice(home.length)}`;
}
return path;
}
/**
* Replace tabs with spaces for consistent rendering
*/
function replaceTabs(text: string): string {
return text.replace(/\t/g, " ");
}
export interface ToolExecutionOptions {
showImages?: boolean; // default: true (only used if terminal supports images)
}
/**
* Component that renders a tool call with its result (updateable)
*/
export class ToolExecutionComponent extends Container {
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[] = [];
private toolName: string;
private args: any;
private expanded = false;
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 | 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));
// 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);
} else {
this.addChild(this.contentText);
}
this.updateDisplay();
}
updateArgs(args: any): void {
this.args = args;
this.updateDisplay();
}
updateResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: any;
isError: boolean;
},
isPartial = false,
): void {
this.result = result;
this.isPartial = isPartial;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
setShowImages(show: boolean): void {
this.showImages = show;
this.updateDisplay();
}
private updateDisplay(): void {
// Set background based on state
const bgFn = this.isPartial
? (text: string) => theme.bg("toolPendingBg", text)
: this.result?.isError
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text);
// Check for custom tool rendering
if (this.customTool) {
// Custom tools use Box for flexible component rendering
this.contentBox.setBgFn(bgFn);
this.contentBox.clear();
// Render call component
if (this.customTool.renderCall) {
try {
const callComponent = this.customTool.renderCall(this.args, theme);
if (callComponent) {
this.contentBox.addChild(callComponent);
}
} catch {
// Fall back to default on error
this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
}
} else {
// No custom renderCall, show tool name
this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
}
// Render result component if we have a result
if (this.result && this.customTool.renderResult) {
try {
const resultComponent = this.customTool.renderResult(
{ content: this.result.content as any, details: this.result.details },
{ expanded: this.expanded, isPartial: this.isPartial },
theme,
);
if (resultComponent) {
this.contentBox.addChild(resultComponent);
}
} catch {
// Fall back to showing raw output on error
const output = this.getTextOutput();
if (output) {
this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
}
}
} else if (this.result) {
// Has result but no custom renderResult
const output = this.getTextOutput();
if (output) {
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 {
// Other built-in tools: use Text directly with caching
this.contentText.setCustomBgFn(bgFn);
this.contentText.setText(this.formatToolExecution());
}
// Handle images (same for both custom and built-in)
for (const img of this.imageComponents) {
this.removeChild(img);
}
this.imageComponents = [];
for (const spacer of this.imageSpacers) {
this.removeChild(spacer);
}
this.imageSpacers = [];
if (this.result) {
const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
const caps = getCapabilities();
for (const img of imageBlocks) {
if (caps.images && this.showImages && img.data && img.mimeType) {
const spacer = new Spacer(1);
this.addChild(spacer);
this.imageSpacers.push(spacer);
const imageComponent = new Image(
img.data,
img.mimeType,
{ fallbackColor: (s: string) => theme.fg("toolOutput", s) },
{ maxWidthCells: 60 },
);
this.imageComponents.push(imageComponent);
this.addChild(imageComponent);
}
}
}
}
/**
* 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 "";
const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
let output = textBlocks
.map((c: any) => {
let text = stripAnsi(c.text || "").replace(/\r/g, "");
text = text.replace(/\x1b./g, "");
text = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
return text;
})
.join("\n");
const caps = getCapabilities();
if (imageBlocks.length > 0 && (!caps.images || !this.showImages)) {
const imageIndicators = imageBlocks
.map((img: any) => {
const dims = img.data ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
return imageFallback(img.mimeType, dims);
})
.join("\n");
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
}
return output;
}
private formatToolExecution(): string {
let text = "";
if (this.toolName === "read") {
const path = shortenPath(this.args?.file_path || this.args?.path || "");
const offset = this.args?.offset;
const limit = this.args?.limit;
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : "";
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
text = `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
if (this.result) {
const output = this.getTextOutput();
const rawPath = this.args?.file_path || this.args?.path || "";
const lang = getLanguageFromPath(rawPath);
const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
const maxLines = this.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text +=
"\n\n" +
displayLines
.map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
.join("\n");
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
const truncation = this.result.details?.truncation;
if (truncation?.truncated) {
if (truncation.firstLineExceedsLimit) {
text +=
"\n" +
theme.fg(
"warning",
`[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`,
);
} else if (truncation.truncatedBy === "lines") {
text +=
"\n" +
theme.fg(
"warning",
`[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`,
);
} else {
text +=
"\n" +
theme.fg(
"warning",
`[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`,
);
}
}
}
} else if (this.toolName === "write") {
const rawPath = this.args?.file_path || this.args?.path || "";
const path = shortenPath(rawPath);
const fileContent = this.args?.content || "";
const lang = getLanguageFromPath(rawPath);
const lines = fileContent
? lang
? highlightCode(replaceTabs(fileContent), lang)
: fileContent.split("\n")
: [];
const totalLines = lines.length;
text =
theme.fg("toolTitle", theme.bold("write")) +
" " +
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
if (totalLines > 10) {
text += ` (${totalLines} lines)`;
}
if (fileContent) {
const maxLines = this.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text +=
"\n\n" +
displayLines
.map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
.join("\n");
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
} else if (this.toolName === "edit") {
const rawPath = this.args?.file_path || this.args?.path || "";
const path = shortenPath(rawPath);
text =
theme.fg("toolTitle", theme.bold("edit")) +
" " +
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
if (this.result) {
if (this.result.isError) {
const errorText = this.getTextOutput();
if (errorText) {
text += `\n\n${theme.fg("error", errorText)}`;
}
} else if (this.result.details?.diff) {
text += `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath })}`;
}
}
} else if (this.toolName === "ls") {
const path = shortenPath(this.args?.path || ".");
const limit = this.args?.limit;
text = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", path)}`;
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
if (this.result) {
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
const entryLimit = this.result.details?.entryLimitReached;
const truncation = this.result.details?.truncation;
if (entryLimit || truncation?.truncated) {
const warnings: string[] = [];
if (entryLimit) {
warnings.push(`${entryLimit} entries limit`);
}
if (truncation?.truncated) {
warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
}
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
}
} else if (this.toolName === "find") {
const pattern = this.args?.pattern || "";
const path = shortenPath(this.args?.path || ".");
const limit = this.args?.limit;
text =
theme.fg("toolTitle", theme.bold("find")) +
" " +
theme.fg("accent", pattern) +
theme.fg("toolOutput", ` in ${path}`);
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
if (this.result) {
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
const resultLimit = this.result.details?.resultLimitReached;
const truncation = this.result.details?.truncation;
if (resultLimit || truncation?.truncated) {
const warnings: string[] = [];
if (resultLimit) {
warnings.push(`${resultLimit} results limit`);
}
if (truncation?.truncated) {
warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
}
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
}
} else if (this.toolName === "grep") {
const pattern = this.args?.pattern || "";
const path = shortenPath(this.args?.path || ".");
const glob = this.args?.glob;
const limit = this.args?.limit;
text =
theme.fg("toolTitle", theme.bold("grep")) +
" " +
theme.fg("accent", `/${pattern}/`) +
theme.fg("toolOutput", ` in ${path}`);
if (glob) {
text += theme.fg("toolOutput", ` (${glob})`);
}
if (limit !== undefined) {
text += theme.fg("toolOutput", ` limit ${limit}`);
}
if (this.result) {
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 15;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
const matchLimit = this.result.details?.matchLimitReached;
const truncation = this.result.details?.truncation;
const linesTruncated = this.result.details?.linesTruncated;
if (matchLimit || truncation?.truncated || linesTruncated) {
const warnings: string[] = [];
if (matchLimit) {
warnings.push(`${matchLimit} matches limit`);
}
if (truncation?.truncated) {
warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
}
if (linesTruncated) {
warnings.push("some lines truncated");
}
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
}
} else {
// Generic tool (shouldn't reach here for custom tools)
text = theme.fg("toolTitle", theme.bold(this.toolName));
const content = JSON.stringify(this.args, null, 2);
text += `\n\n${content}`;
const output = this.getTextOutput();
if (output) {
text += `\n${output}`;
}
}
return text;
}
}