Files
letta-code/src/cli/components/ToolCallMessageRich.tsx
2026-02-09 20:59:27 -08:00

879 lines
30 KiB
TypeScript

// existsSync, readFileSync removed - no longer needed since plan content
// is shown via StaticPlanApproval during approval, not in tool result
import { Box } 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 { getSubagentByToolCallId } from "../helpers/subagentState.js";
import {
getDisplayToolName,
isFileEditTool,
isFileReadTool,
isFileWriteTool,
isGlobTool,
isMemoryTool,
isPatchTool,
isPlanTool,
isSearchTool,
isTaskTool,
isTodoTool,
} from "../helpers/toolNameMapping.js";
import { Text } from "./Text";
/**
* Check if tool is AskUserQuestion
*/
function isQuestionTool(name: string): boolean {
return name === "AskUserQuestion";
}
import type { StreamingState } from "../helpers/accumulator";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
import { BlinkDot } from "./BlinkDot.js";
import { CollapsedOutputDisplay } from "./CollapsedOutputDisplay";
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 { StreamingOutputDisplay } from "./StreamingOutputDisplay";
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";
streaming?: StreamingState;
};
/**
* Check if tool is a shell/bash tool that supports streaming output
*/
function isShellTool(name: string): boolean {
const shellTools = [
"Bash",
"Shell",
"shell",
"shell_command",
"run_shell_command",
"RunShellCommand",
"ShellCommand",
];
return shellTools.includes(name);
}
/**
* 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,
isStreaming,
}: {
line: ToolCallLine;
precomputedDiffs?: Map<string, AdvancedDiffSuccess>;
lastPlanFilePath?: string | null;
isStreaming?: boolean;
}) => {
const columns = useTerminalWidth();
// Parse and format the tool call
const rawName = line.name ?? "?";
const argsText =
typeof line.argsText === "string"
? line.argsText
: line.argsText == null
? ""
: JSON.stringify(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";
const subagent = line.toolCallId
? getSubagentByToolCallId(line.toolCallId)
: undefined;
if (subagent) {
// Task tool calls with subagent data are handled by SubagentGroupDisplay/Static
return null;
}
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...";
}
}
const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
// Determine args display:
// - Question tool: hide args (shown in result instead)
// - Still streaming + phase "ready": args may be incomplete, show ellipsis
// - Phase "running"/"finished" or stream done: args complete, show formatted
let args = "";
if (!isQuestionTool(rawName)) {
const parseArgs = (): {
formatted: ReturnType<typeof formatArgsDisplay> | null;
parseable: boolean;
} => {
if (!argsText.trim()) {
return { formatted: null, parseable: true };
}
try {
const formatted = formatArgsDisplay(argsText, rawName);
return { formatted, parseable: true };
} catch {
return { formatted: null, parseable: false };
}
};
// Args are complete once running/finished, stream done, or JSON is parseable.
const { formatted, parseable } = parseArgs();
const argsComplete =
parseable ||
line.phase === "running" ||
line.phase === "finished" ||
!isStreaming;
if (!argsComplete) {
args = "(…)";
} else {
const formattedArgs = formatted ?? formatArgsDisplay(argsText, rawName);
// Normalize newlines to spaces to prevent forced line breaks
const normalizedDisplay = formattedArgs.display.replace(/\n/g, " ");
// For max 2 lines: boxWidth * 2, minus parens (2) and margin (2)
const argsBoxWidth = rightWidth - displayName.length;
const maxArgsChars = Math.max(0, argsBoxWidth * 2 - 4);
const needsTruncation = normalizedDisplay.length > maxArgsChars;
const truncatedDisplay = needsTruncation
? `${normalizedDisplay.slice(0, maxArgsChars - 1)}`
: normalizedDisplay;
args = `(${truncatedDisplay})`;
}
}
// If name exceeds available width, fall back to simple wrapped rendering
const fallback = displayName.length >= rightWidth;
const dotColor = (() => {
switch (line.phase) {
case "streaming":
return colors.tool.streaming;
case "ready":
return colors.tool.pending;
case "running":
return colors.tool.running;
case "finished":
return line.resultOk === false
? colors.tool.error
: colors.tool.completed;
default:
return undefined;
}
})();
const dotShouldAnimate =
line.phase === "running" || (line.phase === "ready" && !isStreaming);
// 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 (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text dimColor>Running...</Text>
</Box>
</Box>
);
}
if (line.resultText === INTERRUPTED_BY_USER) {
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text color={colors.status.interrupt}>{INTERRUPTED_BY_USER}</Text>
</Box>
</Box>
);
}
// 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<string, unknown> =>
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 <TodoRenderer todos={safeTodos} />;
}
} 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 <PlanRenderer plan={safePlan} explanation={explanation} />;
}
} 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 = (
<MemoryDiffRenderer argsText={line.argsText} toolName={rawName} />
);
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 (
<Box flexDirection="column">
{qaPairs.map((qa) => (
<Box key={qa.question} flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text wrap="wrap">
<Text dimColor>·</Text> {qa.question}{" "}
<Text dimColor></Text> {qa.answer}
</Text>
</Box>
</Box>
))}
</Box>
);
}
// 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 <Static> and is visible in terminal scrollback)
if (rawName === "ExitPlanMode" && line.resultOk !== false) {
const planFilePath = lastPlanFilePath;
if (planFilePath) {
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text dimColor>Plan saved to: {planFilePath}</Text>
</Box>
</Box>
);
}
// 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 (
<AdvancedDiffRenderer
precomputed={diff}
kind="multi_edit"
filePath={filePath}
edits={edits}
/>
);
}
// Single edit
return (
<AdvancedDiffRenderer
precomputed={diff}
kind="edit"
filePath={filePath}
oldString={parsedArgs.old_string || ""}
newString={parsedArgs.new_string || ""}
replaceAll={parsedArgs.replace_all}
/>
);
}
// 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 (
<MultiEditRenderer
filePath={filePath}
edits={edits}
showLineNumbers={false}
/>
);
}
// Single edit: has old_string/new_string
if (parsedArgs.old_string !== undefined) {
return (
<EditRenderer
filePath={filePath}
oldString={parsedArgs.old_string || ""}
newString={parsedArgs.new_string || ""}
showLineNumbers={false}
/>
);
}
} 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 (
<AdvancedDiffRenderer
precomputed={diff}
kind="write"
filePath={filePath}
content={content}
/>
);
}
return <WriteRenderer filePath={filePath} content={content} />;
}
} 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 (
<Box flexDirection="column">
{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 ? (
<AdvancedDiffRenderer
key={`patch-add-${op.path}`}
precomputed={diff}
kind="write"
filePath={op.path}
content={op.content}
/>
) : (
<WriteRenderer
key={`patch-add-${op.path}`}
filePath={op.path}
content={op.content}
/>
);
}
if (op.kind === "update") {
return diff ? (
<AdvancedDiffRenderer
key={`patch-update-${op.path}`}
precomputed={diff}
kind="edit"
filePath={op.path}
oldString={op.oldString}
newString={op.newString}
/>
) : (
<EditRenderer
key={`patch-update-${op.path}`}
filePath={op.path}
oldString={op.oldString}
newString={op.newString}
showLineNumbers={false}
/>
);
}
if (op.kind === "delete") {
const gutterWidth = 4;
return (
<Box
key={`patch-delete-${op.path}`}
flexDirection="row"
>
<Box width={gutterWidth} flexShrink={0}>
<Text>
{" "}
<Text dimColor></Text>
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap">
Deleted <Text bold>{op.path}</Text>
</Text>
</Box>
</Box>
);
}
return null;
})}
</Box>
);
}
}
} catch {
// If parsing fails, fall through to regular handling
}
}
// Check if this is a file read tool - show line count or image summary
if (
isFileReadTool(rawName) &&
line.resultOk !== false &&
line.resultText
) {
// Check if this is an image result (starts with "[Image: filename]")
const isImageResult = line.resultText.startsWith("[Image: ");
if (isImageResult) {
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text>
Read <Text bold>1</Text> image
</Text>
</Box>
</Box>
);
}
// Count lines in the result (the content returned by Read tool)
const lineCount = line.resultText.split("\n").length;
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text>
Read <Text bold>{lineCount}</Text> line
{lineCount !== 1 ? "s" : ""}
</Text>
</Box>
</Box>
);
}
// Check if this is a search/grep tool - show line/file count summary
if (isSearchTool(rawName) && line.resultOk !== false && line.resultText) {
const text = line.resultText;
// Match "Found N file(s)" at start of output (files_with_matches mode)
const filesMatch = text.match(/^Found (\d+) files?/);
const noFilesMatch = text === "No files found";
const noMatchesMatch = text === "No matches found";
if (filesMatch?.[1]) {
const count = parseInt(filesMatch[1], 10);
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text>
Found <Text bold>{count}</Text> file{count !== 1 ? "s" : ""}
</Text>
</Box>
</Box>
);
} else if (noFilesMatch || noMatchesMatch) {
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text>
Found <Text bold>0</Text> {noFilesMatch ? "files" : "matches"}
</Text>
</Box>
</Box>
);
} else {
// Content mode - count lines in the output
const lineCount = text.split("\n").length;
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text>
Found <Text bold>{lineCount}</Text> line
{lineCount !== 1 ? "s" : ""}
</Text>
</Box>
</Box>
);
}
}
// Check if this is a glob tool - show file count summary
if (isGlobTool(rawName) && line.resultOk !== false && line.resultText) {
const text = line.resultText;
const filesMatch = text.match(/^Found (\d+) files?/);
const noFilesMatch = text === "No files found";
if (filesMatch?.[1]) {
const count = parseInt(filesMatch[1], 10);
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text>
Found <Text bold>{count}</Text> file{count !== 1 ? "s" : ""}
</Text>
</Box>
</Box>
);
} else if (noFilesMatch) {
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text>
Found <Text bold>0</Text> files
</Text>
</Box>
</Box>
);
}
// Fall through to default if no match pattern found
}
// 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 (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
{isError ? (
<Text color={colors.status.error}>{displayText}</Text>
) : (
<MarkdownDisplay text={displayText} />
)}
</Box>
</Box>
);
};
return (
<Box flexDirection="column">
{/* Tool call with exact wrapping logic from old codebase */}
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<BlinkDot color={dotColor} shouldAnimate={dotShouldAnimate} />
<Text></Text>
</Box>
<Box flexGrow={1} width={rightWidth}>
{fallback ? (
<Text wrap="wrap">
{isMemoryTool(rawName) ? (
<>
<Text bold color={colors.tool.memoryName}>
{displayName}
</Text>
{args}
</>
) : (
<>
<Text bold>{displayName}</Text>
{args}
</>
)}
</Text>
) : (
<Box flexDirection="row">
<Text
bold
color={
isMemoryTool(rawName) ? colors.tool.memoryName : undefined
}
>
{displayName}
</Text>
{args ? (
<Box
flexGrow={1}
width={Math.max(0, rightWidth - displayName.length)}
>
<Text wrap="wrap">{args}</Text>
</Box>
) : null}
</Box>
)}
</Box>
</Box>
{/* Streaming output for shell tools during execution */}
{isShellTool(rawName) && line.phase === "running" && line.streaming && (
<StreamingOutputDisplay streaming={line.streaming} />
)}
{/* Collapsed output for shell tools after completion */}
{isShellTool(rawName) &&
line.phase === "finished" &&
line.resultText &&
line.resultOk !== false && (
<CollapsedOutputDisplay output={line.resultText} maxChars={300} />
)}
{/* Tool result for non-shell tools or shell tool errors */}
{(() => {
// Show default result element when:
// - Not a shell tool (always show result)
// - Shell tool with error (show error message)
// - Shell tool in streaming/ready phase (show default "Running..." etc)
const showDefaultResult =
!isShellTool(rawName) ||
(line.phase === "finished" && line.resultOk === false) ||
(line.phase !== "running" && line.phase !== "finished");
return showDefaultResult ? getResultElement() : null;
})()}
</Box>
);
},
);
ToolCallMessage.displayName = "ToolCallMessage";