diff --git a/src/cli/components/AdvancedDiffRenderer.tsx b/src/cli/components/AdvancedDiffRenderer.tsx index 3d5df27..065223c 100644 --- a/src/cli/components/AdvancedDiffRenderer.tsx +++ b/src/cli/components/AdvancedDiffRenderer.tsx @@ -257,9 +257,20 @@ export function AdvancedDiffRenderer( } if (result.mode === "unpreviewable") { + const gutterWidth = 4; return ( - - ⎿ Cannot preview changes: {result.reason} + + + + {" "} + + + + + + Cannot preview changes: {result.reason} + + ); } diff --git a/src/cli/components/ApprovalDialogRich.tsx b/src/cli/components/ApprovalDialogRich.tsx index b1f290b..ff20ce3 100644 --- a/src/cli/components/ApprovalDialogRich.tsx +++ b/src/cli/components/ApprovalDialogRich.tsx @@ -4,6 +4,7 @@ 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 { parsePatchOperations } from "../helpers/formatArgsDisplay"; import { resolvePlaceholders } from "../helpers/pasteRegistry"; import type { ApprovalRequest } from "../helpers/stream"; import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer"; @@ -185,16 +186,61 @@ const DynamicPreview: React.FC = ({ if (t === "apply_patch" || t === "applypatch") { const inputVal = parsedArgs?.input; - const patchPreview = - typeof inputVal === "string" && inputVal.length > 100 - ? `${inputVal.slice(0, 100)}...` - : typeof inputVal === "string" - ? inputVal - : "(no patch content)"; + if (typeof inputVal === "string") { + const operations = parsePatchOperations(inputVal); + if (operations.length > 0) { + return ( + + {operations.map((op) => { + if (op.kind === "add") { + return ( + + ); + } + if (op.kind === "update") { + return ( + + ); + } + if (op.kind === "delete") { + return ( + + Delete file: {op.path} + + ); + } + return null; + })} + + ); + } + } + // Fallback for unparseable patches return ( - {patchPreview} + + {typeof inputVal === "string" && inputVal.length > 100 + ? `${inputVal.slice(0, 100)}...` + : typeof inputVal === "string" + ? inputVal + : "(no patch content)"} + ); } @@ -623,14 +669,31 @@ export const ApprovalDialog = memo(function ApprovalDialog({ return null; }, [approvalRequest, parsedArgs]); + // Get the human-readable header label + const headerLabel = useMemo(() => { + if (!approvalRequest) return ""; + const t = approvalRequest.toolName.toLowerCase(); + // For patch tools, determine header from operation type + 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"; + } + } + return "Apply Patch"; // Fallback + } + return getHeaderLabel(approvalRequest.toolName); + }, [approvalRequest, parsedArgs]); + // Guard: should never happen as parent checks length, but satisfies TypeScript if (!approvalRequest) { return null; } - // Get the human-readable header label - const headerLabel = getHeaderLabel(approvalRequest.toolName); - if (isEnteringReason) { return ( diff --git a/src/cli/components/DiffRenderer.tsx b/src/cli/components/DiffRenderer.tsx index a56b17c..fecd184 100644 --- a/src/cli/components/DiffRenderer.tsx +++ b/src/cli/components/DiffRenderer.tsx @@ -5,14 +5,17 @@ import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; // Helper to format path as relative with ../ -function formatRelativePath(filePath: string): string { +/** + * Formats a file path for display (matches Claude Code style): + * - Files within cwd: relative path without ./ prefix + * - Files outside cwd: full absolute path + */ +function formatDisplayPath(filePath: string): string { const cwd = process.cwd(); const relativePath = relative(cwd, filePath); - - // If file is outside cwd, it will start with .. - // If file is in cwd, add ./ prefix - if (!relativePath.startsWith("..")) { - return `./${relativePath}`; + // If path goes outside cwd (starts with ..), show full absolute path + if (relativePath.startsWith("..")) { + return filePath; } return relativePath; } @@ -30,6 +33,7 @@ interface DiffLineProps { content: string; compareContent?: string; // The other version to compare against for word diff columns: number; + showLineNumbers?: boolean; // Whether to show line numbers (default true) } function DiffLine({ @@ -38,6 +42,7 @@ function DiffLine({ content, compareContent, columns, + showLineNumbers = true, }: DiffLineProps) { const prefix = type === "add" ? "+" : "-"; const lineBg = @@ -45,8 +50,13 @@ function DiffLine({ const wordBg = type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg; - const prefixWidth = 1; // Single space prefix - const contentWidth = Math.max(0, columns - prefixWidth); + const gutterWidth = 4; // " " indent to align with tool return prefix + const contentWidth = Math.max(0, columns - gutterWidth); + + // Build the line prefix (with or without line number) + const linePrefix = showLineNumbers + ? `${lineNumber} ${prefix} ` + : `${prefix} `; // If we have something to compare against, do word-level diff if (compareContent !== undefined && content.trim() && compareContent.trim()) { @@ -57,13 +67,13 @@ function DiffLine({ return ( - - + + {" "} - {`${lineNumber} ${prefix} `} + {linePrefix} {wordDiffs.map((part, i) => { if (part.added && type === "add") { @@ -112,8 +122,8 @@ function DiffLine({ // No comparison, just show the whole line with one background return ( - - + + {" "} - {`${lineNumber} ${prefix} ${content}`} + {`${linePrefix}${content}`} @@ -135,23 +145,33 @@ interface WriteRendererProps { export function WriteRenderer({ filePath, content }: WriteRendererProps) { const columns = useTerminalWidth(); - const relativePath = formatRelativePath(filePath); + const relativePath = formatDisplayPath(filePath); const lines = content.split("\n"); const lineCount = lines.length; - const prefixWidth = 1; // Single space prefix - const contentWidth = Math.max(0, columns - prefixWidth); + const gutterWidth = 4; // " " indent to align with tool return prefix + const contentWidth = Math.max(0, columns - gutterWidth); return ( - - {" "} - ⎿ Wrote {lineCount} line{lineCount !== 1 ? "s" : ""} to {relativePath} - + + + + {" "} + + + + + + Wrote {lineCount} line + {lineCount !== 1 ? "s" : ""} to {relativePath} + + + {lines.map((line, i) => ( - - + + {" "} {line} @@ -166,15 +186,17 @@ interface EditRendererProps { filePath: string; oldString: string; newString: string; + showLineNumbers?: boolean; // Whether to show line numbers (default true) } export function EditRenderer({ filePath, oldString, newString, + showLineNumbers = true, }: EditRendererProps) { const columns = useTerminalWidth(); - const relativePath = formatRelativePath(filePath); + const relativePath = formatDisplayPath(filePath); const oldLines = oldString.split("\n"); const newLines = newString.split("\n"); @@ -187,14 +209,28 @@ export function EditRenderer({ // For multi-line, we could do more sophisticated matching const singleLineEdit = oldLines.length === 1 && newLines.length === 1; + const gutterWidth = 4; // " " indent to align with tool return prefix + const contentWidth = Math.max(0, columns - gutterWidth); + return ( - - {" "} - ⎿ Updated {relativePath} with {additions} addition - {additions !== 1 ? "s" : ""} and {removals} removal - {removals !== 1 ? "s" : ""} - + + + + {" "} + + + + + + Updated {relativePath} with{" "} + {additions} addition + {additions !== 1 ? "s" : ""} and {removals}{" "} + removal + {removals !== 1 ? "s" : ""} + + + {/* Show removals */} {oldLines.map((line, i) => ( @@ -205,6 +241,7 @@ export function EditRenderer({ content={line} compareContent={singleLineEdit ? newLines[0] : undefined} columns={columns} + showLineNumbers={showLineNumbers} /> ))} @@ -217,6 +254,7 @@ export function EditRenderer({ content={line} compareContent={singleLineEdit ? oldLines[0] : undefined} columns={columns} + showLineNumbers={showLineNumbers} /> ))} @@ -229,11 +267,16 @@ interface MultiEditRendererProps { old_string: string; new_string: string; }>; + showLineNumbers?: boolean; // Whether to show line numbers (default true) } -export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) { +export function MultiEditRenderer({ + filePath, + edits, + showLineNumbers = true, +}: MultiEditRendererProps) { const columns = useTerminalWidth(); - const relativePath = formatRelativePath(filePath); + const relativePath = formatDisplayPath(filePath); // Count total additions and removals let totalAdditions = 0; @@ -244,14 +287,28 @@ export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) { totalRemovals += countLines(edit.old_string); }); + const gutterWidth = 4; // " " indent to align with tool return prefix + const contentWidth = Math.max(0, columns - gutterWidth); + return ( - - {" "} - ⎿ Updated {relativePath} with {totalAdditions} addition - {totalAdditions !== 1 ? "s" : ""} and {totalRemovals} removal - {totalRemovals !== 1 ? "s" : ""} - + + + + {" "} + + + + + + Updated {relativePath} with{" "} + {totalAdditions} addition + {totalAdditions !== 1 ? "s" : ""} and{" "} + {totalRemovals} removal + {totalRemovals !== 1 ? "s" : ""} + + + {/* For multi-edit, show each edit sequentially */} {edits.map((edit, index) => { @@ -267,25 +324,27 @@ export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) { {oldLines.map((line, i) => ( ))} {newLines.map((line, i) => ( ))} diff --git a/src/cli/components/MemoryDiffRenderer.tsx b/src/cli/components/MemoryDiffRenderer.tsx index 125c77d..0d244c1 100644 --- a/src/cli/components/MemoryDiffRenderer.tsx +++ b/src/cli/components/MemoryDiffRenderer.tsx @@ -58,12 +58,23 @@ export function MemoryDiffRenderer({ const contentWidth = Math.max(0, columns - prefixWidth); return ( - - {" "} - Inserted into memory block{" "} - {blockName} - {insertLine !== undefined && ` at line ${insertLine}`} - + + + + {" "} + + + + + + Inserted into memory block{" "} + + {blockName} + + {insertLine !== undefined && ` at line ${insertLine}`} + + + {insertText.split("\n").map((line: string, i: number) => ( - - {" "} - Created memory block{" "} - {blockName} - {description && ( - - {truncate(description, 40)} - )} - + + + + {" "} + + + + + + Created memory block{" "} + + {blockName} + + {description && ( + - {truncate(description, 40)} + )} + + + {fileText ?.split("\n") .slice(0, 3) @@ -134,12 +156,25 @@ export function MemoryDiffRenderer({ } case "delete": { + const prefixWidth = 4; + const contentWidth = Math.max(0, columns - prefixWidth); return ( - - {" "} - Deleted memory block{" "} - {blockName} - + + + + {" "} + + + + + + Deleted memory block{" "} + + {blockName} + + + + ); } @@ -147,33 +182,74 @@ export function MemoryDiffRenderer({ const newPath = args.new_path || ""; const newBlockName = newPath.split("/").pop() || newPath; const description = args.description; + const prefixWidth = 4; + const contentWidth = Math.max(0, columns - prefixWidth); if (description) { return ( - - {" "} - Updated description of{" "} - {blockName} - + + + + {" "} + + + + + + Updated description of{" "} + + {blockName} + + + + ); } return ( - - {" "} - Renamed{" "} - {blockName} to{" "} - {newBlockName} - + + + + {" "} + + + + + + Renamed{" "} + + {blockName} + {" "} + to{" "} + + {newBlockName} + + + + ); } - default: + default: { + const defaultPrefixWidth = 4; + const defaultContentWidth = Math.max(0, columns - defaultPrefixWidth); return ( - - {" "} - Memory operation: {command} on{" "} - {blockName} - + + + + {" "} + + + + + + Memory operation: {command} on{" "} + + {blockName} + + + + ); + } } } catch { // If parsing fails, return null to fall through to regular handling @@ -182,7 +258,9 @@ export function MemoryDiffRenderer({ } /** - * Renders a str_replace diff with word-level highlighting + * Renders a str_replace diff with line-level diffing and word-level highlighting. + * Uses Diff.diffLines() to only show lines that actually changed, + * instead of showing all old lines then all new lines. */ function MemoryStrReplaceDiff({ blockName, @@ -195,47 +273,111 @@ function MemoryStrReplaceDiff({ newStr: string; columns: number; }) { - const oldLines = oldStr.split("\n"); - const newLines = newStr.split("\n"); - const singleLine = oldLines.length === 1 && newLines.length === 1; + // Use line-level diff to find what actually changed + const lineDiffs = Diff.diffLines(oldStr, newStr); + + // Build display rows: only show added/removed lines, not unchanged + // For adjacent remove/add pairs, enable word-level highlighting + type DiffRow = { + type: "add" | "remove"; + content: string; + pairContent?: string; // For word-level diff when remove is followed by add + }; + const rows: DiffRow[] = []; + + for (let i = 0; i < lineDiffs.length; i++) { + const part = lineDiffs[i]; + if (!part) continue; + + // Skip unchanged lines (context) + if (!part.added && !part.removed) continue; + + // Split the value into individual lines (remove trailing newline) + const lines = part.value.replace(/\n$/, "").split("\n"); + + if (part.removed) { + // Check if next part is an addition (for word-level diff pairing) + const nextPart = lineDiffs[i + 1]; + const nextIsAdd = nextPart?.added; + const nextLines = nextIsAdd + ? nextPart.value.replace(/\n$/, "").split("\n") + : []; + + lines.forEach((line, lineIdx) => { + rows.push({ + type: "remove", + content: line, + // Pair with corresponding add line for word-level diff (if same count) + pairContent: + nextIsAdd && lines.length === nextLines.length + ? nextLines[lineIdx] + : undefined, + }); + }); + } else if (part.added) { + // Check if previous part was a removal (already handled pairing above) + const prevPart = lineDiffs[i - 1]; + const prevIsRemove = prevPart?.removed; + const prevLines = prevIsRemove + ? prevPart.value.replace(/\n$/, "").split("\n") + : []; + + lines.forEach((line, lineIdx) => { + rows.push({ + type: "add", + content: line, + // Pair with corresponding remove line for word-level diff (if same count) + pairContent: + prevIsRemove && lines.length === prevLines.length + ? prevLines[lineIdx] + : undefined, + }); + }); + } + } // Limit display to avoid huge diffs - const maxLines = 5; - const oldTruncated = oldLines.slice(0, maxLines); - const newTruncated = newLines.slice(0, maxLines); - const hasMore = oldLines.length > maxLines || newLines.length > maxLines; + const maxRows = 10; + const displayRows = rows.slice(0, maxRows); + const hasMore = rows.length > maxRows; + + const prefixWidth = 4; + const contentWidth = Math.max(0, columns - prefixWidth); return ( - - {" "} - Updated memory block{" "} - {blockName} - + + + + {" "} + + + + + + Updated memory block{" "} + + {blockName} + + + + - {/* Removals */} - {oldTruncated.map((line, i) => ( + {displayRows.map((row, i) => ( ))} - {/* Additions */} - {newTruncated.map((line, i) => ( - - ))} - - {hasMore && {" "}... diff truncated} + {hasMore && ( + + {" "}... {rows.length - maxRows} more lines + + )} ); } @@ -366,11 +508,22 @@ function PatchDiffRenderer({ return ( - - {" "} - Patched memory block{" "} - {label} - + + + + {" "} + + + + + + Patched memory block{" "} + + {label} + + + + {displayLines.map((line, i) => { // Skip @@ hunk headers if (line.startsWith("@@")) { diff --git a/src/cli/components/SubagentGroupDisplay.tsx b/src/cli/components/SubagentGroupDisplay.tsx index 5caa0eb..f66b4ad 100644 --- a/src/cli/components/SubagentGroupDisplay.tsx +++ b/src/cli/components/SubagentGroupDisplay.tsx @@ -24,6 +24,7 @@ import { subscribe, toggleExpanded, } from "../helpers/subagentState.js"; +import { useTerminalWidth } from "../hooks/useTerminalWidth.js"; import { BlinkDot } from "./BlinkDot.js"; import { colors } from "./colors.js"; @@ -62,6 +63,9 @@ interface AgentRowProps { const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => { const { treeChar, continueChar } = getTreeChars(isLast); + const columns = useTerminalWidth(); + const gutterWidth = 6; // tree char (1) + " ⎿ " (5) + const contentWidth = Math.max(0, columns - gutterWidth); const getDotElement = () => { switch (agent.status) { @@ -101,11 +105,17 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => { {/* Subagent URL */} {agent.agentURL && ( - {continueChar} - - {" ⎿ Subagent: "} - {agent.agentURL} - + + + {continueChar} + {" ⎿ "} + + + + + Subagent: {agent.agentURL} + + )} @@ -126,21 +136,38 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => { {/* Status line */} - {continueChar} {agent.status === "completed" ? ( - {" ⎿ Done"} + <> + {continueChar} + {" ⎿ Done"} + ) : agent.status === "error" ? ( - - {" ⎿ "} - {agent.error} - + <> + + + {continueChar} + {" ⎿ "} + + + + + {agent.error} + + + ) : lastTool ? ( - - {" ⎿ "} - {lastTool.name} - + <> + {continueChar} + + {" ⎿ "} + {lastTool.name} + + ) : ( - {" ⎿ Starting..."} + <> + {continueChar} + {" ⎿ Starting..."} + )} diff --git a/src/cli/components/SubagentGroupStatic.tsx b/src/cli/components/SubagentGroupStatic.tsx index 3e7b6d9..c9ec8bf 100644 --- a/src/cli/components/SubagentGroupStatic.tsx +++ b/src/cli/components/SubagentGroupStatic.tsx @@ -16,6 +16,7 @@ import { Box, Text } from "ink"; import { memo } from "react"; import { formatStats, getTreeChars } from "../helpers/subagentDisplay.js"; +import { useTerminalWidth } from "../hooks/useTerminalWidth.js"; import { colors } from "./colors.js"; // ============================================================================ @@ -49,6 +50,9 @@ interface AgentRowProps { const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { const { treeChar, continueChar } = getTreeChars(isLast); + const columns = useTerminalWidth(); + const gutterWidth = 6; // tree char (1) + " ⎿ " (5) + const contentWidth = Math.max(0, columns - gutterWidth); const dotColor = agent.status === "completed" @@ -72,24 +76,41 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { {/* Subagent URL */} {agent.agentURL && ( - {continueChar} - - {" ⎿ Subagent: "} - {agent.agentURL} - + + + {continueChar} + {" ⎿ "} + + + + + Subagent: {agent.agentURL} + + )} {/* Status line */} - {continueChar} {agent.status === "completed" ? ( - {" ⎿ Done"} + <> + {continueChar} + {" ⎿ Done"} + ) : ( - - {" ⎿ "} - {agent.error} - + <> + + + {continueChar} + {" ⎿ "} + + + + + {agent.error} + + + )} diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx index daacdaa..9c22216 100644 --- a/src/cli/components/ToolCallMessageRich.tsx +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -2,10 +2,17 @@ import { Box, Text } from "ink"; import { memo } from "react"; import { INTERRUPTED_BY_USER } from "../../constants"; import { clipToolReturn } from "../../tools/manager.js"; -import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js"; +import { + formatArgsDisplay, + parsePatchInput, + parsePatchOperations, +} from "../helpers/formatArgsDisplay.js"; import { getDisplayToolName, + isFileEditTool, + isFileWriteTool, isMemoryTool, + isPatchTool, isPlanTool, isTaskTool, isTodoTool, @@ -13,6 +20,11 @@ import { import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { BlinkDot } from "./BlinkDot.js"; import { colors } from "./colors.js"; +import { + EditRenderer, + MultiEditRenderer, + WriteRenderer, +} from "./DiffRenderer.js"; import { MarkdownDisplay } from "./MarkdownDisplay.js"; import { MemoryDiffRenderer } from "./MemoryDiffRenderer.js"; import { PlanRenderer } from "./PlanRenderer.js"; @@ -58,10 +70,29 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => { } // Apply tool name remapping - const displayName = getDisplayToolName(rawName); + let displayName = getDisplayToolName(rawName); + + // For Patch tools, override display name based on patch content + // (Add → Write, Update → Update, Delete → Delete) + if (isPatchTool(rawName)) { + try { + const parsedArgs = JSON.parse(argsText); + if (parsedArgs.input) { + const patchInfo = parsePatchInput(parsedArgs.input); + if (patchInfo) { + if (patchInfo.kind === "add") displayName = "Write"; + else if (patchInfo.kind === "update") displayName = "Update"; + else if (patchInfo.kind === "delete") displayName = "Delete"; + } + } + } catch { + // Keep default "Patch" name if parsing fails + } + } // Format arguments for display using the old formatting logic - const formatted = formatArgsDisplay(argsText); + // 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 @@ -227,6 +258,120 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => { // 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 ( + + ); + } + + // Single edit: has old_string/new_string + if (parsedArgs.old_string !== undefined) { + return ( + + ); + } + } catch { + // If parsing fails, fall through to regular handling + } + } + + // Check if this is a file write tool - show written content + if (isFileWriteTool(rawName) && line.resultOk !== false && line.argsText) { + try { + const parsedArgs = JSON.parse(line.argsText); + const filePath = parsedArgs.file_path || ""; + const content = parsedArgs.content || ""; + + if (filePath && content) { + return ; + } + } catch { + // If parsing fails, fall through to regular handling + } + } + + // Check if this is a patch tool - show diff/content based on operation type + if (isPatchTool(rawName) && line.resultOk !== false && line.argsText) { + try { + const parsedArgs = JSON.parse(line.argsText); + if (parsedArgs.input) { + const operations = parsePatchOperations(parsedArgs.input); + + if (operations.length > 0) { + return ( + + {operations.map((op) => { + if (op.kind === "add") { + return ( + + ); + } + if (op.kind === "update") { + return ( + + ); + } + if (op.kind === "delete") { + const gutterWidth = 4; + return ( + + + + {" "} + + + + + + Deleted {op.path} + + + + ); + } + return null; + })} + + ); + } + } + } catch { + // If parsing fails, fall through to regular handling + } + } + // Regular result handling const isError = line.resultOk === false; @@ -278,16 +423,22 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => { {isMemoryTool(rawName) ? ( <> - {displayName} + + {displayName} + {args} ) : ( - `${displayName}${args}` + <> + {displayName} + {args} + )} ) : ( => typeof v === "object" && v !== null; -export function formatArgsDisplay(argsJson: string): { +/** + * Formats a file path for display (matches Claude Code style): + * - Files within cwd: relative path without ./ prefix + * - Files outside cwd: full absolute path + */ +function formatDisplayPath(filePath: string): string { + const cwd = process.cwd(); + const relativePath = relative(cwd, filePath); + // If path goes outside cwd (starts with ..), show full absolute path + if (relativePath.startsWith("..")) { + return filePath; + } + return relativePath; +} + +/** + * Parses a patch input to extract operation type and file path. + * Returns null if parsing fails. Used for tool call display. + */ +export function parsePatchInput( + input: string, +): { kind: "add" | "update" | "delete"; path: string } | null { + if (!input) return null; + + // Look for the first operation marker + const addMatch = /\*\*\* Add File:\s*(.+)/.exec(input); + if (addMatch?.[1]) { + return { kind: "add", path: addMatch[1].trim() }; + } + + const updateMatch = /\*\*\* Update File:\s*(.+)/.exec(input); + if (updateMatch?.[1]) { + return { kind: "update", path: updateMatch[1].trim() }; + } + + const deleteMatch = /\*\*\* Delete File:\s*(.+)/.exec(input); + if (deleteMatch?.[1]) { + return { kind: "delete", path: deleteMatch[1].trim() }; + } + + return null; +} + +/** + * Patch operation types for result rendering + */ +export type PatchOperation = + | { kind: "add"; path: string; content: string } + | { kind: "update"; path: string; oldString: string; newString: string } + | { kind: "delete"; path: string }; + +/** + * Parses a patch input to extract all operations with full content. + * Used for rendering patch results (shows diffs/content). + * Based on ApplyPatch.ts parsing logic. + */ +export function parsePatchOperations(input: string): PatchOperation[] { + if (!input) return []; + + const lines = input.split(/\r?\n/); + const beginIdx = lines.findIndex((l) => l.trim() === "*** Begin Patch"); + const endIdx = lines.findIndex((l) => l.trim() === "*** End Patch"); + + // If no markers, try to parse anyway (some patches might not have them) + const startIdx = beginIdx === -1 ? 0 : beginIdx + 1; + const stopIdx = endIdx === -1 ? lines.length : endIdx; + + const operations: PatchOperation[] = []; + let i = startIdx; + + while (i < stopIdx) { + const line = lines[i]?.trim(); + if (!line) { + i++; + continue; + } + + // Add File operation + if (line.startsWith("*** Add File:")) { + const path = line.replace("*** Add File:", "").trim(); + i++; + const contentLines: string[] = []; + while (i < stopIdx) { + const raw = lines[i]; + if (raw === undefined || raw.startsWith("*** ")) break; + if (raw.startsWith("+")) { + contentLines.push(raw.slice(1)); + } + i++; + } + operations.push({ kind: "add", path, content: contentLines.join("\n") }); + continue; + } + + // Update File operation + if (line.startsWith("*** Update File:")) { + const path = line.replace("*** Update File:", "").trim(); + i++; + + // Skip optional "*** Move to:" line + if (i < stopIdx && lines[i]?.startsWith("*** Move to:")) { + i++; + } + + // Collect all hunk lines + const oldParts: string[] = []; + const newParts: string[] = []; + + while (i < stopIdx) { + const hLine = lines[i]; + if (hLine === undefined || hLine.startsWith("*** ")) break; + + if (hLine.startsWith("@@")) { + // Skip hunk header + i++; + continue; + } + + // Parse diff lines + if (hLine === "") { + // Empty line counts as context + oldParts.push(""); + newParts.push(""); + } else { + const prefix = hLine[0]; + const text = hLine.slice(1); + + if (prefix === " ") { + // Context line - appears in both + oldParts.push(text); + newParts.push(text); + } else if (prefix === "-") { + // Removed line + oldParts.push(text); + } else if (prefix === "+") { + // Added line + newParts.push(text); + } + } + i++; + } + + operations.push({ + kind: "update", + path, + oldString: oldParts.join("\n"), + newString: newParts.join("\n"), + }); + continue; + } + + // Delete File operation + if (line.startsWith("*** Delete File:")) { + const path = line.replace("*** Delete File:", "").trim(); + operations.push({ kind: "delete", path }); + i++; + continue; + } + + // Unknown line, skip + i++; + } + + return operations; +} + +export function formatArgsDisplay( + argsJson: string, + toolName?: string, +): { display: string; parsed: Record; } { let parsed: Record = {}; let display = "…"; + try { if (argsJson?.trim()) { const p = JSON.parse(argsJson); @@ -22,6 +201,83 @@ export function formatArgsDisplay(argsJson: string): { >; if ("request_heartbeat" in clone) delete clone.request_heartbeat; parsed = clone; + + // Special handling for file tools - show clean relative path + if (toolName) { + // Patch tools: parse input and show operation + path + if (isPatchTool(toolName) && typeof parsed.input === "string") { + const patchInfo = parsePatchInput(parsed.input); + if (patchInfo) { + display = formatDisplayPath(patchInfo.path); + return { display, parsed }; + } + // Fallback if parsing fails + display = "…"; + return { display, parsed }; + } + + // Edit tools: show just the file path + if (isFileEditTool(toolName) && parsed.file_path) { + const filePath = String(parsed.file_path); + display = formatDisplayPath(filePath); + return { display, parsed }; + } + + // Write tools: show just the file path + if (isFileWriteTool(toolName) && parsed.file_path) { + const filePath = String(parsed.file_path); + display = formatDisplayPath(filePath); + return { display, parsed }; + } + + // Read tools: show file path + any other useful args (limit, offset) + if (isFileReadTool(toolName) && parsed.file_path) { + const filePath = String(parsed.file_path); + const relativePath = formatDisplayPath(filePath); + + // Collect other non-hidden args + const otherArgs: string[] = []; + for (const [k, v] of Object.entries(parsed)) { + if (k === "file_path") continue; + if (v === undefined || v === null) continue; + if (typeof v === "boolean" || typeof v === "number") { + otherArgs.push(`${k}=${v}`); + } else if (typeof v === "string" && v.length <= 30) { + otherArgs.push(`${k}="${v}"`); + } + } + + if (otherArgs.length > 0) { + display = `${relativePath}, ${otherArgs.join(", ")}`; + } else { + display = relativePath; + } + return { display, parsed }; + } + + // Shell/Bash tools: show just the command + if (isShellTool(toolName) && parsed.command) { + // Handle both string and array command formats + if (Array.isArray(parsed.command)) { + // For ["bash", "-c", "actual command"], show just the actual command + const cmd = parsed.command; + if ( + cmd.length >= 3 && + (cmd[0] === "bash" || cmd[0] === "sh") && + (cmd[1] === "-c" || cmd[1] === "-lc") + ) { + display = cmd.slice(2).join(" "); + } else { + display = cmd.join(" "); + } + } else { + display = String(parsed.command); + } + return { display, parsed }; + } + } + + // Default handling for other tools const keys = Object.keys(parsed); const firstKey = keys[0]; if ( diff --git a/src/cli/helpers/toolNameMapping.ts b/src/cli/helpers/toolNameMapping.ts index a3ca10a..aac8c08 100644 --- a/src/cli/helpers/toolNameMapping.ts +++ b/src/cli/helpers/toolNameMapping.ts @@ -13,7 +13,7 @@ export function getDisplayToolName(rawName: string): string { // Anthropic toolset if (rawName === "write") return "Write"; - if (rawName === "edit" || rawName === "multi_edit") return "Edit"; + if (rawName === "edit" || rawName === "multi_edit") return "Update"; if (rawName === "read") return "Read"; if (rawName === "bash") return "Bash"; if (rawName === "grep") return "Grep"; @@ -26,7 +26,7 @@ export function getDisplayToolName(rawName: string): string { // Codex toolset (snake_case) if (rawName === "update_plan") return "Planning"; - if (rawName === "shell_command" || rawName === "shell") return "Shell"; + if (rawName === "shell_command" || rawName === "shell") return "Bash"; if (rawName === "read_file") return "Read"; if (rawName === "list_dir") return "LS"; if (rawName === "grep_files") return "Grep"; @@ -34,14 +34,14 @@ export function getDisplayToolName(rawName: string): string { // Codex toolset (PascalCase) if (rawName === "UpdatePlan") return "Planning"; - if (rawName === "ShellCommand" || rawName === "Shell") return "Shell"; + if (rawName === "ShellCommand" || rawName === "Shell") return "Bash"; if (rawName === "ReadFile") return "Read"; if (rawName === "ListDir") return "LS"; if (rawName === "GrepFiles") return "Grep"; if (rawName === "ApplyPatch") return "Patch"; // Gemini toolset (snake_case) - if (rawName === "run_shell_command") return "Shell"; + if (rawName === "run_shell_command") return "Bash"; if (rawName === "read_file_gemini") return "Read"; if (rawName === "list_directory") return "LS"; if (rawName === "glob_gemini") return "Glob"; @@ -51,7 +51,7 @@ export function getDisplayToolName(rawName: string): string { if (rawName === "read_many_files") return "Read Multiple"; // Gemini toolset (PascalCase) - if (rawName === "RunShellCommand") return "Shell"; + if (rawName === "RunShellCommand") return "Bash"; if (rawName === "ReadFileGemini") return "Read"; if (rawName === "ListDirectory") return "LS"; if (rawName === "GlobGemini") return "Glob"; @@ -61,11 +61,11 @@ export function getDisplayToolName(rawName: string): string { if (rawName === "ReadManyFiles") return "Read Multiple"; // Additional tools - if (rawName === "Replace" || rawName === "replace") return "Edit"; + if (rawName === "Replace" || rawName === "replace") return "Update"; if (rawName === "WriteFile" || rawName === "write_file") return "Write"; - if (rawName === "KillBash") return "Kill Shell"; + if (rawName === "KillBash") return "Kill Bash"; if (rawName === "BashOutput") return "Shell Output"; - if (rawName === "MultiEdit") return "Edit"; + if (rawName === "MultiEdit") return "Update"; // No mapping found, return as-is return rawName; @@ -119,3 +119,70 @@ export function isFancyUITool(name: string): boolean { export function isMemoryTool(name: string): boolean { return name === "memory" || name === "memory_apply_patch"; } + +/** + * Checks if a tool is a file edit tool (has old_string/new_string args) + */ +export function isFileEditTool(name: string): boolean { + return ( + name === "edit" || + name === "Edit" || + name === "multi_edit" || + name === "MultiEdit" || + name === "Replace" || + name === "replace" + ); +} + +/** + * Checks if a tool is a file write tool (has file_path/content args) + */ +export function isFileWriteTool(name: string): boolean { + return ( + name === "write" || + name === "Write" || + name === "WriteFile" || + name === "write_file" || + name === "write_file_gemini" || + name === "WriteFileGemini" + ); +} + +/** + * Checks if a tool is a file read tool (has file_path arg) + */ +export function isFileReadTool(name: string): boolean { + return ( + name === "read" || + name === "Read" || + name === "ReadFile" || + name === "read_file" || + name === "read_file_gemini" || + name === "ReadFileGemini" || + name === "read_many_files" || + name === "ReadManyFiles" + ); +} + +/** + * Checks if a tool is a patch tool (applies unified diffs) + */ +export function isPatchTool(name: string): boolean { + return name === "apply_patch" || name === "ApplyPatch"; +} + +/** + * Checks if a tool is a shell/bash tool + */ +export function isShellTool(name: string): boolean { + return ( + name === "bash" || + name === "Bash" || + name === "shell" || + name === "Shell" || + name === "shell_command" || + name === "ShellCommand" || + name === "run_shell_command" || + name === "RunShellCommand" + ); +}