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) => (
+
+ );
+ })}
@@ -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 {