diff --git a/src/renderer/components/WorkbenchControls.tsx b/src/renderer/components/WorkbenchControls.tsx index 282ce4f..5cf1ddf 100644 --- a/src/renderer/components/WorkbenchControls.tsx +++ b/src/renderer/components/WorkbenchControls.tsx @@ -310,10 +310,17 @@ export const TranscriptRow = memo(function TranscriptRow({ {entry.running ? 'Running' : entry.ok ? 'Done' : 'Failed'} -
-          {entry.output ||
-            (entry.running ? 'Waiting for output…' : 'No output.')}
-        
+
+

Input

+
{entry.input || 'No input.'}
+
+
+

Output

+
+            {entry.output ||
+              (entry.running ? 'Waiting for output…' : 'No output.')}
+          
+
); return ( diff --git a/src/renderer/timeline.ts b/src/renderer/timeline.ts index 5522ce6..34064de 100644 --- a/src/renderer/timeline.ts +++ b/src/renderer/timeline.ts @@ -9,6 +9,7 @@ type Entry = toolId: string; tool: string; args: string; + input: string; output: string; running: boolean; ok: boolean; @@ -55,6 +56,33 @@ function toolArgsSummary(name: string, args: Record | undefined return JSON.stringify(args).slice(0, 120); } +function toolInputText(name: string, args: Record | undefined): string { + if (!args) return ''; + if (name === 'bash') return String(args.command ?? ''); + if (name === 'read' || name === 'write' || name === 'edit') { + const p = String(args.path ?? args.file ?? ''); + const parts = [p]; + if (name === 'write' && typeof args.content === 'string') + parts.push('', args.content); + if (name === 'edit') { + if (typeof args.oldText === 'string') parts.push('', 'Old:', args.oldText); + if (typeof args.newText === 'string') parts.push('', 'New:', args.newText); + } + if (name === 'read') { + const range: string[] = []; + if (typeof args.offset === 'number') range.push(`offset ${args.offset}`); + if (typeof args.limit === 'number') range.push(`limit ${args.limit}`); + if (range.length) parts.push(range.join(', ')); + } + return parts.join('\n'); + } + try { + return JSON.stringify(args, null, 2); + } catch { + return String(args); + } +} + function resultText(content: unknown): string { if (!Array.isArray(content)) return ''; return content @@ -92,6 +120,7 @@ function entriesFromMessages(messages: unknown[]): Entry[] { toolId: p.id, tool: p.name, args: toolArgsSummary(p.name, p.arguments), + input: toolInputText(p.name, p.arguments), output: '', running: false, ok: true, @@ -177,6 +206,7 @@ function applyEvent( toolId: toolCallId, tool: toolName, args: toolArgsSummary(toolName, event.args as Record), + input: toolInputText(toolName, event.args as Record), output: '', running: true, ok: true, diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index e8c2e36..8123754 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -706,11 +706,22 @@ html[data-reduced-motion='true'] .wb-shell *::after { font-size: 12px; color: var(--copy); } +.wb-tool-section h3 { + margin: 0; + padding: 8px 12px 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} +.wb-tool-section + .wb-tool-section { + border-top: 1px solid var(--line-soft); +} .wb-tool pre { - padding: 12px; + padding: 8px 12px 12px; max-height: 320px; overflow: auto; - border-top: 1px solid var(--line-soft); margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; diff --git a/tests/integration/ToolCard.test.tsx b/tests/integration/ToolCard.test.tsx new file mode 100644 index 0000000..534ba36 --- /dev/null +++ b/tests/integration/ToolCard.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { TranscriptRow } from '../../src/renderer/components/WorkbenchControls'; +import type { Entry } from '../../src/renderer/timeline'; + +const toolEntry: Extract = { + key: 'tool-1', + kind: 'tool', + toolId: 'call_1', + tool: 'edit', + args: '/repo/src/app.ts', + input: '/repo/src/app.ts\n\nOld:\nconst before = 1;\n\nNew:\nconst after = 2;', + output: 'Edited /repo/src/app.ts', + running: false, + ok: true, +}; + +describe(' tool card', () => { + beforeEach(() => cleanup()); + + it('shows input and output as separate labelled sections when expanded', () => { + const expanded = new Map([[toolEntry.key, true]]); + const { container } = render( + , + ); + const headings = [...container.querySelectorAll('.wb-tool-section h3')].map( + (h) => h.textContent, + ); + expect(headings).toEqual(['Input', 'Output']); + expect(container.textContent).toContain('const before = 1;'); + expect(container.textContent).toContain('const after = 2;'); + expect(container.textContent).toContain('Edited /repo/src/app.ts'); + expect(container.textContent).toContain('Done'); + }); + + it('shows running state and waiting output while a tool runs', () => { + const running: Extract = { + ...toolEntry, + key: 'tool-2', + tool: 'bash', + args: 'sleep 1', + input: 'sleep 1', + output: '', + running: true, + }; + const expanded = new Map([[running.key, true]]); + const { container } = render( + , + ); + expect(container.textContent).toContain('Running'); + expect(container.textContent).toContain('Waiting for output'); + }); +}); diff --git a/tests/unit/timeline.test.ts b/tests/unit/timeline.test.ts index 1fe00ab..d42944a 100644 --- a/tests/unit/timeline.test.ts +++ b/tests/unit/timeline.test.ts @@ -114,6 +114,58 @@ describe('entriesFromMessages', () => { } }); + it('keeps full edit input with old and new text', () => { + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolCall', + id: 'call_edit', + name: 'edit', + arguments: { + path: '/repo/src/app.ts', + oldText: 'const before = 1;', + newText: 'const after = 2;', + }, + }, + ], + }, + ]; + const [entry] = entriesFromMessages(messages); + if (entry?.kind === 'tool') { + expect(entry.args).toBe('/repo/src/app.ts'); + expect(entry.input).toContain('/repo/src/app.ts'); + expect(entry.input).toContain('const before = 1;'); + expect(entry.input).toContain('const after = 2;'); + } else { + expect.fail('expected tool entry'); + } + }); + + it('does not truncate long input for other tools', () => { + const long = 'x'.repeat(300); + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolCall', + id: 'call_long', + name: 'custom_lookup', + arguments: { query: long }, + }, + ], + }, + ]; + const [entry] = entriesFromMessages(messages); + if (entry?.kind === 'tool') { + expect(entry.input).toContain(long); + } else { + expect.fail('expected tool entry'); + } + }); + it('interleaves user, assistant, tool, toolResult entries', () => { const messages = [ { role: 'user', content: [{ type: 'text', text: 'do it' }] }, @@ -246,6 +298,7 @@ describe('applyEvent', () => { if (entry?.kind === 'tool') { expect(entry.tool).toBe('bash'); expect(entry.args).toBe('ls'); + expect(entry.input).toBe('ls'); expect(entry.running).toBe(true); } else { expect.fail('expected tool'); @@ -260,6 +313,7 @@ describe('applyEvent', () => { toolId: 'c1', tool: 'bash', args: 'ls', + input: 'ls', output: '', running: true, ok: true, @@ -287,6 +341,7 @@ describe('applyEvent', () => { toolId: 'c1', tool: 'bash', args: 'ls', + input: 'ls', output: '', running: true, ok: true,