diff --git a/src/renderer/components/WorkbenchControls.tsx b/src/renderer/components/WorkbenchControls.tsx index 1fb297a..6d8695d 100644 --- a/src/renderer/components/WorkbenchControls.tsx +++ b/src/renderer/components/WorkbenchControls.tsx @@ -278,12 +278,46 @@ export function Composer({ export const TOOL_TEXT_PREVIEW_LIMIT = 2000; -function ToolTextSection({ title, text }: { title: string; text: string }) { - const [showAll, setShowAll] = useState(false); +export type DiffLine = { type: '-' | '+' | ' '; text: string }; + +export function diffLines(oldText: string, newText: string): DiffLine[] { + const a = oldText === '' ? [] : oldText.split('\n'); + const b = newText === '' ? [] : newText.split('\n'); + if (a.length * b.length > 250_000) + return [ + ...a.map((text): DiffLine => ({ type: '-', text })), + ...b.map((text): DiffLine => ({ type: '+', text })), + ]; + const dp: Uint32Array[] = Array.from({ length: a.length + 1 }, () => + new Uint32Array(b.length + 1), + ); + for (let i = a.length - 1; i >= 0; i--) + for (let j = b.length - 1; j >= 0; j--) + dp[i][j] = + a[i] === b[j] + ? dp[i + 1][j + 1] + 1 + : Math.max(dp[i + 1][j], dp[i][j + 1]); + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + out.push({ type: ' ', text: a[i] }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + out.push({ type: '-', text: a[i++] }); + } else { + out.push({ type: '+', text: b[j++] }); + } + } + while (i < a.length) out.push({ type: '-', text: a[i++] }); + while (j < b.length) out.push({ type: '+', text: b[j++] }); + return out; +} + +function useCopy(text: string) { const [copied, setCopied] = useState(false); - const isLong = text.length > TOOL_TEXT_PREVIEW_LIMIT; - const visible = - isLong && !showAll ? text.slice(0, TOOL_TEXT_PREVIEW_LIMIT) : text; const copy = async () => { try { await navigator.clipboard.writeText(text); @@ -293,6 +327,77 @@ function ToolTextSection({ title, text }: { title: string; text: string }) { setCopied(false); } }; + return { copied, copy }; +} + +function ToolDiffSection({ + text, + oldText, + newText, +}: { + text: string; + oldText: string; + newText: string; +}) { + const { copied, copy } = useCopy(text); + const [showAll, setShowAll] = useState(false); + const rows = diffLines(oldText, newText); + const isLong = text.length > TOOL_TEXT_PREVIEW_LIMIT; + let visible = rows; + if (isLong && !showAll) { + visible = []; + let used = 0; + for (const row of rows) { + used += row.text.length + 1; + if (used > TOOL_TEXT_PREVIEW_LIMIT) break; + visible.push(row); + } + if (visible.length === 0 && rows.length > 0) visible = [rows[0]]; + } + return ( +
+
+

Input

+ +
+
+ {visible.map((row, index) => ( +
+ + {row.text} +
+ ))} + {visible.length === 0 &&
No changes.
} +
+ {isLong && ( + + )} +
+ ); +} + +function ToolTextSection({ title, text }: { title: string; text: string }) { + const [showAll, setShowAll] = useState(false); + const { copied, copy } = useCopy(text); + const isLong = text.length > TOOL_TEXT_PREVIEW_LIMIT; + const visible = + isLong && !showAll ? text.slice(0, TOOL_TEXT_PREVIEW_LIMIT) : text; return (
@@ -354,7 +459,15 @@ export const TranscriptRow = memo(function TranscriptRow({ {entry.running ? 'Running' : entry.ok ? 'Done' : 'Failed'} - + {entry.oldText !== undefined || entry.newText !== undefined ? ( + + ) : ( + + )} | undefined): } } +function toolDiff( + name: string, + args: Record | undefined, +): { oldText: string; newText: string } | undefined { + if (!args) return undefined; + if (name === 'edit' && (typeof args.oldText === 'string' || typeof args.newText === 'string')) + return { + oldText: typeof args.oldText === 'string' ? args.oldText : '', + newText: typeof args.newText === 'string' ? args.newText : '', + }; + if (name === 'write' && typeof args.content === 'string') + return { oldText: '', newText: args.content }; + return undefined; +} + function resultText(content: unknown): string { if (!Array.isArray(content)) return ''; return content @@ -121,6 +138,7 @@ function entriesFromMessages(messages: unknown[]): Entry[] { tool: p.name, args: toolArgsSummary(p.name, p.arguments), input: toolInputText(p.name, p.arguments), + ...toolDiff(p.name, p.arguments), output: '', running: false, ok: true, @@ -207,6 +225,7 @@ function applyEvent( tool: toolName, args: toolArgsSummary(toolName, event.args as Record), input: toolInputText(toolName, event.args as Record), + ...toolDiff(toolName, event.args as Record), output: '', running: true, ok: true, diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index e5d9eb7..0672533 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -746,6 +746,34 @@ html[data-reduced-motion='true'] .wb-shell *::after { white-space: pre-wrap; overflow-wrap: anywhere; } +.wb-tool-diff { + padding: 8px 0 12px; + max-height: 320px; + overflow: auto; + font-family: var(--mono, monospace); + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.wb-tool-diff-line { + padding: 0 12px; + display: flex; + gap: 8px; +} +.wb-tool-diff-line > span { + flex: 0 0 1ch; + user-select: none; +} +.wb-tool-diff-line--add { + color: var(--success); + background: color-mix(in srgb, var(--success) 12%, transparent); +} +.wb-tool-diff-line--del { + color: var(--error); + background: color-mix(in srgb, var(--error) 12%, transparent); +} +.wb-tool-diff-line--context { + color: var(--copy); +} .wb-tool--error { border-color: var(--error); } diff --git a/tests/integration/ToolCard.test.tsx b/tests/integration/ToolCard.test.tsx index e83a617..708ee6e 100644 --- a/tests/integration/ToolCard.test.tsx +++ b/tests/integration/ToolCard.test.tsx @@ -70,6 +70,73 @@ describe(' tool card', () => { expect(container.textContent).not.toContain('end-marker'); }); + it('renders edit input as a diff with added, removed, and context lines', () => { + const editEntry: Extract = { + ...toolEntry, + key: 'tool-diff-edit', + oldText: 'const keep = 0;\nconst before = 1;', + newText: 'const keep = 0;\nconst after = 2;', + }; + const expanded = new Map([[editEntry.key, true]]); + const { container } = render( + , + ); + const del = container.querySelector('.wb-tool-diff-line--del'); + const add = container.querySelector('.wb-tool-diff-line--add'); + const context = container.querySelector('.wb-tool-diff-line--context'); + expect(del?.textContent).toContain('-'); + expect(del?.textContent).toContain('const before = 1;'); + expect(add?.textContent).toContain('+'); + expect(add?.textContent).toContain('const after = 2;'); + expect(context?.textContent).toContain('const keep = 0;'); + }); + + it('renders write input as added lines', () => { + const writeEntry: Extract = { + ...toolEntry, + key: 'tool-diff-write', + tool: 'write', + args: '/repo/new.ts', + input: '/repo/new.ts\n\nexport const a = 1;\nexport const b = 2;', + oldText: '', + newText: 'export const a = 1;\nexport const b = 2;', + output: 'Wrote /repo/new.ts', + }; + const expanded = new Map([[writeEntry.key, true]]); + const { container } = render( + , + ); + const added = [...container.querySelectorAll('.wb-tool-diff-line--add')]; + expect(added).toHaveLength(2); + expect(added[0]?.textContent).toContain('export const a = 1;'); + expect(container.querySelector('.wb-tool-diff-line--del')).toBeNull(); + }); + + it('copies the exact plain input text from a diff card', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + const editEntry: Extract = { + ...toolEntry, + key: 'tool-diff-copy', + oldText: 'const before = 1;', + newText: 'const after = 2;', + }; + const expanded = new Map([[editEntry.key, true]]); + const { getByRole } = render( + , + ); + fireEvent.click(getByRole('button', { name: 'Copy input' })); + expect(writeText).toHaveBeenCalledWith(toolEntry.input); + await vi.waitFor(() => + expect(getByRole('button', { name: 'Copy input' }).textContent).toBe( + 'Copied', + ), + ); + }); + it('shows running state and waiting output while a tool runs', () => { const running: Extract = { ...toolEntry, diff --git a/tests/unit/timeline.test.ts b/tests/unit/timeline.test.ts index d42944a..0a93e82 100644 --- a/tests/unit/timeline.test.ts +++ b/tests/unit/timeline.test.ts @@ -143,6 +143,56 @@ describe('entriesFromMessages', () => { } }); + it('carries old and new text for an edit tool call', () => { + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolCall', + id: 'call_edit_diff', + name: 'edit', + arguments: { + path: '/repo/src/app.ts', + oldText: 'const keep = 0;\nconst before = 1;', + newText: 'const keep = 0;\nconst after = 2;', + }, + }, + ], + }, + ]; + const [entry] = entriesFromMessages(messages); + if (entry?.kind === 'tool') { + expect(entry.oldText).toBe('const keep = 0;\nconst before = 1;'); + expect(entry.newText).toBe('const keep = 0;\nconst after = 2;'); + } else { + expect.fail('expected tool entry'); + } + }); + + it('carries write content as new text against an empty old side', () => { + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolCall', + id: 'call_write_diff', + name: 'write', + arguments: { path: '/repo/new.ts', content: 'export const a = 1;' }, + }, + ], + }, + ]; + const [entry] = entriesFromMessages(messages); + if (entry?.kind === 'tool') { + expect(entry.oldText).toBe(''); + expect(entry.newText).toBe('export const a = 1;'); + } else { + expect.fail('expected tool entry'); + } + }); + it('does not truncate long input for other tools', () => { const long = 'x'.repeat(300); const messages = [ @@ -305,6 +355,26 @@ describe('applyEvent', () => { } }); + it('tool_execution_start carries edit old and new text', () => { + const { entries } = applyEvent(empty, { + type: 'tool_execution_start', + toolCallId: 'call_live_edit', + toolName: 'edit', + args: { + path: '/repo/src/app.ts', + oldText: 'const before = 1;', + newText: 'const after = 2;', + }, + }); + const entry = entries[0]; + if (entry?.kind === 'tool') { + expect(entry.oldText).toBe('const before = 1;'); + expect(entry.newText).toBe('const after = 2;'); + } else { + expect.fail('expected tool entry'); + } + }); + it('tool_execution_update patches partial output', () => { const seeded: Entry[] = [ {