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); // Build path display, appending :line if we have a successful result with line info let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); if (this.result && !this.result.isError && this.result.details?.firstChangedLine) { pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`); } text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; 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; } }