Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 119 additions & 6 deletions src/renderer/components/WorkbenchControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 (
<div className="wb-tool-section">
<div className="wb-tool-section-head">
<h3>Input</h3>
<button
type="button"
className="wb-tool-copy"
aria-label="Copy input"
onClick={() => void copy()}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<div className="wb-tool-diff">
{visible.map((row, index) => (
<div
key={index}
className={`wb-tool-diff-line wb-tool-diff-line--${row.type === '+' ? 'add' : row.type === '-' ? 'del' : 'context'}`}
>
<span aria-hidden="true">{row.type === ' ' ? ' ' : row.type}</span>
{row.text}
</div>
))}
{visible.length === 0 && <div className="wb-tool-diff-line">No changes.</div>}
</div>
{isLong && (
<button
type="button"
className="wb-tool-toggle"
onClick={() => setShowAll((value) => !value)}
>
{showAll ? 'Show less' : 'Show more'}
</button>
)}
</div>
);
}

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 (
<div className="wb-tool-section">
<div className="wb-tool-section-head">
Expand Down Expand Up @@ -354,7 +459,15 @@ export const TranscriptRow = memo(function TranscriptRow({
{entry.running ? 'Running' : entry.ok ? 'Done' : 'Failed'}
</small>
</summary>
<ToolTextSection title="Input" text={entry.input || 'No input.'} />
{entry.oldText !== undefined || entry.newText !== undefined ? (
<ToolDiffSection
text={entry.input || 'No input.'}
oldText={entry.oldText ?? ''}
newText={entry.newText ?? ''}
/>
) : (
<ToolTextSection title="Input" text={entry.input || 'No input.'} />
)}
<ToolTextSection
title="Output"
text={
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ type Entry =
tool: string;
args: string;
input: string;
oldText?: string;
newText?: string;
output: string;
running: boolean;
ok: boolean;
Expand Down Expand Up @@ -83,6 +85,21 @@ function toolInputText(name: string, args: Record<string, unknown> | undefined):
}
}

function toolDiff(
name: string,
args: Record<string, unknown> | 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -207,6 +225,7 @@ function applyEvent(
tool: toolName,
args: toolArgsSummary(toolName, event.args as Record<string, unknown>),
input: toolInputText(toolName, event.args as Record<string, unknown>),
...toolDiff(toolName, event.args as Record<string, unknown>),
output: '',
running: true,
ok: true,
Expand Down
28 changes: 28 additions & 0 deletions src/renderer/workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
67 changes: 67 additions & 0 deletions tests/integration/ToolCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,73 @@ describe('<TranscriptRow> 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<Entry, { kind: 'tool' }> = {
...toolEntry,
key: 'tool-diff-edit',
oldText: 'const keep = 0;\nconst before = 1;',
newText: 'const keep = 0;\nconst after = 2;',
};
const expanded = new Map<string, boolean>([[editEntry.key, true]]);
const { container } = render(
<TranscriptRow entry={editEntry} expanded={expanded} />,
);
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<Entry, { kind: 'tool' }> = {
...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<string, boolean>([[writeEntry.key, true]]);
const { container } = render(
<TranscriptRow entry={writeEntry} expanded={expanded} />,
);
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<Entry, { kind: 'tool' }> = {
...toolEntry,
key: 'tool-diff-copy',
oldText: 'const before = 1;',
newText: 'const after = 2;',
};
const expanded = new Map<string, boolean>([[editEntry.key, true]]);
const { getByRole } = render(
<TranscriptRow entry={editEntry} expanded={expanded} />,
);
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<Entry, { kind: 'tool' }> = {
...toolEntry,
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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[] = [
{
Expand Down
Loading