feat: show advanced diffs in tool returns (#396)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
336
src/cli/App.tsx
336
src/cli/App.tsx
@@ -94,7 +94,13 @@ import {
|
||||
toLines,
|
||||
} from "./helpers/accumulator";
|
||||
import { backfillBuffers } from "./helpers/backfill";
|
||||
import {
|
||||
type AdvancedDiffSuccess,
|
||||
computeAdvancedDiff,
|
||||
parsePatchToAdvancedDiff,
|
||||
} from "./helpers/diff";
|
||||
import { formatErrorDetails } from "./helpers/errorFormatter";
|
||||
import { parsePatchOperations } from "./helpers/formatArgsDisplay";
|
||||
import {
|
||||
buildMemoryReminder,
|
||||
parseMemoryPreference,
|
||||
@@ -117,6 +123,11 @@ import {
|
||||
clearSubagentsByIds,
|
||||
} from "./helpers/subagentState";
|
||||
import { getRandomThinkingVerb } from "./helpers/thinkingMessages";
|
||||
import {
|
||||
isFileEditTool,
|
||||
isFileWriteTool,
|
||||
isPatchTool,
|
||||
} from "./helpers/toolNameMapping";
|
||||
import { isFancyUITool, isTaskTool } from "./helpers/toolNameMapping.js";
|
||||
import { useSuspend } from "./hooks/useSuspend/useSuspend.ts";
|
||||
import { useSyncedState } from "./hooks/useSyncedState";
|
||||
@@ -682,6 +693,10 @@ export default function App({
|
||||
if ("phase" in ln && ln.phase === "finished") {
|
||||
emittedIdsRef.current.add(id);
|
||||
newlyCommitted.push({ ...ln });
|
||||
// Note: We intentionally don't cleanup precomputedDiffs here because
|
||||
// the Static area renders AFTER this function returns (on next React tick),
|
||||
// and the diff needs to be available for ToolCallMessage to render.
|
||||
// The diffs will be cleaned up when the session ends or on next session start.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,6 +734,12 @@ export default function App({
|
||||
// Track whether we've already backfilled history (should only happen once)
|
||||
const hasBackfilledRef = useRef(false);
|
||||
|
||||
// Cache precomputed diffs from approval dialogs for tool return rendering
|
||||
// Key: toolCallId or "toolCallId:filePath" for Patch operations
|
||||
const precomputedDiffsRef = useRef<Map<string, AdvancedDiffSuccess>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
// Recompute UI state from buffers after chunks (micro-batched)
|
||||
const refreshDerived = useCallback(() => {
|
||||
const b = buffersRef.current;
|
||||
@@ -1255,6 +1276,77 @@ export default function App({
|
||||
}
|
||||
}
|
||||
|
||||
// Precompute diffs for auto-allowed file edit tools before execution
|
||||
for (const ac of autoAllowed) {
|
||||
const toolName = ac.approval.toolName;
|
||||
const toolCallId = ac.approval.toolCallId;
|
||||
try {
|
||||
const args = JSON.parse(ac.approval.toolArgs || "{}");
|
||||
|
||||
if (isFileWriteTool(toolName)) {
|
||||
const filePath = args.file_path as string | undefined;
|
||||
if (filePath) {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "write",
|
||||
filePath,
|
||||
content: (args.content as string) || "",
|
||||
});
|
||||
if (result.mode === "advanced") {
|
||||
precomputedDiffsRef.current.set(toolCallId, result);
|
||||
}
|
||||
}
|
||||
} else if (isFileEditTool(toolName)) {
|
||||
const filePath = args.file_path as string | undefined;
|
||||
if (filePath) {
|
||||
// Check if it's a multi-edit (has edits array) or single edit
|
||||
if (args.edits && Array.isArray(args.edits)) {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "multi_edit",
|
||||
filePath,
|
||||
edits: args.edits as Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}>,
|
||||
});
|
||||
if (result.mode === "advanced") {
|
||||
precomputedDiffsRef.current.set(toolCallId, result);
|
||||
}
|
||||
} else {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "edit",
|
||||
filePath,
|
||||
oldString: (args.old_string as string) || "",
|
||||
newString: (args.new_string as string) || "",
|
||||
replaceAll: args.replace_all as boolean | undefined,
|
||||
});
|
||||
if (result.mode === "advanced") {
|
||||
precomputedDiffsRef.current.set(toolCallId, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isPatchTool(toolName) && args.input) {
|
||||
// Patch tools - parse hunks directly (patches ARE diffs)
|
||||
const operations = parsePatchOperations(args.input as string);
|
||||
for (const op of operations) {
|
||||
const key = `${toolCallId}:${op.path}`;
|
||||
if (op.kind === "add" || op.kind === "update") {
|
||||
const result = parsePatchToAdvancedDiff(
|
||||
op.patchLines,
|
||||
op.path,
|
||||
);
|
||||
if (result) {
|
||||
precomputedDiffsRef.current.set(key, result);
|
||||
}
|
||||
}
|
||||
// Delete operations don't need diffs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in diff computation for auto-allowed tools
|
||||
}
|
||||
}
|
||||
|
||||
// Execute auto-allowed tools (sequential for writes, parallel for reads)
|
||||
const autoAllowedResults = await executeAutoAllowedTools(
|
||||
autoAllowed,
|
||||
@@ -3711,6 +3803,79 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
||||
|
||||
// If all approvals can be auto-handled (yolo mode), process them immediately
|
||||
if (needsUserInput.length === 0) {
|
||||
// Precompute diffs for auto-allowed file edit tools before execution
|
||||
for (const ac of autoAllowed) {
|
||||
const toolName = ac.approval.toolName;
|
||||
const toolCallId = ac.approval.toolCallId;
|
||||
try {
|
||||
const args = JSON.parse(ac.approval.toolArgs || "{}");
|
||||
|
||||
if (isFileWriteTool(toolName)) {
|
||||
const filePath = args.file_path as string | undefined;
|
||||
if (filePath) {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "write",
|
||||
filePath,
|
||||
content: (args.content as string) || "",
|
||||
});
|
||||
if (result.mode === "advanced") {
|
||||
precomputedDiffsRef.current.set(toolCallId, result);
|
||||
}
|
||||
}
|
||||
} else if (isFileEditTool(toolName)) {
|
||||
const filePath = args.file_path as string | undefined;
|
||||
if (filePath) {
|
||||
// Check if it's a multi-edit (has edits array) or single edit
|
||||
if (args.edits && Array.isArray(args.edits)) {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "multi_edit",
|
||||
filePath,
|
||||
edits: args.edits as Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}>,
|
||||
});
|
||||
if (result.mode === "advanced") {
|
||||
precomputedDiffsRef.current.set(toolCallId, result);
|
||||
}
|
||||
} else {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "edit",
|
||||
filePath,
|
||||
oldString: (args.old_string as string) || "",
|
||||
newString: (args.new_string as string) || "",
|
||||
replaceAll: args.replace_all as boolean | undefined,
|
||||
});
|
||||
if (result.mode === "advanced") {
|
||||
precomputedDiffsRef.current.set(toolCallId, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isPatchTool(toolName) && args.input) {
|
||||
// Patch tools - parse hunks directly (patches ARE diffs)
|
||||
const operations = parsePatchOperations(
|
||||
args.input as string,
|
||||
);
|
||||
for (const op of operations) {
|
||||
const key = `${toolCallId}:${op.path}`;
|
||||
if (op.kind === "add" || op.kind === "update") {
|
||||
const result = parsePatchToAdvancedDiff(
|
||||
op.patchLines,
|
||||
op.path,
|
||||
);
|
||||
if (result) {
|
||||
precomputedDiffsRef.current.set(key, result);
|
||||
}
|
||||
}
|
||||
// Delete operations don't need diffs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in diff computation for auto-allowed tools
|
||||
}
|
||||
}
|
||||
|
||||
// Execute auto-allowed tools (sequential for writes, parallel for reads)
|
||||
const autoAllowedResults = await executeAutoAllowedTools(
|
||||
autoAllowed,
|
||||
@@ -3794,6 +3959,79 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
||||
.filter(Boolean) as ApprovalContext[],
|
||||
);
|
||||
|
||||
// Precompute diffs for auto-allowed file edit tools before execution
|
||||
for (const ac of autoAllowed) {
|
||||
const toolName = ac.approval.toolName;
|
||||
const toolCallId = ac.approval.toolCallId;
|
||||
try {
|
||||
const args = JSON.parse(ac.approval.toolArgs || "{}");
|
||||
|
||||
if (isFileWriteTool(toolName)) {
|
||||
const filePath = args.file_path as string | undefined;
|
||||
if (filePath) {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "write",
|
||||
filePath,
|
||||
content: (args.content as string) || "",
|
||||
});
|
||||
if (result.mode === "advanced") {
|
||||
precomputedDiffsRef.current.set(toolCallId, result);
|
||||
}
|
||||
}
|
||||
} else if (isFileEditTool(toolName)) {
|
||||
const filePath = args.file_path as string | undefined;
|
||||
if (filePath) {
|
||||
// Check if it's a multi-edit (has edits array) or single edit
|
||||
if (args.edits && Array.isArray(args.edits)) {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "multi_edit",
|
||||
filePath,
|
||||
edits: args.edits as Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}>,
|
||||
});
|
||||
if (result.mode === "advanced") {
|
||||
precomputedDiffsRef.current.set(toolCallId, result);
|
||||
}
|
||||
} else {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "edit",
|
||||
filePath,
|
||||
oldString: (args.old_string as string) || "",
|
||||
newString: (args.new_string as string) || "",
|
||||
replaceAll: args.replace_all as boolean | undefined,
|
||||
});
|
||||
if (result.mode === "advanced") {
|
||||
precomputedDiffsRef.current.set(toolCallId, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isPatchTool(toolName) && args.input) {
|
||||
// Patch tools - parse hunks directly (patches ARE diffs)
|
||||
const operations = parsePatchOperations(
|
||||
args.input as string,
|
||||
);
|
||||
for (const op of operations) {
|
||||
const key = `${toolCallId}:${op.path}`;
|
||||
if (op.kind === "add" || op.kind === "update") {
|
||||
const result = parsePatchToAdvancedDiff(
|
||||
op.patchLines,
|
||||
op.path,
|
||||
);
|
||||
if (result) {
|
||||
precomputedDiffsRef.current.set(key, result);
|
||||
}
|
||||
}
|
||||
// Delete operations don't need diffs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in diff computation for auto-allowed tools
|
||||
}
|
||||
}
|
||||
|
||||
// Execute auto-allowed tools (sequential for writes, parallel for reads)
|
||||
const autoAllowedWithResults = await executeAutoAllowedTools(
|
||||
autoAllowed,
|
||||
@@ -4083,51 +4321,64 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
||||
);
|
||||
|
||||
// Handle approval callbacks - sequential review
|
||||
const handleApproveCurrent = useCallback(async () => {
|
||||
if (isExecutingTool) return;
|
||||
const handleApproveCurrent = useCallback(
|
||||
async (diffs?: Map<string, AdvancedDiffSuccess>) => {
|
||||
if (isExecutingTool) return;
|
||||
|
||||
const currentIndex = approvalResults.length;
|
||||
const currentApproval = pendingApprovals[currentIndex];
|
||||
const currentIndex = approvalResults.length;
|
||||
const currentApproval = pendingApprovals[currentIndex];
|
||||
|
||||
if (!currentApproval) return;
|
||||
if (!currentApproval) return;
|
||||
|
||||
setIsExecutingTool(true);
|
||||
// Store precomputed diffs before execution
|
||||
if (diffs) {
|
||||
for (const [key, diff] of diffs) {
|
||||
precomputedDiffsRef.current.set(key, diff);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Store approval decision (don't execute yet - batch execute after all approvals)
|
||||
const decision = {
|
||||
type: "approve" as const,
|
||||
approval: currentApproval,
|
||||
};
|
||||
setIsExecutingTool(true);
|
||||
|
||||
// Check if we're done with all approvals
|
||||
if (currentIndex + 1 >= pendingApprovals.length) {
|
||||
// All approvals collected, execute and send to backend
|
||||
// sendAllResults owns the lock release via its finally block
|
||||
await sendAllResults(decision);
|
||||
} else {
|
||||
// Not done yet, store decision and show next approval
|
||||
setApprovalResults((prev) => [...prev, decision]);
|
||||
try {
|
||||
// Store approval decision (don't execute yet - batch execute after all approvals)
|
||||
const decision = {
|
||||
type: "approve" as const,
|
||||
approval: currentApproval,
|
||||
};
|
||||
|
||||
// Check if we're done with all approvals
|
||||
if (currentIndex + 1 >= pendingApprovals.length) {
|
||||
// All approvals collected, execute and send to backend
|
||||
// sendAllResults owns the lock release via its finally block
|
||||
await sendAllResults(decision);
|
||||
} else {
|
||||
// Not done yet, store decision and show next approval
|
||||
setApprovalResults((prev) => [...prev, decision]);
|
||||
setIsExecutingTool(false);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorDetails = formatErrorDetails(e, agentId);
|
||||
appendError(errorDetails);
|
||||
setStreaming(false);
|
||||
setIsExecutingTool(false);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorDetails = formatErrorDetails(e, agentId);
|
||||
appendError(errorDetails);
|
||||
setStreaming(false);
|
||||
setIsExecutingTool(false);
|
||||
}
|
||||
}, [
|
||||
agentId,
|
||||
pendingApprovals,
|
||||
approvalResults,
|
||||
sendAllResults,
|
||||
appendError,
|
||||
isExecutingTool,
|
||||
setStreaming,
|
||||
]);
|
||||
},
|
||||
[
|
||||
agentId,
|
||||
pendingApprovals,
|
||||
approvalResults,
|
||||
sendAllResults,
|
||||
appendError,
|
||||
isExecutingTool,
|
||||
setStreaming,
|
||||
],
|
||||
);
|
||||
|
||||
const handleApproveAlways = useCallback(
|
||||
async (scope?: "project" | "session") => {
|
||||
async (
|
||||
scope?: "project" | "session",
|
||||
diffs?: Map<string, AdvancedDiffSuccess>,
|
||||
) => {
|
||||
if (isExecutingTool) return;
|
||||
|
||||
// For now, just handle the first approval with approve-always
|
||||
@@ -4159,7 +4410,8 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
||||
refreshDerived();
|
||||
|
||||
// Approve current tool (handleApproveCurrent manages the execution guard)
|
||||
await handleApproveCurrent();
|
||||
// Pass diffs through to store them before execution
|
||||
await handleApproveCurrent(diffs);
|
||||
},
|
||||
[
|
||||
approvalResults,
|
||||
@@ -5011,7 +5263,10 @@ Plan file path: ${planFilePath}`;
|
||||
) : item.kind === "assistant" ? (
|
||||
<AssistantMessage line={item} />
|
||||
) : item.kind === "tool_call" ? (
|
||||
<ToolCallMessage line={item} />
|
||||
<ToolCallMessage
|
||||
line={item}
|
||||
precomputedDiffs={precomputedDiffsRef.current}
|
||||
/>
|
||||
) : item.kind === "subagent_group" ? (
|
||||
<SubagentGroupStatic agents={item.agents} />
|
||||
) : item.kind === "error" ? (
|
||||
@@ -5053,7 +5308,10 @@ Plan file path: ${planFilePath}`;
|
||||
) : ln.kind === "assistant" ? (
|
||||
<AssistantMessage line={ln} />
|
||||
) : ln.kind === "tool_call" ? (
|
||||
<ToolCallMessage line={ln} />
|
||||
<ToolCallMessage
|
||||
line={ln}
|
||||
precomputedDiffs={precomputedDiffsRef.current}
|
||||
/>
|
||||
) : ln.kind === "error" ? (
|
||||
<ErrorMessage line={ln} />
|
||||
) : ln.kind === "status" ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -189,3 +189,73 @@ export function computeAdvancedDiff(
|
||||
|
||||
return { mode: "advanced", fileName, oldStr, newStr, hunks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a patch operation's hunks directly into AdvancedDiffSuccess format.
|
||||
* This bypasses the "read file -> find oldString" flow since the patch IS the diff.
|
||||
* Used for ApplyPatch tool previews where multi-hunk patches can't be found as
|
||||
* contiguous blocks in the file.
|
||||
*/
|
||||
export function parsePatchToAdvancedDiff(
|
||||
patchLines: string[], // Lines for this file operation (after "*** Update File:" or "*** Add File:")
|
||||
filePath: string,
|
||||
): AdvancedDiffSuccess | null {
|
||||
const fileName = basename(filePath);
|
||||
const hunks: AdvancedHunk[] = [];
|
||||
|
||||
let currentHunk: AdvancedHunk | null = null;
|
||||
let oldLine = 1;
|
||||
let newLine = 1;
|
||||
|
||||
for (const line of patchLines) {
|
||||
if (line.startsWith("@@")) {
|
||||
// Start new hunk - try to parse line numbers from @@ -old,count +new,count @@
|
||||
if (currentHunk && currentHunk.lines.length > 0) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
// Try standard unified diff format: @@ -10,5 +10,7 @@
|
||||
const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
currentHunk = {
|
||||
oldStart: match?.[1] ? parseInt(match[1], 10) : oldLine,
|
||||
newStart: match?.[2] ? parseInt(match[2], 10) : newLine,
|
||||
lines: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentHunk) {
|
||||
// Create implicit first hunk if no @@ header seen yet
|
||||
currentHunk = { oldStart: 1, newStart: 1, lines: [] };
|
||||
}
|
||||
|
||||
// Parse diff line (prefix + content)
|
||||
if (line.length === 0) {
|
||||
// Empty line - treat as context
|
||||
currentHunk.lines.push({ raw: " " });
|
||||
oldLine++;
|
||||
newLine++;
|
||||
} else {
|
||||
const prefix = line[0];
|
||||
if (prefix === " " || prefix === "-" || prefix === "+") {
|
||||
currentHunk.lines.push({ raw: line });
|
||||
if (prefix === " " || prefix === "-") oldLine++;
|
||||
if (prefix === " " || prefix === "+") newLine++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk && currentHunk.lines.length > 0) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
if (hunks.length === 0) return null;
|
||||
|
||||
return {
|
||||
mode: "advanced",
|
||||
fileName,
|
||||
oldStr: "", // Not needed for rendering when hunks are provided
|
||||
newStr: "", // Not needed for rendering when hunks are provided
|
||||
hunks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,8 +61,14 @@ export function parsePatchInput(
|
||||
* Patch operation types for result rendering
|
||||
*/
|
||||
export type PatchOperation =
|
||||
| { kind: "add"; path: string; content: string }
|
||||
| { kind: "update"; path: string; oldString: string; newString: string }
|
||||
| { kind: "add"; path: string; content: string; patchLines: string[] }
|
||||
| {
|
||||
kind: "update";
|
||||
path: string;
|
||||
oldString: string;
|
||||
newString: string;
|
||||
patchLines: string[];
|
||||
}
|
||||
| { kind: "delete"; path: string };
|
||||
|
||||
/**
|
||||
@@ -96,15 +102,22 @@ export function parsePatchOperations(input: string): PatchOperation[] {
|
||||
const path = line.replace("*** Add File:", "").trim();
|
||||
i++;
|
||||
const contentLines: string[] = [];
|
||||
const patchLines: string[] = [];
|
||||
while (i < stopIdx) {
|
||||
const raw = lines[i];
|
||||
if (raw === undefined || raw.startsWith("*** ")) break;
|
||||
patchLines.push(raw); // Store raw patch line for direct hunk parsing
|
||||
if (raw.startsWith("+")) {
|
||||
contentLines.push(raw.slice(1));
|
||||
}
|
||||
i++;
|
||||
}
|
||||
operations.push({ kind: "add", path, content: contentLines.join("\n") });
|
||||
operations.push({
|
||||
kind: "add",
|
||||
path,
|
||||
content: contentLines.join("\n"),
|
||||
patchLines,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -121,13 +134,16 @@ export function parsePatchOperations(input: string): PatchOperation[] {
|
||||
// Collect all hunk lines
|
||||
const oldParts: string[] = [];
|
||||
const newParts: string[] = [];
|
||||
const patchLines: string[] = []; // Store raw lines for direct hunk parsing
|
||||
|
||||
while (i < stopIdx) {
|
||||
const hLine = lines[i];
|
||||
if (hLine === undefined || hLine.startsWith("*** ")) break;
|
||||
|
||||
patchLines.push(hLine); // Store raw patch line
|
||||
|
||||
if (hLine.startsWith("@@")) {
|
||||
// Skip hunk header
|
||||
// Hunk header - don't parse for oldParts/newParts, just store in patchLines
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -161,6 +177,7 @@ export function parsePatchOperations(input: string): PatchOperation[] {
|
||||
path,
|
||||
oldString: oldParts.join("\n"),
|
||||
newString: newParts.join("\n"),
|
||||
patchLines,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user