// existsSync, readFileSync removed - no longer needed since plan content // is shown via StaticPlanApproval during approval, not in tool result import { Box, Text } from "ink"; import { memo } from "react"; import { INTERRUPTED_BY_USER } from "../../constants"; import { clipToolReturn } from "../../tools/manager.js"; import type { AdvancedDiffSuccess } from "../helpers/diff"; import { formatArgsDisplay, parsePatchInput, parsePatchOperations, } from "../helpers/formatArgsDisplay.js"; import { getDisplayToolName, isFileEditTool, isFileWriteTool, isMemoryTool, isPatchTool, isPlanTool, isTaskTool, isTodoTool, } from "../helpers/toolNameMapping.js"; /** * Check if tool is AskUserQuestion */ function isQuestionTool(name: string): boolean { return name === "AskUserQuestion"; } import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer"; import { BlinkDot } from "./BlinkDot.js"; import { colors } from "./colors.js"; import { EditRenderer, MultiEditRenderer, WriteRenderer, } from "./DiffRenderer.js"; import { MarkdownDisplay } from "./MarkdownDisplay.js"; import { MemoryDiffRenderer } from "./MemoryDiffRenderer.js"; import { PlanRenderer } from "./PlanRenderer.js"; import { TodoRenderer } from "./TodoRenderer.js"; type ToolCallLine = { kind: "tool_call"; id: string; toolCallId?: string; name?: string; argsText?: string; resultText?: string; resultOk?: boolean; phase: "streaming" | "ready" | "running" | "finished"; }; /** * ToolCallMessageRich - Rich formatting version with old layout logic * This preserves the exact wrapping and spacing logic from the old codebase * * Features: * - Two-column layout for tool calls (2 chars for dot) * - Smart wrapping that keeps function name and args together when possible * - Blinking dots for pending/running states * - Result shown with ⎿ prefix underneath */ export const ToolCallMessage = memo( ({ line, precomputedDiffs, lastPlanFilePath, }: { line: ToolCallLine; precomputedDiffs?: Map; lastPlanFilePath?: string | null; }) => { const columns = useTerminalWidth(); // Parse and format the tool call const rawName = line.name ?? "?"; const argsText = line.argsText ?? "..."; // Task tool rendering decision: // - Cancelled/rejected: render as error tool call (won't appear in SubagentGroupDisplay) // - Finished with success: render as normal tool call (for backfilled tools without subagent data) // - In progress: don't render here (SubagentGroupDisplay handles running subagents, // and liveItems handles pending approvals via InlineGenericApproval) if (isTaskTool(rawName)) { const isFinished = line.phase === "finished"; if (!isFinished) { // Not finished - SubagentGroupDisplay or approval UI handles this return null; } // Finished Task tools render here (both success and error) } // Apply tool name remapping let displayName = getDisplayToolName(rawName); // For Patch tools, override display name based on patch content // (Add → Write, Update → Update, Delete → Delete) if (isPatchTool(rawName)) { try { const parsedArgs = JSON.parse(argsText); if (parsedArgs.input) { const patchInfo = parsePatchInput(parsedArgs.input); if (patchInfo) { if (patchInfo.kind === "add") displayName = "Write"; else if (patchInfo.kind === "update") displayName = "Update"; else if (patchInfo.kind === "delete") displayName = "Delete"; } } } catch { // Keep default "Patch" name if parsing fails } } // For AskUserQuestion, show friendly header only after completion if (isQuestionTool(rawName)) { if (line.phase === "finished" && line.resultOk !== false) { displayName = "User answered Letta Code's questions:"; } else { displayName = "Asking user questions..."; } } // Format arguments for display using the old formatting logic // Pass rawName to enable special formatting for file tools const formatted = formatArgsDisplay(argsText, rawName); // Hide args for question tool (shown in result instead) const args = isQuestionTool(rawName) ? "" : `(${formatted.display})`; const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols // If name exceeds available width, fall back to simple wrapped rendering const fallback = displayName.length >= rightWidth; // Determine dot state based on phase const getDotElement = () => { switch (line.phase) { case "streaming": return ; case "ready": return ; case "running": return ; case "finished": if (line.resultOk === false) { return ; } return ; default: return ; } }; // Format result for display const getResultElement = () => { if (!line.resultText) return null; const prefix = ` ⎿ `; // Match old format: 2 spaces, glyph, 2 spaces const prefixWidth = 5; // Total width of prefix const contentWidth = Math.max(0, columns - prefixWidth); // Special cases from old ToolReturnBlock (check before truncation) if (line.resultText === "Running...") { return ( {prefix} Running... ); } if (line.resultText === INTERRUPTED_BY_USER) { return ( {prefix} {INTERRUPTED_BY_USER} ); } // Truncate the result text for display (UI only, API gets full response) // Strip trailing newlines to avoid extra visual spacing (e.g., from bash echo) const displayResultText = clipToolReturn(line.resultText).replace( /\n+$/, "", ); // Helper to check if a value is a record const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null; // Check if this is a todo_write tool with successful result if ( isTodoTool(rawName, displayName) && line.resultOk !== false && line.argsText ) { try { const parsedArgs = JSON.parse(line.argsText); if (parsedArgs.todos && Array.isArray(parsedArgs.todos)) { // Convert todos to safe format for TodoRenderer // Note: Anthropic/Codex use "content", Gemini uses "description" const safeTodos = parsedArgs.todos.map((t: unknown, i: number) => { const rec = isRecord(t) ? t : {}; const status: "pending" | "in_progress" | "completed" = rec.status === "completed" ? "completed" : rec.status === "in_progress" ? "in_progress" : "pending"; const id = typeof rec.id === "string" ? rec.id : String(i); // Handle both "content" (Anthropic/Codex) and "description" (Gemini) fields const content = typeof rec.content === "string" ? rec.content : typeof rec.description === "string" ? rec.description : JSON.stringify(t); const priority: "high" | "medium" | "low" | undefined = rec.priority === "high" ? "high" : rec.priority === "medium" ? "medium" : rec.priority === "low" ? "low" : undefined; return { content, status, id, priority }; }); // Return TodoRenderer directly - it has its own prefix return ; } } catch { // If parsing fails, fall through to regular handling } } // Check if this is an update_plan tool with successful result if ( isPlanTool(rawName, displayName) && line.resultOk !== false && line.argsText ) { try { const parsedArgs = JSON.parse(line.argsText); if (parsedArgs.plan && Array.isArray(parsedArgs.plan)) { // Convert plan items to safe format for PlanRenderer const safePlan = parsedArgs.plan.map((item: unknown) => { const rec = isRecord(item) ? item : {}; const status: "pending" | "in_progress" | "completed" = rec.status === "completed" ? "completed" : rec.status === "in_progress" ? "in_progress" : "pending"; const step = typeof rec.step === "string" ? rec.step : JSON.stringify(item); return { step, status }; }); const explanation = typeof parsedArgs.explanation === "string" ? parsedArgs.explanation : undefined; // Return PlanRenderer directly - it has its own prefix return ; } } catch { // If parsing fails, fall through to regular handling } } // Check if this is a memory tool - show diff instead of raw result if (isMemoryTool(rawName) && line.resultOk !== false && line.argsText) { const memoryDiff = ( ); if (memoryDiff) { return memoryDiff; } // If MemoryDiffRenderer returns null, fall through to regular handling } // Check if this is AskUserQuestion - show pretty Q&A format if (isQuestionTool(rawName) && line.resultOk !== false) { // Parse the result to extract questions and answers // Format: "Question"="Answer", "Question2"="Answer2" const qaPairs: Array<{ question: string; answer: string }> = []; const qaRegex = /"([^"]+)"="([^"]*)"/g; const resultText = line.resultText || ""; const matches = resultText.matchAll(qaRegex); for (const match of matches) { if (match[1] && match[2] !== undefined) { qaPairs.push({ question: match[1], answer: match[2] }); } } if (qaPairs.length > 0) { return ( {qaPairs.map((qa) => ( {prefix} · {qa.question}{" "} {qa.answer} ))} ); } // Fall through to regular handling if parsing fails } // Check if this is ExitPlanMode - just show path, not plan content // The plan content was already shown during approval via StaticPlanApproval // (rendered via Ink's and is visible in terminal scrollback) if (rawName === "ExitPlanMode" && line.resultOk !== false) { const planFilePath = lastPlanFilePath; if (planFilePath) { return ( {prefix} Plan saved to: {planFilePath} ); } // Fall through to default if no plan path } // Check if this is a file edit tool - show diff instead of success message if (isFileEditTool(rawName) && line.resultOk !== false && line.argsText) { const diff = line.toolCallId ? precomputedDiffs?.get(line.toolCallId) : undefined; try { const parsedArgs = JSON.parse(line.argsText); const filePath = parsedArgs.file_path || ""; // Use AdvancedDiffRenderer if we have a precomputed diff if (diff) { // Multi-edit: has edits array if (parsedArgs.edits && Array.isArray(parsedArgs.edits)) { const edits = parsedArgs.edits.map( (e: { old_string?: string; new_string?: string; replace_all?: boolean; }) => ({ old_string: e.old_string || "", new_string: e.new_string || "", replace_all: e.replace_all, }), ); return ( ); } // Single edit return ( ); } // Fallback to simple renderers when no precomputed diff // Multi-edit: has edits array if (parsedArgs.edits && Array.isArray(parsedArgs.edits)) { const edits = parsedArgs.edits.map( (e: { old_string?: string; new_string?: string }) => ({ old_string: e.old_string || "", new_string: e.new_string || "", }), ); return ( ); } // Single edit: has old_string/new_string if (parsedArgs.old_string !== undefined) { return ( ); } } catch { // If parsing fails, fall through to regular handling } } // Check if this is a file write tool - show written content if ( isFileWriteTool(rawName) && line.resultOk !== false && line.argsText ) { const diff = line.toolCallId ? precomputedDiffs?.get(line.toolCallId) : undefined; try { const parsedArgs = JSON.parse(line.argsText); const filePath = parsedArgs.file_path || ""; const content = parsedArgs.content || ""; if (filePath && content) { if (diff) { return ( ); } return ; } } catch { // If parsing fails, fall through to regular handling } } // Check if this is a patch tool - show diff/content based on operation type if (isPatchTool(rawName) && line.resultOk !== false && line.argsText) { try { const parsedArgs = JSON.parse(line.argsText); if (parsedArgs.input) { const operations = parsePatchOperations(parsedArgs.input); if (operations.length > 0) { return ( {operations.map((op) => { // Look up precomputed diff using compound key const key = `${line.toolCallId}:${op.path}`; const diff = precomputedDiffs?.get(key); if (op.kind === "add") { return diff ? ( ) : ( ); } if (op.kind === "update") { return diff ? ( ) : ( ); } if (op.kind === "delete") { const gutterWidth = 4; return ( {" "} Deleted {op.path} ); } return null; })} ); } } } catch { // If parsing fails, fall through to regular handling } } // Regular result handling const isError = line.resultOk === false; // Try to parse JSON for cleaner error display let displayText = displayResultText; try { const parsed = JSON.parse(displayResultText); if (parsed.error && typeof parsed.error === "string") { displayText = parsed.error; } } catch { // Not JSON, use raw text } // Format tool denial errors more user-friendly if (isError && displayText.includes("request to call tool denied")) { // Use [\s\S]+ to match multiline reasons const match = displayText.match(/User reason: ([\s\S]+)$/); const reason = match?.[1]?.trim() || "(empty)"; displayText = `User rejected the tool call with reason: ${reason}`; } return ( {prefix} {isError ? ( {displayText} ) : ( )} ); }; return ( {/* Tool call with exact wrapping logic from old codebase */} {getDotElement()} {fallback ? ( {isMemoryTool(rawName) ? ( <> {displayName} {args} ) : ( <> {displayName} {args} )} ) : ( {displayName} {args ? ( {args} ) : null} )} {/* Tool result (if present) */} {getResultElement()} ); }, ); ToolCallMessage.displayName = "ToolCallMessage";