Skip to content

Commit ec00b3e

Browse files
tclaude
andcommitted
feat(desktop): let a tool say how it should be drawn
Every tool call rendered as the same grey text blob. An Edit showed its result sentence but never the change; a Bash showed its output with no sign of which command produced it. The information was all present in the arguments — nothing was reading it. A tool now declares a render intent alongside its schema, and clients read that instead of each hardcoding tool names. Bash declares terminal, Edit/Write/ NotebookEdit declare diff, everything else stays generic. The alternative was `name === 'Edit'` in the desktop, again in the CLI, again in the VS Code extension — three copies of one fact, and a fourth to write for the next tool. Declaring it once on the tool is why the mapping is in core. Presentation is a pure function of the call's arguments. It never reads the result or the filesystem, so a session replayed from its log renders exactly as it did live. That constraint is also why Write renders as wholly added: its arguments genuinely do not say what the file held before, and inventing a before-side would be a nicer-looking lie. A declared intent is a request, not a guarantee. A call declaring diff with no usable path or text falls back to generic rather than handing the client an empty diff to draw. Verified in a dev-only preview harness: real +/- colouring on both diff cases, the command as a shell prompt above its output, running and error states, and generic left untouched. The harness is excluded from the production bundle the same way the FilePanel one is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4d56f44 commit ec00b3e

17 files changed

Lines changed: 527 additions & 65 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Tool-card bodies, one per render intent.
2+
//
3+
// Which body a call gets is decided in core (`tools/presentation.ts`), not here.
4+
// This module only knows how to draw the three shapes — the mapping from tool to
5+
// shape is the tool's own declaration, so adding a tool does not mean editing
6+
// this file, the CLI, and the extension.
7+
8+
import type { JSX } from 'react';
9+
import { computeLineDiff } from '../lib/diff.js';
10+
import type { ToolPresentation } from '@deepcode/core/dist/tools/presentation.js';
11+
12+
/** How much of a tool's text output a card shows before cutting it off. */
13+
const MAX_BODY_CHARS = 1500;
14+
15+
function clip(text: string): string {
16+
return text.length > MAX_BODY_CHARS ? `${text.slice(0, MAX_BODY_CHARS)}\n…` : text;
17+
}
18+
19+
/**
20+
* A change as added and removed lines.
21+
*
22+
* A `Write` (or a `NotebookEdit`) states only the new text, so `before` is
23+
* empty and every line reads as an addition. That is accurate: the tool's
24+
* arguments genuinely do not say what was there before.
25+
*/
26+
function DiffBody({ before, after }: { before: string; after: string }): JSX.Element {
27+
const lines = computeLineDiff(before, after);
28+
return (
29+
<>
30+
{lines.map((line, i) => (
31+
<div
32+
key={i}
33+
className={
34+
line.kind === 'add' ? 'diff-add' : line.kind === 'del' ? 'diff-del' : undefined
35+
}
36+
>
37+
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
38+
{line.text}
39+
</div>
40+
))}
41+
</>
42+
);
43+
}
44+
45+
/** A command and what it printed, styled as a shell transcript. */
46+
function TerminalBody({ command, output }: { command: string; output?: string }): JSX.Element {
47+
return (
48+
<>
49+
<div className="tc-prompt">
50+
<span className="tc-sigil">$</span> {command}
51+
</div>
52+
{output ? <div className="tc-stream">{clip(output)}</div> : null}
53+
</>
54+
);
55+
}
56+
57+
/**
58+
* Render a tool call's body according to the intent its tool declared.
59+
*
60+
* @param presentation What core derived from the call's arguments.
61+
* @param resultText The tool's output, once it has any.
62+
* @returns The body, or null when there is nothing to show yet.
63+
*/
64+
export function ToolBody({
65+
presentation,
66+
resultText,
67+
}: {
68+
presentation: ToolPresentation;
69+
resultText?: string;
70+
}): JSX.Element | null {
71+
if (presentation.kind === 'diff' && presentation.diff) {
72+
return <DiffBody before={presentation.diff.before} after={presentation.diff.after} />;
73+
}
74+
if (presentation.kind === 'terminal' && presentation.command !== undefined) {
75+
return <TerminalBody command={presentation.command} output={resultText} />;
76+
}
77+
return resultText ? <>{clip(resultText)}</> : null;
78+
}

apps/desktop/src/components/ToolCard.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,27 @@ interface ToolCardProps {
2020
status?: { kind: BadgeKind; label: string };
2121
/** Body content — pre-formatted (mono, preserves whitespace). */
2222
body?: ReactNode;
23-
/** If true, body is a diff (line-by-line; preserves whitespace strictly). */
24-
diff?: boolean;
23+
/**
24+
* How the body is laid out. `diff` and `terminal` preserve columns strictly;
25+
* `generic` wraps. Chosen from the tool's own declared render intent — see
26+
* core's `tools/presentation.ts`.
27+
*/
28+
layout?: 'generic' | 'diff' | 'terminal';
2529
/**
2630
* If set, the target becomes a clickable "open preview" affordance — used for
2731
* file tools (Read/Write/Edit) to load the file into the right-side panel.
2832
*/
2933
onOpen?: () => void;
3034
}
3135

32-
export function ToolCard({ name, target, status, body, diff, onOpen }: ToolCardProps): JSX.Element {
36+
export function ToolCard({
37+
name,
38+
target,
39+
status,
40+
body,
41+
layout = 'generic',
42+
onOpen,
43+
}: ToolCardProps): JSX.Element {
3344
return (
3445
<div className={'tool-card' + (onOpen ? ' openable' : '')}>
3546
<div className="tc-head">
@@ -49,7 +60,7 @@ export function ToolCard({ name, target, status, body, diff, onOpen }: ToolCardP
4960
))}
5061
{status && <Badge kind={status.kind}>{status.label}</Badge>}
5162
</div>
52-
{body !== undefined && <div className={diff ? 'tc-body diff' : 'tc-body'}>{body}</div>}
63+
{body !== undefined && <div className={`tc-body ${layout}`}>{body}</div>}
5364
</div>
5465
);
5566
}

apps/desktop/src/index.css

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,7 +975,9 @@ select {
975975
max-height: 280px;
976976
overflow-y: auto;
977977
}
978-
.tool-card .tc-body.diff {
978+
/* Columns carry meaning in a diff and in terminal output, so neither wraps. */
979+
.tool-card .tc-body.diff,
980+
.tool-card .tc-body.terminal {
979981
white-space: pre;
980982
}
981983
.diff-add {
@@ -984,6 +986,20 @@ select {
984986
.diff-del {
985987
color: var(--error);
986988
}
989+
/* The command line, held above its output the way a shell transcript reads. */
990+
.tool-card .tc-body.terminal .tc-prompt {
991+
color: var(--text-1);
992+
padding-bottom: 6px;
993+
}
994+
.tool-card .tc-body.terminal .tc-sigil {
995+
color: var(--brand);
996+
user-select: none;
997+
}
998+
.tool-card .tc-body.terminal .tc-stream {
999+
color: var(--text-2);
1000+
border-top: 1px solid var(--border);
1001+
padding-top: 6px;
1002+
}
9871003

9881004
/* Inline approval — sits right under a tool card */
9891005
.approval-row {

apps/desktop/src/lib/repl-stream.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
// streaming deltas must NOT orphan the open assistant turn or spawn a second
1010
// streaming bubble — that was the "two blinking cursors" bug.
1111

12+
// The card header's label comes from core, so the CLI and the extension read
13+
// the same answer rather than each keeping their own key list.
14+
import { pickTarget } from '@deepcode/core/dist/tools/presentation.js';
15+
16+
export { pickTarget };
17+
1218
export interface ToolInvocation {
1319
toolId: string;
1420
name: string;
@@ -234,15 +240,6 @@ export function appendStoredLine(input: Msg[], m: StoredLine): Msg[] {
234240
return msgs;
235241
}
236242

237-
/** Pick a human-readable target from a tool's input for the card header. */
238-
export function pickTarget(input: Record<string, unknown>): string | undefined {
239-
for (const k of ['file_path', 'command', 'pattern', 'path', 'url', 'query']) {
240-
const v = input[k];
241-
if (typeof v === 'string') return v;
242-
}
243-
return undefined;
244-
}
245-
246243
// ── Resuming from a protocol thread ──────────────────────────────────────
247244

248245
/** The subset of a protocol CompletedItem this projection needs. */
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Tool cards preview (dev only)</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/preview-toolcards.tsx"></script>
11+
</body>
12+
</html>
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// DEV-ONLY preview harness for tool cards. Not part of the prod bundle — vite's
2+
// build input is pinned to index.html, so this page exists only under
3+
// `vite dev` (served at /preview-toolcards.html) for visual iteration.
4+
//
5+
// Renders one card per render intent so the three layouts can be compared side
6+
// by side without running an agent.
7+
8+
import type { JSX } from 'react';
9+
import { createRoot } from 'react-dom/client';
10+
import { presentToolCall } from '@deepcode/core/dist/tools/presentation.js';
11+
import { ToolBody } from './components/ToolBody.js';
12+
import { ToolCard } from './components/ToolCard.js';
13+
import './index.css';
14+
15+
const CALLS: Array<{
16+
name: string;
17+
input: Record<string, unknown>;
18+
result?: string;
19+
status: 'ok' | 'err' | 'running';
20+
}> = [
21+
{
22+
name: 'Edit',
23+
input: {
24+
file_path: 'packages/core/src/tools/bash.ts',
25+
old_string:
26+
'const MAX_OUTPUT_BYTES = 30_000;\n\nfunction capStream(s: string, label: string): string {\n return s.slice(0, MAX_OUTPUT_BYTES);\n}',
27+
new_string:
28+
'const CAPTURE_HEAD_CHARS = 1_000_000;\nconst CAPTURE_TAIL_CHARS = 3_000_000;\n\nfunction newCapture(): BoundedCapture {\n return new BoundedCapture(CAPTURE_HEAD_CHARS, CAPTURE_TAIL_CHARS);\n}',
29+
},
30+
result: 'Edited packages/core/src/tools/bash.ts',
31+
status: 'ok',
32+
},
33+
{
34+
name: 'Write',
35+
input: {
36+
file_path: 'packages/core/src/spill/types.ts',
37+
content:
38+
'export interface SpillRef {\n locator: string;\n bytes: number;\n retrievalHint: string;\n}',
39+
},
40+
result: 'Wrote 5 lines',
41+
status: 'ok',
42+
},
43+
{
44+
name: 'Bash',
45+
input: { command: 'pnpm --filter @deepcode/core test -- src/spill' },
46+
result:
47+
'<stdout>\n RUN v4.1.10\n\n Test Files 2 passed (2)\n Tests 19 passed (19)\n</stdout>\nexit: 0',
48+
status: 'ok',
49+
},
50+
{
51+
name: 'Bash',
52+
input: { command: 'cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml' },
53+
result: '<stderr>\nerror: could not compile `deepcode-desktop`\n</stderr>\nexit: 101',
54+
status: 'err',
55+
},
56+
{
57+
name: 'Grep',
58+
input: { pattern: 'applySpillPolicy', path: 'packages/core/src' },
59+
result:
60+
'packages/core/src/agent.ts:16\npackages/core/src/spill/policy.ts:71\npackages/core/src/index.ts:213',
61+
status: 'ok',
62+
},
63+
{
64+
name: 'Bash',
65+
input: { command: 'pnpm build' },
66+
status: 'running',
67+
},
68+
];
69+
70+
function Preview(): JSX.Element {
71+
return (
72+
<div style={{ padding: 24, maxWidth: 860, margin: '0 auto' }}>
73+
<h2 style={{ font: '600 15px/1.4 system-ui', color: 'var(--text-1)', marginBottom: 16 }}>
74+
Tool cards by render intent
75+
</h2>
76+
{CALLS.map((call, i) => {
77+
const presentation = presentToolCall(call.name, call.input);
78+
return (
79+
<div key={i} style={{ marginBottom: 14 }}>
80+
<div style={{ font: '11px/1.6 system-ui', color: 'var(--text-3)', marginBottom: 4 }}>
81+
{call.name}{presentation.kind}
82+
</div>
83+
<ToolCard
84+
name={call.name}
85+
target={presentation.kind === 'terminal' ? undefined : presentation.target}
86+
layout={presentation.kind}
87+
status={{
88+
kind: call.status === 'running' ? 'info' : call.status === 'ok' ? 'ok' : 'err',
89+
label:
90+
call.status === 'running'
91+
? '… running'
92+
: call.status === 'ok'
93+
? '✓ done'
94+
: '✕ error',
95+
}}
96+
body={<ToolBody presentation={presentation} resultText={call.result} />}
97+
/>
98+
</div>
99+
);
100+
})}
101+
</div>
102+
);
103+
}
104+
105+
createRoot(document.getElementById('root') as HTMLElement).render(<Preview />);

0 commit comments

Comments
 (0)