From f073be0b98312417510a6d0d928c0948b1e358ba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 10 Jan 2026 23:37:13 +0700 Subject: [PATCH] feat(app): improve tool call sheet with word-level diff and better UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add word-level highlighting using LCS algorithm for edit diffs - Match diff colors to explorer sidebar (GitHub-style rgba backgrounds) - Use discriminated union for tool call parsing (shell, edit, generic) - Add horizontal scroll for bash/command output instead of wrapping - Remove status badge from bottom sheet header - Fix sheet snap points (60%/95%) and disable dynamic sizing - Add min-width to diff viewer for full-width backgrounds 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/app/src/components/diff-viewer.tsx | 62 ++- packages/app/src/components/message.tsx | 6 +- .../app/src/components/tool-call-sheet.tsx | 517 +++--------------- .../app/src/utils/tool-call-parsers.test.ts | 160 ++++++ packages/app/src/utils/tool-call-parsers.ts | 241 +++++++- 5 files changed, 518 insertions(+), 468 deletions(-) create mode 100644 packages/app/src/utils/tool-call-parsers.test.ts diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 69cd5716c..c838526e5 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -3,7 +3,7 @@ import { View, Text } from "react-native"; import { ScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; -import type { DiffLine } from "@/utils/tool-call-parsers"; +import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers"; interface DiffViewerProps { diffLines: DiffLine[]; @@ -12,6 +12,8 @@ interface DiffViewerProps { } export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No changes to display" }: DiffViewerProps) { + const [scrollViewWidth, setScrollViewWidth] = React.useState(0); + if (!diffLines.length) { return ( @@ -32,8 +34,9 @@ export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No change nestedScrollEnabled showsHorizontalScrollIndicator contentContainerStyle={styles.horizontalContent} + onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)} > - + 0 && { minWidth: scrollViewWidth }]}> {diffLines.map((line, index) => ( - - {line.content} - + {line.segments ? ( + + + {line.content[0]} + + {line.segments.map((segment, segIdx) => ( + + {segment.text} + + ))} + + ) : ( + + {line.content} + + )} ))} @@ -92,16 +114,22 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.mutedForeground, }, addLine: { - backgroundColor: theme.colors.palette.green[900], + backgroundColor: "rgba(46, 160, 67, 0.15)", }, addText: { - color: theme.colors.palette.green[200], + color: theme.colors.foreground, }, removeLine: { - backgroundColor: theme.colors.palette.red[900], + backgroundColor: "rgba(248, 81, 73, 0.1)", }, removeText: { - color: theme.colors.palette.red[200], + color: theme.colors.foreground, + }, + addHighlight: { + backgroundColor: "rgba(46, 160, 67, 0.4)", + }, + removeHighlight: { + backgroundColor: "rgba(248, 81, 73, 0.35)", }, contextLine: { backgroundColor: theme.colors.card, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 56bcc655c..6e12a0faa 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1168,12 +1168,8 @@ export const ToolCall = memo(function ToolCall({ args, result, error, - parsedEditEntries, - parsedReadEntries, - parsedCommandDetails, - cwd, }); - }, [openToolCall, toolName, kind, status, args, result, error, parsedEditEntries, parsedReadEntries, parsedCommandDetails, cwd]); + }, [openToolCall, toolName, kind, status, args, result, error]); // Dummy renderDetails to make badge tappable - actual content is rendered in the sheet const dummyRenderDetails = useCallback(() => null, []); diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 3467977e2..9ff4411cc 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -15,16 +15,8 @@ import { BottomSheetBackdrop, } from "@gorhom/bottom-sheet"; import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native"; +import { parseToolCallDisplay, buildLineDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { DiffViewer } from "./diff-viewer"; -import { - extractKeyValuePairs, - stripCwdPrefix, - type EditEntry, - type ReadEntry, - type CommandDetails, - type DiffLine, - type KeyValuePair, -} from "@/utils/tool-call-parsers"; // ----- Types ----- @@ -35,10 +27,6 @@ export interface ToolCallSheetData { args?: unknown; result?: unknown; error?: unknown; - parsedEditEntries?: EditEntry[]; - parsedReadEntries?: ReadEntry[]; - parsedCommandDetails?: CommandDetails | null; - cwd?: string; } interface ToolCallSheetContextValue { @@ -83,80 +71,6 @@ function formatValue(value: unknown): string { } } -// Type guard for structured tool results -type StructuredToolResult = { - type: "command" | "file_write" | "file_edit" | "file_read" | "generic"; - [key: string]: unknown; -}; - -function isStructuredToolResult(result: unknown): result is StructuredToolResult { - return ( - typeof result === "object" && - result !== null && - "type" in result && - typeof result.type === "string" && - ["command", "file_write", "file_edit", "file_read", "generic"].includes(result.type) - ); -} - -// Build diff lines from before/after strings -function buildLineDiffFromStrings(originalText: string, updatedText: string): DiffLine[] { - const splitIntoLines = (text: string): string[] => { - if (!text) return []; - return text.replace(/\r\n/g, "\n").split("\n"); - }; - - const originalLines = splitIntoLines(originalText); - const updatedLines = splitIntoLines(updatedText); - - const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0; - if (!hasAnyContent) return []; - - const m = originalLines.length; - const n = updatedLines.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); - - for (let i = m - 1; i >= 0; i -= 1) { - for (let j = n - 1; j >= 0; j -= 1) { - if (originalLines[i] === updatedLines[j]) { - dp[i][j] = dp[i + 1][j + 1] + 1; - } else { - dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); - } - } - } - - const diff: DiffLine[] = []; - let i = 0; - let j = 0; - - while (i < m && j < n) { - if (originalLines[i] === updatedLines[j]) { - diff.push({ type: "context", content: ` ${originalLines[i]}` }); - i += 1; - j += 1; - } else if (dp[i + 1][j] >= dp[i][j + 1]) { - diff.push({ type: "remove", content: `-${originalLines[i]}` }); - i += 1; - } else { - diff.push({ type: "add", content: `+${updatedLines[j]}` }); - j += 1; - } - } - - while (i < m) { - diff.push({ type: "remove", content: `-${originalLines[i]}` }); - i += 1; - } - - while (j < n) { - diff.push({ type: "add", content: `+${updatedLines[j]}` }); - j += 1; - } - - return diff; -} - // ----- Provider Component ----- interface ToolCallSheetProviderProps { @@ -167,7 +81,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) const bottomSheetRef = useRef(null); const [sheetData, setSheetData] = React.useState(null); - const snapPoints = useMemo(() => ["60%", "90%"], []); + const snapPoints = useMemo(() => ["60%", "95%"], []); const openToolCall = useCallback((data: ToolCallSheetData) => { setSheetData(data); @@ -207,6 +121,8 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) (args !== undefined ? formatValue(args) : ""), - [args] - ); - const serializedResult = useMemo( - () => (result !== undefined ? formatValue(result) : ""), - [result] - ); const serializedError = useMemo( () => (error !== undefined ? formatValue(error) : ""), [error] ); - // Check if result has a type field for structured rendering - const structuredResult = useMemo( - () => (isStructuredToolResult(result) ? result : null), - [result] + // Parse tool call display using discriminated union + const toolCallDisplay = useMemo( + () => parseToolCallDisplay(args, result), + [args, result] ); - // Extract functions for structured results - const extractCommandFromStructured = useCallback( - (structured: StructuredToolResult): CommandDetails | null => { - if (structured.type !== "command") return null; - - const cmd: CommandDetails = {}; - if (typeof structured.command === "string") cmd.command = structured.command; - if (typeof structured.cwd === "string") cmd.cwd = structured.cwd; - if (typeof structured.output === "string") cmd.output = structured.output; - if (typeof structured.exitCode === "number") cmd.exitCode = structured.exitCode; - - return cmd.command || cmd.output ? cmd : null; - }, - [] - ); - - const extractDiffFromStructured = useCallback( - (structured: StructuredToolResult): EditEntry[] => { - if (structured.type !== "file_write" && structured.type !== "file_edit") { - return []; - } - - const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined; - - if (structured.type === "file_write") { - const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : ""; - const newContent = typeof structured.newContent === "string" ? structured.newContent : ""; - const diffLines = buildLineDiffFromStrings(oldContent, newContent); - if (diffLines.length > 0) { - return [{ filePath, diffLines }]; - } - } - - if (structured.type === "file_edit") { - if (Array.isArray(structured.diffLines)) { - return [{ filePath, diffLines: structured.diffLines as DiffLine[] }]; - } - const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : ""; - const newContent = typeof structured.newContent === "string" ? structured.newContent : ""; - const diffLines = buildLineDiffFromStrings(oldContent, newContent); - if (diffLines.length > 0) { - return [{ filePath, diffLines }]; - } - } - - return []; - }, - [] - ); - - const extractReadFromStructured = useCallback( - (structured: StructuredToolResult): ReadEntry[] => { - if (structured.type !== "file_read") return []; - - const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined; - const content = typeof structured.content === "string" ? structured.content : ""; - - if (content) { - return [{ filePath, content }]; - } - - return []; - }, - [] - ); + // Compute diff lines for edit type + const editDiffLines = useMemo((): DiffLine[] => { + if (toolCallDisplay.type !== "edit") return []; + return buildLineDiff(toolCallDisplay.oldString, toolCallDisplay.newString); + }, [toolCallDisplay]); // Render content sections const renderSections = useCallback(() => { const sections: ReactNode[] = []; - let hasOutput = false; - // Always show args first if available - if (args !== undefined) { - // Add Input group header + if (toolCallDisplay.type === "shell") { + // Shell tool: show command and output as single block sections.push( - - Input + + Command + + {toolCallDisplay.command} + + {toolCallDisplay.output ? ( + + + {toolCallDisplay.output} + + + ) : null} ); - - const argPairs = extractKeyValuePairs(args); - if (argPairs.length > 0) { - argPairs.forEach((pair, index) => { + } else if (toolCallDisplay.type === "edit") { + // Edit tool: show file path and diff + sections.push( + + File + + {toolCallDisplay.filePath} + + {editDiffLines.length > 0 ? ( + + + + ) : null} + + ); + } else { + // Generic tool: show input/output as key-value pairs + if (toolCallDisplay.input.length > 0) { + sections.push( + + Input + + ); + toolCallDisplay.input.forEach((pair, index) => { sections.push( - + {pair.key} ); }); - } else { - // Fallback to raw JSON display - sections.push( - - Arguments - - {serializedArgs} - - - ); } - } - // Helper to add output header once - const addOutputHeader = () => { - if (!hasOutput) { - hasOutput = true; + if (toolCallDisplay.output.length > 0) { sections.push( Output ); - } - }; - - // Render based on structured result type or raw data - if (structuredResult) { - switch (structuredResult.type) { - case "command": { - const cmd = parsedCommandDetails ?? extractCommandFromStructured(structuredResult); - if (cmd) { - addOutputHeader(); - sections.push( - - Command - {cmd.command ? ( - - {cmd.command} - - ) : null} - {cmd.cwd ? ( - - Directory - {cmd.cwd} - - ) : null} - {cmd.exitCode !== undefined ? ( - - Exit Code - - {cmd.exitCode === null ? "Unknown" : cmd.exitCode} - - - ) : null} - {cmd.output ? ( - - {cmd.output} - - ) : null} - - ); - } - break; - } - - case "file_write": - case "file_edit": { - const diffs = parsedEditEntries?.length - ? parsedEditEntries - : extractDiffFromStructured(structuredResult); - if (diffs.length > 0) { - addOutputHeader(); - } - diffs.forEach((entry, index) => { - sections.push( - - Diff - {entry.filePath ? ( - - {stripCwdPrefix(entry.filePath, cwd)} - - ) : null} - - - - - ); - }); - break; - } - - case "file_read": { - const reads = parsedReadEntries?.length - ? parsedReadEntries - : extractReadFromStructured(structuredResult); - if (reads.length > 0) { - addOutputHeader(); - } - reads.forEach((entry, index) => { - sections.push( - - Read Result - {entry.filePath ? ( - - {stripCwdPrefix(entry.filePath, cwd)} - - ) : null} - - {entry.content} - - - ); - }); - break; - } - - case "generic": - default: { - if (result !== undefined && sections.length === 1) { - // Only args shown, add result - addOutputHeader(); - sections.push( - - Result - - {serializedResult} - - - ); - } - break; - } - } - } else if (result !== undefined) { - // No structured result - try to extract key-value pairs - addOutputHeader(); - const keyValuePairs = extractKeyValuePairs(result); - if (keyValuePairs.length > 0) { - keyValuePairs.forEach((pair, index) => { + toolCallDisplay.output.forEach((pair, index) => { sections.push( - + {pair.key} ); }); - } else { - // Fallback to raw JSON display - sections.push( - - Result - - {serializedResult} - - - ); } } @@ -587,21 +293,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { } return sections; - }, [ - args, - result, - error, - serializedArgs, - serializedResult, - serializedError, - structuredResult, - parsedEditEntries, - parsedReadEntries, - parsedCommandDetails, - extractCommandFromStructured, - extractDiffFromStructured, - extractReadFromStructured, - ]); + }, [toolCallDisplay, editDiffLines, error, serializedError]); return ( @@ -612,27 +304,6 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { {toolName} - {status && ( - - - {status === "executing" ? "Running" : status === "completed" ? "Done" : "Failed"} - - - )} @@ -687,33 +358,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, flex: 1, }, - statusBadge: { - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.full, - }, - statusExecuting: { - backgroundColor: theme.colors.palette.blue[900], - }, - statusCompleted: { - backgroundColor: theme.colors.palette.green[900], - }, - statusFailed: { - backgroundColor: theme.colors.palette.red[900], - }, - statusText: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.semibold, - }, - statusTextExecuting: { - color: theme.colors.palette.blue[200], - }, - statusTextCompleted: { - color: theme.colors.palette.green[200], - }, - statusTextFailed: { - color: theme.colors.palette.red[200], - }, closeButton: { padding: theme.spacing[2], }, @@ -812,21 +456,4 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontStyle: "italic", }, - metaRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - metaLabel: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - textTransform: "uppercase", - letterSpacing: 0.5, - }, - metaValue: { - color: theme.colors.foreground, - fontFamily: Fonts.mono, - fontSize: theme.fontSize.xs, - flex: 1, - }, })); diff --git a/packages/app/src/utils/tool-call-parsers.test.ts b/packages/app/src/utils/tool-call-parsers.test.ts new file mode 100644 index 000000000..5dd33ccf4 --- /dev/null +++ b/packages/app/src/utils/tool-call-parsers.test.ts @@ -0,0 +1,160 @@ +import { describe, test, expect } from "vitest"; +import { + extractKeyValuePairs, + parseToolCallDisplay, + type ToolCallDisplay, +} from "./tool-call-parsers"; + +describe("tool-call-parsers - real runtime shapes", () => { + // Real data captured from Claude agent test: "shows the command inside pending tool calls" + // Run: npx vitest run claude-agent.test.ts -t "shows the command" + + test("bash tool call - input shape", () => { + // REAL shape from Claude SDK timeline event (status: pending/completed) + const bashInput = { + command: "pwd", + description: "Print working directory", + }; + + const pairs = extractKeyValuePairs(bashInput); + expect(pairs).toContainEqual({ key: "command", value: "pwd" }); + expect(pairs).toContainEqual({ key: "description", value: "Print working directory" }); + }); + + test("bash tool call - output shape (completed)", () => { + // REAL shape from Claude SDK timeline event (status: completed) + // NOTE: output already has type: "command" discriminator! + const bashOutput = { + type: "command", + command: "pwd", + output: "/private/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/claude-agent-e2e-9tnmUm", + }; + + const pairs = extractKeyValuePairs(bashOutput); + expect(pairs).toContainEqual({ key: "type", value: "command" }); + expect(pairs).toContainEqual({ key: "command", value: "pwd" }); + expect(pairs).toContainEqual({ key: "output", value: expect.stringContaining("claude-agent") }); + }); +}); + +describe("parseToolCallDisplay", () => { + test("parses completed bash tool call into shell type", () => { + const input = { command: "pwd", description: "Print working directory" }; + const result = { type: "command", command: "pwd", output: "/some/path" }; + + const display: ToolCallDisplay = parseToolCallDisplay(input, result); + expect(display.type).toBe("shell"); + if (display.type === "shell") { + expect(display.command).toBe("pwd"); + expect(display.output).toBe("/some/path"); + } + }); + + test("parses pending bash tool call into shell type with empty output", () => { + // When tool is pending, we have input but no result yet + const input = { command: "pwd", description: "Print working directory" }; + const result = undefined; + + const display: ToolCallDisplay = parseToolCallDisplay(input, result); + expect(display.type).toBe("shell"); + if (display.type === "shell") { + expect(display.command).toBe("pwd"); + expect(display.output).toBe(""); + } + }); + + test("handles command as array", () => { + const input = { command: ["git", "status"] }; + const result = { type: "command", output: "On branch main" }; + + const display: ToolCallDisplay = parseToolCallDisplay(input, result); + expect(display.type).toBe("shell"); + if (display.type === "shell") { + expect(display.command).toBe("git status"); + expect(display.output).toBe("On branch main"); + } + }); + + test("parses non-command tool call into generic type", () => { + const input = { file_path: "/some/file.txt" }; + const result = { content: "file contents here", lineCount: 42 }; + + const display: ToolCallDisplay = parseToolCallDisplay(input, result); + expect(display.type).toBe("generic"); + if (display.type === "generic") { + expect(display.input).toContainEqual({ key: "file_path", value: "/some/file.txt" }); + expect(display.output).toContainEqual({ key: "content", value: "file contents here" }); + expect(display.output).toContainEqual({ key: "lineCount", value: "42" }); + } + }); + + test("handles file_write output as generic", () => { + const input = { file_path: "/some/file.txt", content: "new content" }; + const result = { type: "file_write", filePath: "/some/file.txt" }; + + const display: ToolCallDisplay = parseToolCallDisplay(input, result); + expect(display.type).toBe("generic"); + }); + + test("handles undefined input and result gracefully", () => { + const display: ToolCallDisplay = parseToolCallDisplay(undefined, undefined); + expect(display.type).toBe("generic"); + if (display.type === "generic") { + expect(display.input).toEqual([]); + expect(display.output).toEqual([]); + } + }); + + test("parses edit tool call into edit type with old_string/new_string", () => { + const input = { + file_path: "/some/file.txt", + old_string: "const foo = 1;", + new_string: "const foo = 2;", + }; + const result = { + type: "file_edit", + filePath: "/some/file.txt", + }; + + const display: ToolCallDisplay = parseToolCallDisplay(input, result); + expect(display.type).toBe("edit"); + if (display.type === "edit") { + expect(display.filePath).toBe("/some/file.txt"); + expect(display.oldString).toBe("const foo = 1;"); + expect(display.newString).toBe("const foo = 2;"); + } + }); + + test("parses edit tool call with old_str/new_str variants", () => { + const input = { + file_path: "/some/file.txt", + old_str: "line 1", + new_str: "line 2", + }; + const result = undefined; + + const display: ToolCallDisplay = parseToolCallDisplay(input, result); + expect(display.type).toBe("edit"); + if (display.type === "edit") { + expect(display.filePath).toBe("/some/file.txt"); + expect(display.oldString).toBe("line 1"); + expect(display.newString).toBe("line 2"); + } + }); + + test("parses pending edit tool call (no result yet)", () => { + const input = { + file_path: "/some/file.txt", + old_string: "old content", + new_string: "new content", + }; + + const display: ToolCallDisplay = parseToolCallDisplay(input, undefined); + expect(display.type).toBe("edit"); + if (display.type === "edit") { + expect(display.filePath).toBe("/some/file.txt"); + expect(display.oldString).toBe("old content"); + expect(display.newString).toBe("new content"); + } + }); +}); diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index 99066738a..52a5407e4 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -1,8 +1,14 @@ import { z } from "zod"; +export type DiffSegment = { + text: string; + changed: boolean; +}; + export type DiffLine = { type: "add" | "remove" | "context" | "header"; content: string; + segments?: DiffSegment[]; }; export type EditEntry = { @@ -38,7 +44,112 @@ function splitIntoLines(text: string): string[] { return text.replace(/\r\n/g, "\n").split("\n"); } -function buildLineDiff(originalText: string, updatedText: string): DiffLine[] { +function splitIntoWords(text: string): string[] { + const result: string[] = []; + let current = ""; + let inWord = false; + + for (const char of text) { + const isWordChar = /\w/.test(char); + if (isWordChar) { + if (!inWord && current) { + result.push(current); + current = ""; + } + inWord = true; + current += char; + } else { + if (inWord && current) { + result.push(current); + current = ""; + } + inWord = false; + current += char; + } + } + if (current) { + result.push(current); + } + return result; +} + +function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments: DiffSegment[]; newSegments: DiffSegment[] } { + const oldWords = splitIntoWords(oldLine); + const newWords = splitIntoWords(newLine); + + const m = oldWords.length; + const n = newWords.length; + + // LCS to find common words + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + + for (let i = m - 1; i >= 0; i -= 1) { + for (let j = n - 1; j >= 0; j -= 1) { + if (oldWords[i] === newWords[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + // Mark which words are in LCS (unchanged) + const oldInLCS = new Set(); + const newInLCS = new Set(); + + let i = 0; + let j = 0; + while (i < m && j < n) { + if (oldWords[i] === newWords[j]) { + oldInLCS.add(i); + newInLCS.add(j); + i += 1; + j += 1; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + i += 1; + } else { + j += 1; + } + } + + // Build segments: consecutive unchanged or changed words merged + const buildSegments = (words: string[], inLCS: Set): DiffSegment[] => { + if (words.length === 0) return []; + + const segments: DiffSegment[] = []; + let currentText = ""; + let currentChanged: boolean | null = null; + + for (let idx = 0; idx < words.length; idx++) { + const word = words[idx]; + const changed = !inLCS.has(idx); + + if (currentChanged === null) { + currentText = word; + currentChanged = changed; + } else if (changed === currentChanged) { + currentText += word; + } else { + segments.push({ text: currentText, changed: currentChanged }); + currentText = word; + currentChanged = changed; + } + } + + if (currentText) { + segments.push({ text: currentText, changed: currentChanged ?? false }); + } + + return segments; + }; + + return { + oldSegments: buildSegments(oldWords, oldInLCS), + newSegments: buildSegments(newWords, newInLCS), + }; +} + +export function buildLineDiff(originalText: string, updatedText: string): DiffLine[] { const originalLines = splitIntoLines(originalText); const updatedLines = splitIntoLines(updatedText); @@ -91,6 +202,22 @@ function buildLineDiff(originalText: string, updatedText: string): DiffLine[] { j += 1; } + // Post-process to add word-level segments for adjacent remove/add pairs + for (let idx = 0; idx < diff.length - 1; idx++) { + const curr = diff[idx]; + const next = diff[idx + 1]; + + if (curr.type === "remove" && next.type === "add") { + // Strip the leading -/+ from content for comparison + const oldLineText = curr.content.slice(1); + const newLineText = next.content.slice(1); + + const { oldSegments, newSegments } = computeWordLevelDiff(oldLineText, newLineText); + curr.segments = oldSegments; + next.segments = newSegments; + } + } + return diff; } @@ -753,6 +880,118 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] { })); } +// ---- Tool Call Display Discriminated Union ---- + +const KeyValuePairsSchema = z.record(z.unknown()).transform((data) => + Object.entries(data).map(([key, value]) => ({ + key, + value: stringifyValue(value), + })) +); + +// Shell input: { command: "pwd", description?: "..." } +const ShellInputSchema = z.object({ + command: z.union([z.string(), z.array(z.string())]), +}).passthrough(); + +// Shell result (when completed): { type: "command", command: "pwd", output: "..." } +const ShellResultSchema = z.object({ + type: z.literal("command"), + output: z.string(), +}).passthrough(); + +// Shell tool call display schema +const ShellToolCallSchema = z + .object({ + input: ShellInputSchema, + result: z.unknown(), + }) + .transform((data) => { + const command = Array.isArray(data.input.command) + ? data.input.command.join(" ") + : data.input.command; + + const resultParsed = ShellResultSchema.safeParse(data.result); + const output = resultParsed.success ? resultParsed.data.output : ""; + + return { + type: "shell" as const, + command, + output, + }; + }); + +// Edit input: { file_path: string, old_string: string, new_string: string } +// Also supports old_str/new_str variants +const EditInputSchema = z.union([ + z.object({ + file_path: z.string(), + old_string: z.string(), + new_string: z.string(), + }).passthrough(), + z.object({ + file_path: z.string(), + old_str: z.string(), + new_str: z.string(), + }).passthrough(), +]); + +// Edit result: { type: "file_edit", filePath: string, oldContent?: string, newContent?: string } +const EditResultSchema = z.object({ + type: z.literal("file_edit"), + filePath: z.string(), + oldContent: z.string().optional(), + newContent: z.string().optional(), +}).passthrough(); + +// Edit tool call display schema +const EditToolCallSchema = z + .object({ + input: EditInputSchema, + result: z.unknown(), + }) + .transform((data): { type: "edit"; filePath: string; oldString: string; newString: string } => { + const filePath = data.input.file_path; + const oldString = "old_string" in data.input + ? (data.input as { old_string: string }).old_string + : (data.input as { old_str: string }).old_str; + const newString = "new_string" in data.input + ? (data.input as { new_string: string }).new_string + : (data.input as { new_str: string }).new_str; + + return { + type: "edit", + filePath, + oldString, + newString, + }; + }); + +// Generic tool call display schema (fallback) +const GenericToolCallSchema = z + .object({ + input: z.unknown(), + result: z.unknown(), + }) + .transform((data) => { + const inputPairs = KeyValuePairsSchema.safeParse(data.input); + const resultPairs = KeyValuePairsSchema.safeParse(data.result); + + return { + type: "generic" as const, + input: inputPairs.success ? inputPairs.data : [], + output: resultPairs.success ? resultPairs.data : [], + }; + }); + +const ToolCallDisplaySchema = z.union([ShellToolCallSchema, EditToolCallSchema, GenericToolCallSchema]); + +export type ToolCallDisplay = z.infer; + +export function parseToolCallDisplay(input: unknown, result: unknown): ToolCallDisplay { + return ToolCallDisplaySchema.parse({ input, result }); +} + // ---- Principal Parameter Extraction ---- // Re-export from server to avoid drift export {