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 (
+
@@ -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[] = [
{