From 6867ed467ad16e8d6ed77db524b034c700dc9638 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 14 Aug 2026 12:46:50 +0800 Subject: [PATCH] feat(desktop): let a tool say how it should be drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/desktop/src/components/ToolBody.tsx | 78 ++++++++++++ apps/desktop/src/components/ToolCard.tsx | 19 ++- apps/desktop/src/index.css | 18 ++- apps/desktop/src/lib/repl-stream.ts | 15 +-- apps/desktop/src/preview-toolcards.html | 12 ++ apps/desktop/src/preview-toolcards.tsx | 105 ++++++++++++++++ apps/desktop/src/screens/Repl.tsx | 110 +++++++++-------- packages/core/package.json | 6 +- packages/core/src/index.ts | 6 + packages/core/src/tools/bash.ts | 1 + packages/core/src/tools/edit.ts | 1 + packages/core/src/tools/index.ts | 8 ++ packages/core/src/tools/notebook.ts | 1 + packages/core/src/tools/presentation.test.ts | 84 +++++++++++++ packages/core/src/tools/presentation.ts | 119 +++++++++++++++++++ packages/core/src/tools/write.ts | 1 + packages/core/src/types.ts | 8 ++ 17 files changed, 527 insertions(+), 65 deletions(-) create mode 100644 apps/desktop/src/components/ToolBody.tsx create mode 100644 apps/desktop/src/preview-toolcards.html create mode 100644 apps/desktop/src/preview-toolcards.tsx create mode 100644 packages/core/src/tools/presentation.test.ts create mode 100644 packages/core/src/tools/presentation.ts diff --git a/apps/desktop/src/components/ToolBody.tsx b/apps/desktop/src/components/ToolBody.tsx new file mode 100644 index 0000000..798853a --- /dev/null +++ b/apps/desktop/src/components/ToolBody.tsx @@ -0,0 +1,78 @@ +// Tool-card bodies, one per render intent. +// +// Which body a call gets is decided in core (`tools/presentation.ts`), not here. +// This module only knows how to draw the three shapes — the mapping from tool to +// shape is the tool's own declaration, so adding a tool does not mean editing +// this file, the CLI, and the extension. + +import type { JSX } from 'react'; +import { computeLineDiff } from '../lib/diff.js'; +import type { ToolPresentation } from '@deepcode/core/dist/tools/presentation.js'; + +/** How much of a tool's text output a card shows before cutting it off. */ +const MAX_BODY_CHARS = 1500; + +function clip(text: string): string { + return text.length > MAX_BODY_CHARS ? `${text.slice(0, MAX_BODY_CHARS)}\n…` : text; +} + +/** + * A change as added and removed lines. + * + * A `Write` (or a `NotebookEdit`) states only the new text, so `before` is + * empty and every line reads as an addition. That is accurate: the tool's + * arguments genuinely do not say what was there before. + */ +function DiffBody({ before, after }: { before: string; after: string }): JSX.Element { + const lines = computeLineDiff(before, after); + return ( + <> + {lines.map((line, i) => ( +
+ {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} + {line.text} +
+ ))} + + ); +} + +/** A command and what it printed, styled as a shell transcript. */ +function TerminalBody({ command, output }: { command: string; output?: string }): JSX.Element { + return ( + <> +
+ $ {command} +
+ {output ?
{clip(output)}
: null} + + ); +} + +/** + * Render a tool call's body according to the intent its tool declared. + * + * @param presentation What core derived from the call's arguments. + * @param resultText The tool's output, once it has any. + * @returns The body, or null when there is nothing to show yet. + */ +export function ToolBody({ + presentation, + resultText, +}: { + presentation: ToolPresentation; + resultText?: string; +}): JSX.Element | null { + if (presentation.kind === 'diff' && presentation.diff) { + return ; + } + if (presentation.kind === 'terminal' && presentation.command !== undefined) { + return ; + } + return resultText ? <>{clip(resultText)} : null; +} diff --git a/apps/desktop/src/components/ToolCard.tsx b/apps/desktop/src/components/ToolCard.tsx index 3aa1f2f..b923727 100644 --- a/apps/desktop/src/components/ToolCard.tsx +++ b/apps/desktop/src/components/ToolCard.tsx @@ -20,8 +20,12 @@ interface ToolCardProps { status?: { kind: BadgeKind; label: string }; /** Body content — pre-formatted (mono, preserves whitespace). */ body?: ReactNode; - /** If true, body is a diff (line-by-line; preserves whitespace strictly). */ - diff?: boolean; + /** + * How the body is laid out. `diff` and `terminal` preserve columns strictly; + * `generic` wraps. Chosen from the tool's own declared render intent — see + * core's `tools/presentation.ts`. + */ + layout?: 'generic' | 'diff' | 'terminal'; /** * If set, the target becomes a clickable "open preview" affordance — used for * file tools (Read/Write/Edit) to load the file into the right-side panel. @@ -29,7 +33,14 @@ interface ToolCardProps { onOpen?: () => void; } -export function ToolCard({ name, target, status, body, diff, onOpen }: ToolCardProps): JSX.Element { +export function ToolCard({ + name, + target, + status, + body, + layout = 'generic', + onOpen, +}: ToolCardProps): JSX.Element { return (
@@ -49,7 +60,7 @@ export function ToolCard({ name, target, status, body, diff, onOpen }: ToolCardP ))} {status && {status.label}}
- {body !== undefined &&
{body}
} + {body !== undefined &&
{body}
}
); } diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 6f97def..793a5fe 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -975,7 +975,9 @@ select { max-height: 280px; overflow-y: auto; } -.tool-card .tc-body.diff { +/* Columns carry meaning in a diff and in terminal output, so neither wraps. */ +.tool-card .tc-body.diff, +.tool-card .tc-body.terminal { white-space: pre; } .diff-add { @@ -984,6 +986,20 @@ select { .diff-del { color: var(--error); } +/* The command line, held above its output the way a shell transcript reads. */ +.tool-card .tc-body.terminal .tc-prompt { + color: var(--text-1); + padding-bottom: 6px; +} +.tool-card .tc-body.terminal .tc-sigil { + color: var(--brand); + user-select: none; +} +.tool-card .tc-body.terminal .tc-stream { + color: var(--text-2); + border-top: 1px solid var(--border); + padding-top: 6px; +} /* Inline approval — sits right under a tool card */ .approval-row { diff --git a/apps/desktop/src/lib/repl-stream.ts b/apps/desktop/src/lib/repl-stream.ts index b2021e1..630cab4 100644 --- a/apps/desktop/src/lib/repl-stream.ts +++ b/apps/desktop/src/lib/repl-stream.ts @@ -9,6 +9,12 @@ // streaming deltas must NOT orphan the open assistant turn or spawn a second // streaming bubble — that was the "two blinking cursors" bug. +// The card header's label comes from core, so the CLI and the extension read +// the same answer rather than each keeping their own key list. +import { pickTarget } from '@deepcode/core/dist/tools/presentation.js'; + +export { pickTarget }; + export interface ToolInvocation { toolId: string; name: string; @@ -234,15 +240,6 @@ export function appendStoredLine(input: Msg[], m: StoredLine): Msg[] { return msgs; } -/** Pick a human-readable target from a tool's input for the card header. */ -export function pickTarget(input: Record): string | undefined { - for (const k of ['file_path', 'command', 'pattern', 'path', 'url', 'query']) { - const v = input[k]; - if (typeof v === 'string') return v; - } - return undefined; -} - // ── Resuming from a protocol thread ────────────────────────────────────── /** The subset of a protocol CompletedItem this projection needs. */ diff --git a/apps/desktop/src/preview-toolcards.html b/apps/desktop/src/preview-toolcards.html new file mode 100644 index 0000000..e74ae4b --- /dev/null +++ b/apps/desktop/src/preview-toolcards.html @@ -0,0 +1,12 @@ + + + + + + Tool cards preview (dev only) + + +
+ + + diff --git a/apps/desktop/src/preview-toolcards.tsx b/apps/desktop/src/preview-toolcards.tsx new file mode 100644 index 0000000..0231752 --- /dev/null +++ b/apps/desktop/src/preview-toolcards.tsx @@ -0,0 +1,105 @@ +// DEV-ONLY preview harness for tool cards. Not part of the prod bundle — vite's +// build input is pinned to index.html, so this page exists only under +// `vite dev` (served at /preview-toolcards.html) for visual iteration. +// +// Renders one card per render intent so the three layouts can be compared side +// by side without running an agent. + +import type { JSX } from 'react'; +import { createRoot } from 'react-dom/client'; +import { presentToolCall } from '@deepcode/core/dist/tools/presentation.js'; +import { ToolBody } from './components/ToolBody.js'; +import { ToolCard } from './components/ToolCard.js'; +import './index.css'; + +const CALLS: Array<{ + name: string; + input: Record; + result?: string; + status: 'ok' | 'err' | 'running'; +}> = [ + { + name: 'Edit', + input: { + file_path: 'packages/core/src/tools/bash.ts', + old_string: + 'const MAX_OUTPUT_BYTES = 30_000;\n\nfunction capStream(s: string, label: string): string {\n return s.slice(0, MAX_OUTPUT_BYTES);\n}', + new_string: + '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}', + }, + result: 'Edited packages/core/src/tools/bash.ts', + status: 'ok', + }, + { + name: 'Write', + input: { + file_path: 'packages/core/src/spill/types.ts', + content: + 'export interface SpillRef {\n locator: string;\n bytes: number;\n retrievalHint: string;\n}', + }, + result: 'Wrote 5 lines', + status: 'ok', + }, + { + name: 'Bash', + input: { command: 'pnpm --filter @deepcode/core test -- src/spill' }, + result: + '\n RUN v4.1.10\n\n Test Files 2 passed (2)\n Tests 19 passed (19)\n\nexit: 0', + status: 'ok', + }, + { + name: 'Bash', + input: { command: 'cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml' }, + result: '\nerror: could not compile `deepcode-desktop`\n\nexit: 101', + status: 'err', + }, + { + name: 'Grep', + input: { pattern: 'applySpillPolicy', path: 'packages/core/src' }, + result: + 'packages/core/src/agent.ts:16\npackages/core/src/spill/policy.ts:71\npackages/core/src/index.ts:213', + status: 'ok', + }, + { + name: 'Bash', + input: { command: 'pnpm build' }, + status: 'running', + }, +]; + +function Preview(): JSX.Element { + return ( +
+

+ Tool cards by render intent +

+ {CALLS.map((call, i) => { + const presentation = presentToolCall(call.name, call.input); + return ( +
+
+ {call.name} → {presentation.kind} +
+ } + /> +
+ ); + })} +
+ ); +} + +createRoot(document.getElementById('root') as HTMLElement).render(); diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 1b7aa41..73fc6cc 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -42,6 +42,8 @@ import { type SlashCommand, } from '../lib/slash-commands.js'; import { ToolCard } from '../components/ToolCard.js'; +import { ToolBody } from '../components/ToolBody.js'; +import { presentToolCall } from '@deepcode/core/dist/tools/presentation.js'; import { projectName } from '../lib/project.js'; import { useVoice } from '../lib/use-voice.js'; import { insertTranscript } from '../lib/voice.js'; @@ -1130,52 +1132,64 @@ function renderMessage( {m.turn.reasoning ? : null} {m.turn.text} {m.turn.streaming && isActive && } - {m.turn.tools.map((t) => ( -
- onOpenFile(String(t.input.file_path)) - : undefined - } - /> - {/* Inline approval — appears right under the relevant tool card */} - {pendingApproval && pendingApproval.toolName === t.name && t.status === 'running' && ( -
- - - -
- )} -
- ))} + {m.turn.tools.map((t) => { + const presentation = presentToolCall(t.name, t.input ?? {}); + return ( +
+ } + onOpen={ + onOpenFile && typeof t.input?.file_path === 'string' + ? () => onOpenFile(String(t.input.file_path)) + : undefined + } + /> + {/* Inline approval — appears right under the relevant tool card */} + {pendingApproval && + pendingApproval.toolName === t.name && + t.status === 'running' && ( +
+ + + +
+ )} +
+ ); + })} @@ -1193,10 +1207,6 @@ function abbreviatePath(p: string): string { return p; } -function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n) + '…\n[truncated]' : s; -} - /** * The model's reasoning, as a collapsed side channel. * diff --git a/packages/core/package.json b/packages/core/package.json index 7d2edc6..72b50cb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@deepcode/core", "version": "0.0.0", "private": true, - "description": "DeepCode kernel — agent loop, providers, tools, MCP, sandbox, harness (UI-agnostic)", + "description": "DeepCode kernel \u2014 agent loop, providers, tools, MCP, sandbox, harness (UI-agnostic)", "license": "MIT", "type": "module", "main": "./dist/index.js", @@ -40,6 +40,10 @@ "types": "./dist/util/diff.d.ts", "import": "./dist/util/diff.js" }, + "./dist/tools/presentation.js": { + "types": "./dist/tools/presentation.d.ts", + "import": "./dist/tools/presentation.js" + }, "./credentials": { "types": "./dist/credentials/index.d.ts", "import": "./dist/credentials/index.js" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 954b587..19eb54b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,6 +57,12 @@ export { parseDuckDuckGoHtml, ToolRegistry, BUILTIN_TOOLS, + presentToolCall, + pickTarget, + BUILTIN_RENDER_INTENTS, + type ToolRenderKind, + type ToolPresentation, + type ToolDiffIntent, type TodoItem, type TodoStatus, type SearchHit, diff --git a/packages/core/src/tools/bash.ts b/packages/core/src/tools/bash.ts index a9c467a..e6d9832 100644 --- a/packages/core/src/tools/bash.ts +++ b/packages/core/src/tools/bash.ts @@ -161,6 +161,7 @@ export const BashTool: ToolHandler = { name: 'Bash', definition: { name: 'Bash', + render: 'terminal', description: 'Executes a shell command. Captures stdout/stderr/exitCode. Default timeout 2 min.', inputSchema: { diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 827fcc1..d5ec775 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -17,6 +17,7 @@ export const EditTool: ToolHandler = { name: 'Edit', definition: { name: 'Edit', + render: 'diff', description: 'Replaces exact text in a file. old_string must be unique unless replace_all=true. ' + 'old_string and new_string must differ.', diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 44832b8..b720135 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -35,4 +35,12 @@ export { SHELL_TOOLS, } from './shell.js'; export { ToolRegistry, BUILTIN_TOOLS } from './registry.js'; +export { + presentToolCall, + pickTarget, + BUILTIN_RENDER_INTENTS, + type ToolRenderKind, + type ToolPresentation, + type ToolDiffIntent, +} from './presentation.js'; export type { ToolDefinition, ToolContext, ToolResult, ToolHandler } from './types.js'; diff --git a/packages/core/src/tools/notebook.ts b/packages/core/src/tools/notebook.ts index 7dc755d..0f1e8e0 100644 --- a/packages/core/src/tools/notebook.ts +++ b/packages/core/src/tools/notebook.ts @@ -65,6 +65,7 @@ export const NotebookEditTool: ToolHandler = { name: 'NotebookEdit', definition: { name: 'NotebookEdit', + render: 'diff', description: 'Edit a single cell of a Jupyter notebook (.ipynb). edit_mode: "replace" sets the target cell\'s source; "insert" adds a new cell after the target (or at the top if cell_id omitted); "delete" removes the target cell. Identify the cell by its nbformat `cell_id` or a 0-based numeric index.', inputSchema: { diff --git a/packages/core/src/tools/presentation.test.ts b/packages/core/src/tools/presentation.test.ts new file mode 100644 index 0000000..36c36f7 --- /dev/null +++ b/packages/core/src/tools/presentation.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { BUILTIN_TOOLS } from './registry.js'; +import { + BUILTIN_RENDER_INTENTS, + pickTarget, + presentToolCall, + type ToolRenderKind, +} from './presentation.js'; + +describe('presentToolCall', () => { + it('defaults an unknown tool to generic', () => { + expect(presentToolCall('Whatever', { x: 1 })).toEqual({ kind: 'generic', target: undefined }); + }); + + it('reads the intent a tool declared', () => { + const p = presentToolCall('Anything', { command: 'ls' }, 'terminal'); + expect(p.kind).toBe('terminal'); + expect(p.command).toBe('ls'); + }); + + it('renders Bash as a terminal transcript', () => { + const p = presentToolCall('Bash', { command: 'pnpm test' }); + expect(p).toEqual({ kind: 'terminal', target: 'pnpm test', command: 'pnpm test' }); + }); + + it('renders Edit as a two-sided diff', () => { + const p = presentToolCall('Edit', { + file_path: '/a/b.ts', + old_string: 'const x = 1;', + new_string: 'const x = 2;', + }); + expect(p.kind).toBe('diff'); + expect(p.diff).toEqual({ path: '/a/b.ts', before: 'const x = 1;', after: 'const x = 2;' }); + }); + + it('renders Write as wholly added, since its arguments do not say what was there', () => { + const p = presentToolCall('Write', { file_path: '/a/b.ts', content: 'hello' }); + expect(p.diff).toEqual({ path: '/a/b.ts', before: '', after: 'hello' }); + }); + + it('falls back to generic rather than rendering an empty diff', () => { + // A declared intent is a request, not a guarantee — a client should never be + // handed a diff with nothing in it. + expect(presentToolCall('Edit', { old_string: 'a', new_string: 'b' }).kind).toBe('generic'); + expect(presentToolCall('Write', { file_path: '/a/b.ts' }).kind).toBe('generic'); + expect(presentToolCall('Bash', {}).kind).toBe('generic'); + }); + + it('depends only on the arguments', () => { + // Purity is what makes a replayed session render like the live one, so a + // second call on the same input must be indistinguishable from the first. + const input = { file_path: '/a/b.ts', old_string: 'a', new_string: 'b' }; + expect(presentToolCall('Edit', input)).toEqual(presentToolCall('Edit', input)); + }); + + it('ignores non-string arguments where it expects text', () => { + expect(presentToolCall('Bash', { command: 42 }).kind).toBe('generic'); + expect(presentToolCall('Edit', { file_path: '/a', old_string: 1, new_string: 2 }).kind).toBe( + 'generic', + ); + }); +}); + +describe('pickTarget', () => { + it('prefers the most specific argument available', () => { + expect(pickTarget({ file_path: '/a/b.ts', command: 'ls' })).toBe('/a/b.ts'); + expect(pickTarget({ command: 'ls', pattern: 'x' })).toBe('ls'); + }); + + it('returns nothing when no argument makes a useful label', () => { + expect(pickTarget({ replace_all: true })).toBeUndefined(); + }); +}); + +describe('BUILTIN_RENDER_INTENTS', () => { + it('agrees with what each built-in tool declares', () => { + // The table is what clients read when they hold only a name; the definition + // is what the loop reads. They must not drift apart. + for (const tool of BUILTIN_TOOLS) { + const declared = tool.definition.render as ToolRenderKind | undefined; + expect([declared, tool.name]).toEqual([BUILTIN_RENDER_INTENTS[tool.name], tool.name]); + } + }); +}); diff --git a/packages/core/src/tools/presentation.ts b/packages/core/src/tools/presentation.ts new file mode 100644 index 0000000..b46ab33 --- /dev/null +++ b/packages/core/src/tools/presentation.ts @@ -0,0 +1,119 @@ +// How a tool call should be rendered — decided by the tool, not by the client. +// +// Spec: docs/DSH_ADOPTION_PLAN.md §1.6 +// +// A tool knows what its call means; a client only knows a name and a JSON blob. +// Without this, every client re-derives the same knowledge — `name === 'Edit'` +// hardcoded in the desktop, again in the CLI, again in the VS Code extension — +// and the next tool has to be taught to all three. +// +// Presentation is a pure function of the call's ARGUMENTS. It never depends on +// the result, on the filesystem, or on anything that can differ between a live +// render and a replay from the session log, so a resumed session renders +// identically to the one that produced it. +// +// This module holds no UI types. `packages/core` has no UI dependency and does +// not gain one here: the intent is an enum and the payload is plain data. + +/** How a client should present a call. */ +export type ToolRenderKind = + /** Name, target, and the result as text. The default. */ + | 'generic' + /** A shell command and its output. */ + | 'terminal' + /** A change to a file, shown as added and removed lines. */ + | 'diff'; + +/** A file change derivable from a call's arguments alone. */ +export interface ToolDiffIntent { + /** Path as the call named it — not resolved, so this stays pure. */ + path: string; + /** Text being replaced. Empty when the call creates content wholesale. */ + before: string; + /** Text replacing it. */ + after: string; +} + +/** Everything a client needs to render one call, derived from its arguments. */ +export interface ToolPresentation { + kind: ToolRenderKind; + /** Header label: the file, command, or pattern this call is about. */ + target?: string; + /** For `terminal`: the command line, so a client can show it as a prompt. */ + command?: string; + /** For `diff`: the change itself. */ + diff?: ToolDiffIntent; +} + +/** Argument keys that make a reasonable header label, most specific first. */ +const TARGET_KEYS = ['file_path', 'command', 'pattern', 'path', 'url', 'query', 'notebook_path']; + +function str(input: Record, key: string): string | undefined { + const v = input[key]; + return typeof v === 'string' ? v : undefined; +} + +/** + * Pick a human-readable label for a call from its arguments. + * + * @param input The call's arguments. + * @returns The label, or undefined when no argument makes a useful one. + */ +export function pickTarget(input: Record): string | undefined { + for (const key of TARGET_KEYS) { + const v = str(input, key); + if (v !== undefined) return v; + } + return undefined; +} + +/** Render intents for the built-in tools, by name. */ +export const BUILTIN_RENDER_INTENTS: Readonly> = { + Bash: 'terminal', + Edit: 'diff', + Write: 'diff', + NotebookEdit: 'diff', +}; + +/** + * Derive how to render one tool call. + * + * The declared `kind` is a request, not a guarantee: a call that declares `diff` + * but carries no usable path or text falls back to `generic` rather than + * handing the client an empty diff to render. + * + * @param name Tool name. + * @param input The call's arguments. + * @param declared The tool's declared render intent. Defaults to the built-in + * table, so a client holding only a name and arguments — a renderer replaying + * a session log, say — needs no access to the tool definition. + * @returns A presentation the client can render without knowing the tool. + */ +export function presentToolCall( + name: string, + input: Record, + declared: ToolRenderKind | undefined = BUILTIN_RENDER_INTENTS[name], +): ToolPresentation { + const target = pickTarget(input); + const kind = declared ?? 'generic'; + + if (kind === 'terminal') { + const command = str(input, 'command'); + return command === undefined ? { kind: 'generic', target } : { kind, target, command }; + } + + if (kind === 'diff') { + const path = str(input, 'file_path') ?? str(input, 'notebook_path'); + if (path === undefined) return { kind: 'generic', target }; + // Edit states both sides. Write and NotebookEdit state only the new text — + // the old side is on disk, which this function deliberately cannot read, so + // the change renders as wholly added rather than as a lie about what it + // replaced. + const before = str(input, 'old_string') ?? ''; + const after = str(input, 'new_string') ?? str(input, 'content') ?? str(input, 'new_source'); + if (after === undefined) return { kind: 'generic', target }; + return { kind, target, diff: { path, before, after } }; + } + + return { kind: 'generic', target }; +} diff --git a/packages/core/src/tools/write.ts b/packages/core/src/tools/write.ts index 3f78f47..58ee115 100644 --- a/packages/core/src/tools/write.ts +++ b/packages/core/src/tools/write.ts @@ -15,6 +15,7 @@ export const WriteTool: ToolHandler = { name: 'Write', definition: { name: 'Write', + render: 'diff', description: 'Writes content to a file. Creates parent directories if needed. Overwrites existing file.', inputSchema: { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 27be380..ddbf3bd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -105,6 +105,14 @@ export interface ToolDefinition { description: string; /** JSON Schema describing the input shape. */ inputSchema: Record; + /** + * How clients should present this call. Absent means `generic`. + * + * Declared by the tool so every client — desktop, CLI, editor — reads one + * answer instead of each hardcoding the same tool names. See + * `tools/presentation.ts`. + */ + render?: import('./tools/presentation.js').ToolRenderKind; } export interface ToolContext {