feat: show advanced diffs in tool returns (#396)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-26 09:09:04 -08:00
committed by GitHub
parent e8a9d656e8
commit a2e3ee3648
6 changed files with 1201 additions and 535 deletions

View File

@@ -60,7 +60,7 @@ function Line({
text,
pairText,
gutterWidth,
contentWidth,
columns,
enableWord,
}: {
kind: "context" | "remove" | "add";
@@ -68,7 +68,7 @@ function Line({
text: string;
pairText?: string; // when '-' followed by '+' to highlight words
gutterWidth: number;
contentWidth: number;
columns: number;
enableWord: boolean;
}) {
const symbol = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
@@ -106,21 +106,22 @@ function Line({
: Diff.diffChars(text, pairText)
: null;
// Compute remaining width for the text area within this row
const textWidth = Math.max(0, contentWidth - gutterWidth - 2);
// Build prefix: " 1 + " (line number + symbol)
const linePrefix = `${padLeft(displayNo, gutterWidth)} ${symbol} `;
const prefixWidth = linePrefix.length;
const contentWidth = Math.max(0, columns - prefixWidth);
return (
<Box width={contentWidth}>
<Box width={gutterWidth}>
<Text dimColor>{padLeft(displayNo, gutterWidth)}</Text>
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text dimColor={kind === "context"}>
{padLeft(displayNo, gutterWidth)}{" "}
<Text color={symbolColor}>{symbol}</Text>{" "}
</Text>
</Box>
<Box width={2}>
<Text color={symbolColor}>{symbol}</Text>
<Text> </Text>
</Box>
<Box width={textWidth}>
<Box flexGrow={1} width={contentWidth}>
{charParts ? (
<Text>
<Text wrap="wrap" backgroundColor={bgLine}>
{charParts.map((p, i) => {
// For '-' lines: render removed + unchanged; drop added
if (kind === "remove") {
@@ -138,7 +139,6 @@ function Line({
return (
<Text
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
backgroundColor={bgLine}
color={colors.diff.textOnDark}
>
{p.value}
@@ -162,7 +162,6 @@ function Line({
return (
<Text
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
backgroundColor={bgLine}
color={colors.diff.textOnDark}
>
{p.value}
@@ -172,10 +171,7 @@ function Line({
}
// Context (should not occur with charParts), fall back to full line
return (
<Text
key={`context-${i}-${p.value.substring(0, 10)}`}
backgroundColor={bgLine}
>
<Text key={`context-${i}-${p.value.substring(0, 10)}`}>
{p.value}
</Text>
);
@@ -183,6 +179,7 @@ function Line({
</Text>
) : (
<Text
wrap="wrap"
backgroundColor={bgLine}
color={kind === "context" ? undefined : colors.diff.textOnDark}
>
@@ -370,31 +367,101 @@ export function AdvancedDiffRenderer(
? `Wrote changes to ${relative}`
: `Updated ${relative}`;
// Best-effort width clamp for rendering inside approval panel (border + padding + indent ~ 8 cols)
const panelInnerWidth = Math.max(20, columns - 8); // keep a reasonable minimum
// If no changes (empty diff), show a message with filepath
if (rows.length === 0) {
const noChangesGutter = 4;
return (
<Box flexDirection="column">
{showHeader ? (
<Box flexDirection="row">
<Box width={noChangesGutter} flexShrink={0}>
<Text>
{" "}
<Text dimColor></Text>
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap">{header}</Text>
</Box>
</Box>
) : null}
<Box flexDirection="row">
<Box width={noChangesGutter} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1}>
<Text dimColor>
No changes to <Text bold>{relative}</Text> (file content
identical)
</Text>
</Box>
</Box>
</Box>
);
}
// Gutter width for " ⎿" prefix (4 chars: 2 spaces + ⎿ + space)
const toolResultGutter = 4;
return (
<Box flexDirection="column" width={panelInnerWidth}>
<Box flexDirection="column">
{showHeader ? (
<>
<Text>{header}</Text>
<Text
dimColor
>{`Showing ~${ADV_DIFF_CONTEXT_LINES} context line${ADV_DIFF_CONTEXT_LINES === 1 ? "" : "s"}`}</Text>
<Box flexDirection="row">
<Box width={toolResultGutter} flexShrink={0}>
<Text>
{" "}
<Text dimColor></Text>
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap">{header}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width={toolResultGutter} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1}>
<Text
dimColor
>{`Showing ~${ADV_DIFF_CONTEXT_LINES} context line${ADV_DIFF_CONTEXT_LINES === 1 ? "" : "s"}`}</Text>
</Box>
</Box>
</>
) : null}
{rows.map((r, idx) => (
<Line
key={`row-${idx}-${r.kind}-${r.displayNo || idx}`}
kind={r.kind}
displayNo={r.displayNo}
text={r.text}
pairText={r.pairText}
gutterWidth={gutterWidth}
contentWidth={panelInnerWidth}
enableWord={enableWord}
/>
))}
{rows.map((r, idx) =>
showHeader ? (
<Box
key={`row-${idx}-${r.kind}-${r.displayNo || idx}`}
flexDirection="row"
>
<Box width={toolResultGutter} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Line
kind={r.kind}
displayNo={r.displayNo}
text={r.text}
pairText={r.pairText}
gutterWidth={gutterWidth}
columns={columns - toolResultGutter}
enableWord={enableWord}
/>
</Box>
) : (
<Line
key={`row-${idx}-${r.kind}-${r.displayNo || idx}`}
kind={r.kind}
displayNo={r.displayNo}
text={r.text}
pairText={r.pairText}
gutterWidth={gutterWidth}
columns={columns}
enableWord={enableWord}
/>
),
)}
</Box>
);
}

View File

@@ -3,7 +3,11 @@ import { Box, Text, useInput } from "ink";
import type React from "react";
import { memo, useEffect, useMemo, useState } from "react";
import type { ApprovalContext } from "../../permissions/analyzer";
import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff";
import {
type AdvancedDiffSuccess,
computeAdvancedDiff,
parsePatchToAdvancedDiff,
} from "../helpers/diff";
import { parsePatchOperations } from "../helpers/formatArgsDisplay";
import { resolvePlaceholders } from "../helpers/pasteRegistry";
import type { ApprovalRequest } from "../helpers/stream";
@@ -17,8 +21,11 @@ type Props = {
progress?: { current: number; total: number };
totalTools?: number;
isExecuting?: boolean;
onApproveAll: () => void;
onApproveAlways: (scope?: "project" | "session") => void;
onApproveAll: (diffs?: Map<string, AdvancedDiffSuccess>) => void;
onApproveAlways: (
scope?: "project" | "session",
diffs?: Map<string, AdvancedDiffSuccess>,
) => void;
onDenyAll: (reason: string) => void;
onCancel?: () => void; // Cancel all approvals without sending to server
};
@@ -28,6 +35,8 @@ type DynamicPreviewProps = {
toolArgs: string;
parsedArgs: Record<string, unknown> | null;
precomputedDiff: AdvancedDiffSuccess | null;
allDiffs: Map<string, AdvancedDiffSuccess>;
toolCallId: string | undefined;
};
// Options renderer - memoized to prevent unnecessary re-renders
@@ -70,6 +79,8 @@ const DynamicPreview: React.FC<DynamicPreviewProps> = ({
toolArgs,
parsedArgs,
precomputedDiff,
allDiffs,
toolCallId,
}) => {
const t = toolName.toLowerCase();
@@ -189,39 +200,56 @@ const DynamicPreview: React.FC<DynamicPreviewProps> = ({
if (typeof inputVal === "string") {
const operations = parsePatchOperations(inputVal);
if (operations.length > 0) {
const { relative } = require("node:path");
const cwd = process.cwd();
return (
<Box flexDirection="column" paddingLeft={2}>
{operations.map((op) => {
{operations.map((op, idx) => {
const relPath = relative(cwd, op.path);
const displayPath = relPath.startsWith("..") ? op.path : relPath;
// Look up precomputed diff from allDiffs using toolCallId:path key
const diffKey = toolCallId
? `${toolCallId}:${op.path}`
: undefined;
const opDiff = diffKey ? allDiffs.get(diffKey) : undefined;
if (op.kind === "add") {
return (
<AdvancedDiffRenderer
key={`patch-add-${op.path}`}
precomputed={precomputedDiff ?? undefined}
kind="write"
filePath={op.path}
content={op.content}
showHeader={false}
/>
<Box key={`patch-add-${op.path}`} flexDirection="column">
{idx > 0 && <Box height={1} />}
<Text dimColor>{displayPath}</Text>
<AdvancedDiffRenderer
precomputed={opDiff}
kind="write"
filePath={op.path}
content={op.content}
showHeader={false}
/>
</Box>
);
}
if (op.kind === "update") {
return (
<AdvancedDiffRenderer
key={`patch-update-${op.path}`}
precomputed={precomputedDiff ?? undefined}
kind="edit"
filePath={op.path}
oldString={op.oldString}
newString={op.newString}
showHeader={false}
/>
<Box key={`patch-update-${op.path}`} flexDirection="column">
{idx > 0 && <Box height={1} />}
<Text dimColor>{displayPath}</Text>
<AdvancedDiffRenderer
precomputed={opDiff}
kind="edit"
filePath={op.path}
oldString={op.oldString}
newString={op.newString}
showHeader={false}
/>
</Box>
);
}
if (op.kind === "delete") {
return (
<Text key={`patch-delete-${op.path}`}>
Delete file: {op.path}
</Text>
<Box key={`patch-delete-${op.path}`} flexDirection="column">
{idx > 0 && <Box height={1} />}
<Text dimColor>{displayPath}</Text>
<Text color="red">File will be deleted</Text>
</Box>
);
}
return null;
@@ -517,40 +545,6 @@ export const ApprovalDialog = memo(function ApprovalDialog({
setDenyReason("");
}, [progress?.current]);
// Build options based on approval context
const options = useMemo(() => {
const approvalLabel =
progress && progress.total > 1
? "Yes, approve this tool"
: "Yes, just this once";
const opts = [{ label: approvalLabel, action: onApproveAll }];
// Add context-aware approval option if available (only for single approvals)
if (approvalContext?.allowPersistence) {
opts.push({
label: approvalContext.approveAlwaysText,
action: () =>
onApproveAlways(
approvalContext.defaultScope === "user"
? "session"
: approvalContext.defaultScope,
),
});
}
// Add deny option
const denyLabel =
progress && progress.total > 1
? "No, deny this tool (esc)"
: "No, and tell Letta what to do differently (esc)";
opts.push({
label: denyLabel,
action: () => {}, // Handled separately via setIsEnteringReason
});
return opts;
}, [progress, approvalContext, onApproveAll, onApproveAlways]);
useInput((_input, key) => {
if (isExecuting) return;
@@ -669,6 +663,79 @@ export const ApprovalDialog = memo(function ApprovalDialog({
return null;
}, [approvalRequest, parsedArgs]);
// Build map of all diffs (for Edit/Write AND Patch operations)
const allDiffs = useMemo((): Map<string, AdvancedDiffSuccess> => {
const diffs = new Map<string, AdvancedDiffSuccess>();
const toolCallId = approvalRequest?.toolCallId;
if (!toolCallId) return diffs;
// For Edit/Write/MultiEdit - single file diff
if (precomputedDiff) {
diffs.set(toolCallId, precomputedDiff);
return diffs;
}
// For Patch tools - parse hunks directly (patches ARE diffs, no need to recompute)
const t = approvalRequest.toolName.toLowerCase();
if ((t === "apply_patch" || t === "applypatch") && parsedArgs?.input) {
const operations = parsePatchOperations(parsedArgs.input as string);
for (const op of operations) {
const key = `${toolCallId}:${op.path}`;
if (op.kind === "add" || op.kind === "update") {
// Parse patch hunks directly instead of trying to find oldString in file
const result = parsePatchToAdvancedDiff(op.patchLines, op.path);
if (result) {
diffs.set(key, result);
}
}
// Delete operations don't need diffs
}
}
return diffs;
}, [approvalRequest, parsedArgs, precomputedDiff]);
// Build options based on approval context
const options = useMemo(() => {
const approvalLabel =
progress && progress.total > 1
? "Yes, approve this tool"
: "Yes, just this once";
const opts = [
{
label: approvalLabel,
action: () => onApproveAll(allDiffs.size > 0 ? allDiffs : undefined),
},
];
// Add context-aware approval option if available (only for single approvals)
if (approvalContext?.allowPersistence) {
opts.push({
label: approvalContext.approveAlwaysText,
action: () =>
onApproveAlways(
approvalContext.defaultScope === "user"
? "session"
: approvalContext.defaultScope,
allDiffs.size > 0 ? allDiffs : undefined,
),
});
}
// Add deny option
const denyLabel =
progress && progress.total > 1
? "No, deny this tool (esc)"
: "No, and tell Letta Code what to do differently (esc)";
opts.push({
label: denyLabel,
action: () => {}, // Handled separately via setIsEnteringReason
});
return opts;
}, [progress, approvalContext, onApproveAll, onApproveAlways, allDiffs]);
// Get the human-readable header label
const headerLabel = useMemo(() => {
if (!approvalRequest) return "";
@@ -677,18 +744,101 @@ export const ApprovalDialog = memo(function ApprovalDialog({
if (t === "apply_patch" || t === "applypatch") {
if (parsedArgs?.input && typeof parsedArgs.input === "string") {
const operations = parsePatchOperations(parsedArgs.input);
const firstOp = operations[0];
if (firstOp) {
if (firstOp.kind === "add") return "Write File";
if (firstOp.kind === "update") return "Edit File";
if (firstOp.kind === "delete") return "Delete File";
if (operations.length > 0) {
const isMulti = operations.length > 1;
const firstOp = operations[0];
if (firstOp?.kind === "add")
return isMulti ? "Write Files" : "Write File";
if (firstOp?.kind === "update")
return isMulti ? "Edit Files" : "Edit File";
if (firstOp?.kind === "delete")
return isMulti ? "Delete Files" : "Delete File";
}
}
return "Apply Patch"; // Fallback
}
// For write tools, check if file exists to show "Overwrite File" vs "Write File"
if (
t === "write" ||
t === "write_file" ||
t === "writefile" ||
t === "write_file_gemini" ||
t === "writefilegemini"
) {
const filePath = parsedArgs?.file_path as string | undefined;
if (filePath) {
try {
const { existsSync } = require("node:fs");
if (existsSync(filePath)) {
return "Overwrite File";
}
} catch {
// Ignore errors, fall through to default
}
}
return "Write File";
}
return getHeaderLabel(approvalRequest.toolName);
}, [approvalRequest, parsedArgs]);
// Compute the question text (customized for write tools to show filepath)
const questionText = useMemo((): { text: string; boldPath?: string } => {
if (!approvalRequest || !parsedArgs) {
return { text: "Do you want to proceed?" };
}
const t = approvalRequest.toolName.toLowerCase();
// For write tools, show "Write to {path}?" or "Overwrite {path}?"
if (
t === "write" ||
t === "write_file" ||
t === "writefile" ||
t === "write_file_gemini" ||
t === "writefilegemini"
) {
const filePath = parsedArgs.file_path as string | undefined;
if (filePath) {
const { existsSync } = require("node:fs");
const { relative } = require("node:path");
const cwd = process.cwd();
const relPath = relative(cwd, filePath);
const displayPath = relPath.startsWith("..") ? filePath : relPath;
try {
if (existsSync(filePath)) {
return { text: "Overwrite", boldPath: `${displayPath}?` };
}
} catch {
// Ignore errors
}
return { text: "Write to", boldPath: `${displayPath}?` };
}
}
// For patch tools, show file path(s) being modified
if ((t === "apply_patch" || t === "applypatch") && parsedArgs.input) {
const operations = parsePatchOperations(parsedArgs.input as string);
if (operations.length > 0) {
const { relative } = require("node:path");
const cwd = process.cwd();
const paths = operations.map((op) => {
const relPath = relative(cwd, op.path);
return relPath.startsWith("..") ? op.path : relPath;
});
if (paths.length === 1) {
const op = operations[0];
if (op?.kind === "add") {
return { text: "Write to", boldPath: `${paths[0]}?` };
} else if (op?.kind === "update") {
return { text: "Update", boldPath: `${paths[0]}?` };
} else if (op?.kind === "delete") {
return { text: "Delete", boldPath: `${paths[0]}?` };
}
} else {
return { text: "Apply patch to", boldPath: `${paths.length} files?` };
}
}
}
return { text: "Do you want to proceed?" };
}, [approvalRequest, parsedArgs]);
// Guard: should never happen as parent checks length, but satisfies TypeScript
if (!approvalRequest) {
return null;
@@ -748,11 +898,21 @@ export const ApprovalDialog = memo(function ApprovalDialog({
toolArgs={approvalRequest.toolArgs}
parsedArgs={parsedArgs}
precomputedDiff={precomputedDiff}
allDiffs={allDiffs}
toolCallId={approvalRequest.toolCallId}
/>
<Box height={1} />
{/* Prompt */}
<Text bold>Do you want to proceed?</Text>
<Text bold>
{questionText.text}
{questionText.boldPath ? (
<>
{" "}
<Text bold>{questionText.boldPath}</Text>
</>
) : null}
</Text>
<Box height={1} />
{/* Options selector (single line per option) */}

View File

@@ -2,6 +2,7 @@ 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,
@@ -18,6 +19,7 @@ import {
isTodoTool,
} from "../helpers/toolNameMapping.js";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
import { BlinkDot } from "./BlinkDot.js";
import { colors } from "./colors.js";
import {
@@ -51,417 +53,509 @@ type ToolCallLine = {
* - Blinking dots for pending/running states
* - Result shown with ⎿ prefix underneath
*/
export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
const columns = useTerminalWidth();
export const ToolCallMessage = memo(
({
line,
precomputedDiffs,
}: {
line: ToolCallLine;
precomputedDiffs?: Map<string, AdvancedDiffSuccess>;
}) => {
const columns = useTerminalWidth();
// Parse and format the tool call
const rawName = line.name ?? "?";
const argsText = line.argsText ?? "...";
// Parse and format the tool call
const rawName = line.name ?? "?";
const argsText = line.argsText ?? "...";
// Task tool - handled by SubagentGroupDisplay, don't render here
// Exception: Cancelled/rejected Task tools should be rendered inline
// since they won't appear in SubagentGroupDisplay
if (isTaskTool(rawName)) {
const isCancelledOrRejected =
line.phase === "finished" && line.resultOk === false;
if (!isCancelledOrRejected) {
return null;
}
}
// 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";
}
// Task tool - handled by SubagentGroupDisplay, don't render here
// Exception: Cancelled/rejected Task tools should be rendered inline
// since they won't appear in SubagentGroupDisplay
if (isTaskTool(rawName)) {
const isCancelledOrRejected =
line.phase === "finished" && line.resultOk === false;
if (!isCancelledOrRejected) {
return null;
}
} catch {
// Keep default "Patch" name if parsing fails
}
}
// Format arguments for display using the old formatting logic
// Pass rawName to enable special formatting for file tools
const formatted = formatArgsDisplay(argsText, rawName);
const args = `(${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 <Text color={colors.tool.streaming}></Text>;
case "ready":
return <BlinkDot color={colors.tool.pending} />;
case "running":
return <BlinkDot color={colors.tool.running} />;
case "finished":
if (line.resultOk === false) {
return <Text color={colors.tool.error}></Text>;
}
return <Text color={colors.tool.completed}></Text>;
default:
return <Text></Text>;
}
};
// 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>
);
}
// Apply tool name remapping
let displayName = getDisplayToolName(rawName);
// 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
) {
// For Patch tools, override display name based on patch content
// (Add → Write, Update → Update, Delete → Delete)
if (isPatchTool(rawName)) {
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 a file edit tool - show diff instead of success message
if (isFileEditTool(rawName) && line.resultOk !== false && line.argsText) {
try {
const parsedArgs = JSON.parse(line.argsText);
const filePath = parsedArgs.file_path || "";
// 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) {
try {
const parsedArgs = JSON.parse(line.argsText);
const filePath = parsedArgs.file_path || "";
const content = parsedArgs.content || "";
if (filePath && 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);
const parsedArgs = JSON.parse(argsText);
if (parsedArgs.input) {
const operations = parsePatchOperations(parsedArgs.input);
if (operations.length > 0) {
return (
<Box flexDirection="column">
{operations.map((op) => {
if (op.kind === "add") {
return (
<WriteRenderer
key={`patch-add-${op.path}`}
filePath={op.path}
content={op.content}
/>
);
}
if (op.kind === "update") {
return (
<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>
);
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 {
// If parsing fails, fall through to regular handling
// Keep default "Patch" name if parsing fails
}
}
// Regular result handling
const isError = line.resultOk === false;
// Format arguments for display using the old formatting logic
// Pass rawName to enable special formatting for file tools
const formatted = formatArgsDisplay(argsText, rawName);
const args = `(${formatted.display})`;
// 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;
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 <Text color={colors.tool.streaming}></Text>;
case "ready":
return <BlinkDot color={colors.tool.pending} />;
case "running":
return <BlinkDot color={colors.tool.running} />;
case "finished":
if (line.resultOk === false) {
return <Text color={colors.tool.error}></Text>;
}
return <Text color={colors.tool.completed}></Text>;
default:
return <Text></Text>;
}
} 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}`;
}
// 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 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
}
}
// 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="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 flexDirection="column">
{/* Tool call with exact wrapping logic from old codebase */}
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
{getDotElement()}
<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>
{/* Tool result (if present) */}
{getResultElement()}
</Box>
);
};
return (
<Box flexDirection="column">
{/* Tool call with exact wrapping logic from old codebase */}
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
{getDotElement()}
<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>
{/* Tool result (if present) */}
{getResultElement()}
</Box>
);
});
},
);
ToolCallMessage.displayName = "ToolCallMessage";