import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.js"; import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.js"; import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js"; import { convertToPng } from "../../../utils/image-convert.js"; import { theme } from "../theme/theme.js"; export interface ToolExecutionOptions { showImages?: boolean; imageWidthCells?: number; } export class ToolExecutionComponent extends Container { private contentBox: Box; private contentText: Text; private selfRenderContainer: Container; private callRendererComponent?: Component; private resultRendererComponent?: Component; private rendererState: any = {}; private imageComponents: Image[] = []; private imageSpacers: Spacer[] = []; private toolName: string; private toolCallId: string; private args: any; private expanded = false; private showImages: boolean; private imageWidthCells: number; private isPartial = true; private toolDefinition?: ToolDefinition; private builtInToolDefinition?: ToolDefinition; private ui: TUI; private cwd: string; private executionStarted = false; private argsComplete = false; private result?: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError: boolean; details?: any; }; private convertedImages: Map = new Map(); private hideComponent = false; constructor( toolName: string, toolCallId: string, args: any, options: ToolExecutionOptions = {}, toolDefinition: ToolDefinition | undefined, ui: TUI, cwd: string, ) { super(); this.toolName = toolName; this.toolCallId = toolCallId; this.args = args; this.toolDefinition = toolDefinition; this.builtInToolDefinition = createAllToolDefinitions(cwd)[toolName as ToolName]; this.showImages = options.showImages ?? true; this.imageWidthCells = options.imageWidthCells ?? 60; this.ui = ui; this.cwd = cwd; this.addChild(new Spacer(1)); // Always create all shell variants. contentBox is used for default renderer-based composition. // selfRenderContainer is used when the tool renders its own framing. // contentText is reserved for generic fallback rendering when no tool definition exists. this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text)); this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text)); this.selfRenderContainer = new Container(); if (this.hasRendererDefinition()) { this.addChild(this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox); } else { this.addChild(this.contentText); } this.updateDisplay(); } private getCallRenderer(): ToolDefinition["renderCall"] | undefined { if (!this.builtInToolDefinition) { return this.toolDefinition?.renderCall; } if (!this.toolDefinition) { return this.builtInToolDefinition.renderCall; } return this.toolDefinition.renderCall ?? this.builtInToolDefinition.renderCall; } private getResultRenderer(): ToolDefinition["renderResult"] | undefined { if (!this.builtInToolDefinition) { return this.toolDefinition?.renderResult; } if (!this.toolDefinition) { return this.builtInToolDefinition.renderResult; } return this.toolDefinition.renderResult ?? this.builtInToolDefinition.renderResult; } private hasRendererDefinition(): boolean { return this.builtInToolDefinition !== undefined || this.toolDefinition !== undefined; } private getRenderShell(): "default" | "self" { if (!this.builtInToolDefinition) { return this.toolDefinition?.renderShell ?? "default"; } if (!this.toolDefinition) { return this.builtInToolDefinition.renderShell ?? "default"; } return this.toolDefinition.renderShell ?? this.builtInToolDefinition.renderShell ?? "default"; } private getRenderContext(lastComponent: Component | undefined): ToolRenderContext { return { args: this.args, toolCallId: this.toolCallId, invalidate: () => { this.invalidate(); this.ui.requestRender(); }, lastComponent, state: this.rendererState, cwd: this.cwd, executionStarted: this.executionStarted, argsComplete: this.argsComplete, isPartial: this.isPartial, expanded: this.expanded, showImages: this.showImages, isError: this.result?.isError ?? false, }; } private createCallFallback(): Component { return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0); } private createResultFallback(): Component | undefined { const output = this.getTextOutput(); if (!output) { return undefined; } return new Text(theme.fg("toolOutput", output), 0, 0); } updateArgs(args: any): void { this.args = args; this.updateDisplay(); } markExecutionStarted(): void { this.executionStarted = true; this.updateDisplay(); this.ui.requestRender(); } setArgsComplete(): void { this.argsComplete = true; this.updateDisplay(); this.ui.requestRender(); } 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(); this.maybeConvertImagesForKitty(); } private maybeConvertImagesForKitty(): void { const caps = getCapabilities(); if (caps.images !== "kitty") return; if (!this.result) return; const imageBlocks = this.result.content.filter((c) => c.type === "image"); for (let i = 0; i < imageBlocks.length; i++) { const img = imageBlocks[i]; if (!img.data || !img.mimeType) continue; if (img.mimeType === "image/png") continue; if (this.convertedImages.has(i)) continue; const index = i; convertToPng(img.data, img.mimeType).then((converted) => { if (converted) { this.convertedImages.set(index, converted); this.updateDisplay(); this.ui.requestRender(); } }); } } setExpanded(expanded: boolean): void { this.expanded = expanded; this.updateDisplay(); } setShowImages(show: boolean): void { this.showImages = show; this.updateDisplay(); } setImageWidthCells(width: number): void { this.imageWidthCells = Math.max(1, Math.floor(width)); this.updateDisplay(); } override invalidate(): void { super.invalidate(); this.updateDisplay(); } override render(width: number): string[] { if (this.hideComponent) { return []; } return super.render(width); } private updateDisplay(): void { 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); let hasContent = false; this.hideComponent = false; if (this.hasRendererDefinition()) { const renderContainer = this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox; if (renderContainer instanceof Box) { renderContainer.setBgFn(bgFn); } renderContainer.clear(); const callRenderer = this.getCallRenderer(); if (!callRenderer) { renderContainer.addChild(this.createCallFallback()); hasContent = true; } else { try { const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent)); this.callRendererComponent = component; renderContainer.addChild(component); hasContent = true; } catch { this.callRendererComponent = undefined; renderContainer.addChild(this.createCallFallback()); hasContent = true; } } if (this.result) { const resultRenderer = this.getResultRenderer(); if (!resultRenderer) { const component = this.createResultFallback(); if (component) { renderContainer.addChild(component); hasContent = true; } } else { try { const component = resultRenderer( { content: this.result.content as any, details: this.result.details }, { expanded: this.expanded, isPartial: this.isPartial }, theme, this.getRenderContext(this.resultRendererComponent), ); this.resultRendererComponent = component; renderContainer.addChild(component); hasContent = true; } catch { this.resultRendererComponent = undefined; const component = this.createResultFallback(); if (component) { renderContainer.addChild(component); hasContent = true; } } } } } else { this.contentText.setCustomBgFn(bgFn); this.contentText.setText(this.formatToolExecution()); hasContent = true; } 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) => c.type === "image"); const caps = getCapabilities(); for (let i = 0; i < imageBlocks.length; i++) { const img = imageBlocks[i]; if (caps.images && this.showImages && img.data && img.mimeType) { const converted = this.convertedImages.get(i); const imageData = converted?.data ?? img.data; const imageMimeType = converted?.mimeType ?? img.mimeType; if (caps.images === "kitty" && imageMimeType !== "image/png") continue; const spacer = new Spacer(1); this.addChild(spacer); this.imageSpacers.push(spacer); const imageComponent = new Image( imageData, imageMimeType, { fallbackColor: (s: string) => theme.fg("toolOutput", s) }, { maxWidthCells: this.imageWidthCells }, ); this.imageComponents.push(imageComponent); this.addChild(imageComponent); } } } if (this.hasRendererDefinition() && !hasContent && this.imageComponents.length === 0) { this.hideComponent = true; } } private getTextOutput(): string { return getRenderedTextOutput(this.result, this.showImages); } private formatToolExecution(): string { let text = theme.fg("toolTitle", theme.bold(this.toolName)); const content = JSON.stringify(this.args, null, 2); if (content) { text += `\n\n${content}`; } const output = this.getTextOutput(); if (output) { text += `\n${output}`; } return text; } }