diff --git a/CHANGELOG.md b/CHANGELOG.md index 4567472..dcf056d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### ✨ Added +- **Grep and Glob results in the desktop are now clickable locations.** Both + tools attach the places they found as structured data — resolved from their + own parsed rows, where a path containing `:` is still unambiguous — and the + desktop renders a search's card as a list of openable paths (with line and + matched text in content mode) instead of a grey text blob. Clicking one opens + it in the file panel. This is the `locations` render intent the DSH round + specified and deferred: re-deriving entries by parsing the formatted result + text was the rejected design, and attaching them at the source is what makes + the entries exact. A session restored from its log has only the text the + model saw, so replayed cards degrade to today's plain-text body. + - **Persistent shells now last a whole CLI session, and `/shells` shows them.** The registry landed in #273 owned by a single `runAgent` call, which meant a shell opened in one turn was gone by the next — a slower `Bash` with extra diff --git a/apps/desktop/src/components/ToolBody.tsx b/apps/desktop/src/components/ToolBody.tsx index 798853a..1e25045 100644 --- a/apps/desktop/src/components/ToolBody.tsx +++ b/apps/desktop/src/components/ToolBody.tsx @@ -7,7 +7,7 @@ import type { JSX } from 'react'; import { computeLineDiff } from '../lib/diff.js'; -import type { ToolPresentation } from '@deepcode/core/dist/tools/presentation.js'; +import type { ToolLocation, 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; @@ -42,6 +42,44 @@ function DiffBody({ before, after }: { before: string; after: string }): JSX.Ele ); } +/** + * The places a search found, each one openable. + * + * Entries come from the tool's structured result data, resolved by the tool + * itself from its own parsed rows — never re-parsed out of the formatted text, + * whose `:` separator is ambiguous when a path contains one. Without an + * `onOpenFile` the list still renders; the rows are just not buttons. + */ +function LocationsBody({ + locations, + onOpenFile, +}: { + locations: ToolLocation[]; + onOpenFile?: (path: string) => void; +}): JSX.Element { + return ( + <> + {locations.map((loc, i) => { + const label = `${loc.display ?? loc.path}${loc.line !== undefined ? `:${loc.line}` : ''}`; + return ( +
+ {onOpenFile ? ( + + ) : ( + {label} + )} + {loc.preview !== undefined && loc.preview !== '' && ( + {loc.preview} + )} +
+ ); + })} + + ); +} + /** A command and what it printed, styled as a shell transcript. */ function TerminalBody({ command, output }: { command: string; output?: string }): JSX.Element { return ( @@ -59,14 +97,22 @@ function TerminalBody({ command, output }: { command: string; output?: string }) * * @param presentation What core derived from the call's arguments. * @param resultText The tool's output, once it has any. + * @param locations Openable entries from the result's structured data. A call + * whose intent is `locations` but that has none — still running, restored + * from a session log, or genuinely empty — falls back to the text body. + * @param onOpenFile Opens a path in the file panel, when the host offers one. * @returns The body, or null when there is nothing to show yet. */ export function ToolBody({ presentation, resultText, + locations, + onOpenFile, }: { presentation: ToolPresentation; resultText?: string; + locations?: ToolLocation[]; + onOpenFile?: (path: string) => void; }): JSX.Element | null { if (presentation.kind === 'diff' && presentation.diff) { return ; @@ -74,5 +120,8 @@ export function ToolBody({ if (presentation.kind === 'terminal' && presentation.command !== undefined) { return ; } + if (presentation.kind === 'locations' && locations && locations.length > 0) { + return ; + } return resultText ? <>{clip(resultText)} : null; } diff --git a/apps/desktop/src/components/ToolCard.tsx b/apps/desktop/src/components/ToolCard.tsx index b923727..3b32258 100644 --- a/apps/desktop/src/components/ToolCard.tsx +++ b/apps/desktop/src/components/ToolCard.tsx @@ -22,10 +22,11 @@ interface ToolCardProps { body?: ReactNode; /** * 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`. + * `locations` is a one-entry-per-line list of openable paths; `generic` + * wraps. Chosen from the tool's own declared render intent — see core's + * `tools/presentation.ts`. */ - layout?: 'generic' | 'diff' | 'terminal'; + layout?: 'generic' | 'diff' | 'terminal' | 'locations'; /** * 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. diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 793a5fe..17a3b80 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -1000,6 +1000,31 @@ select { border-top: 1px solid var(--border); padding-top: 6px; } +/* One found location per row: an openable path, then the matched text. */ +.tool-card .tc-body.locations { + white-space: pre; +} +.tool-card .tc-loc-row { + overflow: hidden; + text-overflow: ellipsis; +} +.tool-card button.tc-loc { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--brand); + cursor: pointer; +} +.tool-card button.tc-loc:hover { + text-decoration: underline; +} +.tool-card span.tc-loc { + color: var(--text-1); +} +.tool-card .tc-loc-preview { + color: var(--text-2); +} /* Inline approval — sits right under a tool card */ .approval-row { diff --git a/apps/desktop/src/lib/repl-stream.test.ts b/apps/desktop/src/lib/repl-stream.test.ts index ceb5d7d..4eade5c 100644 --- a/apps/desktop/src/lib/repl-stream.test.ts +++ b/apps/desktop/src/lib/repl-stream.test.ts @@ -72,6 +72,24 @@ describe('repl-stream mutators', () => { }); }); + it('keeps a result’s locations on the card, and omits the key when there are none', () => { + let m: Msg[] = []; + m = appendToolUse(m, tool('a', 'Grep')); + m = appendToolUse(m, tool('b', 'Read')); + m = attachToolResult(m, 'a', '/repo/x.ts:1:hit', 'ok', [ + { path: '/repo/x.ts', line: 1, preview: 'hit' }, + ]); + // A restored session replays results without structured data — the card + // must not grow a `locations: []` that reads as "searched, found nothing". + m = attachToolResult(m, 'b', 'file contents', 'ok', []); + const turn = m[0]!; + if (turn.role !== 'assistant') throw new Error('expected assistant'); + expect(turn.turn.tools[0]!.locations).toEqual([ + { path: '/repo/x.ts', line: 1, preview: 'hit' }, + ]); + expect('locations' in turn.turn.tools[1]!).toBe(false); + }); + it('falls back to only the newest running tool across resumed turns', () => { const m: Msg[] = [ { diff --git a/apps/desktop/src/lib/repl-stream.ts b/apps/desktop/src/lib/repl-stream.ts index 630cab4..7d3d8f3 100644 --- a/apps/desktop/src/lib/repl-stream.ts +++ b/apps/desktop/src/lib/repl-stream.ts @@ -12,6 +12,7 @@ // 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'; +import type { ToolLocation } from '@deepcode/core/dist/tools/presentation.js'; export { pickTarget }; @@ -22,6 +23,12 @@ export interface ToolInvocation { input: Record; status: 'running' | 'ok' | 'err'; resultText?: string; + /** + * Openable places the call found, from the result's structured data. Only + * live results carry them — a session restored from its log has just the + * text, and its cards degrade to the plain-text body. + */ + locations?: ToolLocation[]; } export interface AssistantTurn { @@ -123,6 +130,7 @@ export function attachToolResult( toolId: string, content: string, status: 'ok' | 'err', + locations?: ToolLocation[], ): Msg[] { let messageIndex = -1; let toolIndex = -1; @@ -153,7 +161,12 @@ export function attachToolResult( return msgs.map((message, index): Msg => { if (index !== messageIndex || message.role !== 'assistant') return message; const tools = [...message.turn.tools]; - tools[toolIndex] = { ...tools[toolIndex]!, status, resultText: content }; + tools[toolIndex] = { + ...tools[toolIndex]!, + status, + resultText: content, + ...(locations && locations.length > 0 ? { locations } : {}), + }; return { ...message, turn: { ...message.turn, tools } }; }); } diff --git a/apps/desktop/src/preview-toolcards.tsx b/apps/desktop/src/preview-toolcards.tsx index 0231752..4145daa 100644 --- a/apps/desktop/src/preview-toolcards.tsx +++ b/apps/desktop/src/preview-toolcards.tsx @@ -2,12 +2,13 @@ // 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 +// Renders one card per render intent so the four 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 type { ToolLocation } from '@deepcode/core/dist/tools/presentation.js'; import { ToolBody } from './components/ToolBody.js'; import { ToolCard } from './components/ToolCard.js'; import './index.css'; @@ -16,6 +17,7 @@ const CALLS: Array<{ name: string; input: Record; result?: string; + locations?: ToolLocation[]; status: 'ok' | 'err' | 'running'; }> = [ { @@ -55,9 +57,48 @@ const CALLS: Array<{ }, { name: 'Grep', - input: { pattern: 'applySpillPolicy', path: 'packages/core/src' }, + input: { pattern: 'applySpillPolicy', path: 'packages/core/src', '-n': true }, result: - 'packages/core/src/agent.ts:16\npackages/core/src/spill/policy.ts:71\npackages/core/src/index.ts:213', + 'packages/core/src/agent.ts:16:import { applySpillPolicy } from...\npackages/core/src/spill/policy.ts:71:export function applySpillPolicy(\npackages/core/src/index.ts:213: applySpillPolicy,', + locations: [ + { + path: '/repo/packages/core/src/agent.ts', + display: 'packages/core/src/agent.ts', + line: 16, + preview: "import { applySpillPolicy } from './spill/policy.js';", + }, + { + path: '/repo/packages/core/src/spill/policy.ts', + display: 'packages/core/src/spill/policy.ts', + line: 71, + preview: 'export function applySpillPolicy(', + }, + { + path: '/repo/packages/core/src/index.ts', + display: 'packages/core/src/index.ts', + line: 213, + preview: ' applySpillPolicy,', + }, + ], + status: 'ok', + }, + { + name: 'Glob', + input: { pattern: 'src/sandbox/*.ts', path: 'packages/core' }, + result: 'src/sandbox/dns-proxy.ts\nsrc/sandbox/netns.ts\nsrc/sandbox/profiles.ts', + locations: [ + { path: '/repo/packages/core/src/sandbox/dns-proxy.ts', display: 'src/sandbox/dns-proxy.ts' }, + { path: '/repo/packages/core/src/sandbox/netns.ts', display: 'src/sandbox/netns.ts' }, + { path: '/repo/packages/core/src/sandbox/profiles.ts', display: 'src/sandbox/profiles.ts' }, + ], + status: 'ok', + }, + { + // A restored session has only the result text — the same call degrades to + // the generic body, no buttons. + name: 'Grep', + input: { pattern: 'applySpillPolicy', path: 'packages/core/src' }, + result: 'packages/core/src/agent.ts:16\npackages/core/src/spill/policy.ts:71', status: 'ok', }, { @@ -93,7 +134,14 @@ function Preview(): JSX.Element { ? '✓ done' : '✕ error', }} - body={} + body={ + console.log('open', path)} + /> + } /> ); diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 73fc6cc..41b5bfe 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -43,7 +43,7 @@ import { } 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 { presentToolCall, readToolLocations } 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'; @@ -197,7 +197,7 @@ interface AgentEvt { text?: string; name?: string; input?: Record; - result?: { content: string; isError?: boolean }; + result?: { content: string; isError?: boolean; data?: Record }; error?: string; stopReason?: string; // usage event — emitted per provider round-trip with that turn's token counts @@ -404,6 +404,7 @@ export function ReplScreen({ e.id ?? '', e.result?.content ?? '', e.result?.isError ? 'err' : 'ok', + readToolLocations(e.result?.data), ), ); break; @@ -1151,7 +1152,14 @@ function renderMessage( : '✕ error', }} layout={presentation.kind} - body={} + body={ + + } onOpen={ onOpenFile && typeof t.input?.file_path === 'string' ? () => onOpenFile(String(t.input.file_path)) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 19eb54b..ab1ac13 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,10 +59,13 @@ export { BUILTIN_TOOLS, presentToolCall, pickTarget, + readToolLocations, BUILTIN_RENDER_INTENTS, + MAX_TOOL_LOCATIONS, type ToolRenderKind, type ToolPresentation, type ToolDiffIntent, + type ToolLocation, type TodoItem, type TodoStatus, type SearchHit, diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index 9e3e756..e5f8a4b 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -39,6 +39,24 @@ describe('GlobTool', () => { expect(lines.length).toBeLessThanOrEqual(2); }); + it('attaches locations: absolute for opening, relative as displayed', async () => { + const r = await GlobTool.execute({ pattern: '**/*.ts', path: tmp }, { cwd: tmp }); + const locs = (r.data?.locations ?? []) as Array<{ path: string; display?: string }>; + expect(locs.length).toBe(3); + for (const loc of locs) { + expect(loc.path.startsWith(tmp)).toBe(true); + expect(loc.display).toBe(loc.path.slice(tmp.length + 1)); + } + // Entries line up with the listing, in the same (mtime) order. + const listed = (r.content as string).split('\n').filter(Boolean); + expect(locs.map((l) => l.display)).toEqual(listed); + }); + + it('slices locations with limit, so the marker line has no entry', async () => { + const r = await GlobTool.execute({ pattern: '**/*.ts', path: tmp, limit: 1 }, { cwd: tmp }); + expect((r.data?.locations as unknown[]).length).toBe(1); + }); + it('returns (no matches) cleanly', async () => { const r = await GlobTool.execute({ pattern: '**/*.xyz', path: tmp }, { cwd: tmp }); expect(r.isError).toBeFalsy(); diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 9b996ef..fe97284 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -4,6 +4,7 @@ import { glob } from 'node:fs/promises'; import { isAbsolute, relative, resolve } from 'node:path'; import { withheldNotice, withholdDeniedReads } from '../config/contract-dispatch.js'; +import { MAX_TOOL_LOCATIONS, type ToolLocation } from './presentation.js'; import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; interface GlobInput { @@ -18,6 +19,7 @@ export const GlobTool: ToolHandler = { name: 'Glob', definition: { name: 'Glob', + render: 'locations', description: 'Finds files matching a glob pattern (e.g. "src/**/*.ts"). Returns paths sorted by mtime (most recent first).', inputSchema: { @@ -86,9 +88,21 @@ export const GlobTool: ToolHandler = { const notice = withheldNotice(withheld); if (notice) lines.push(notice); + // Structured entries for clients that render results as openable locations: + // the absolute path for opening, plus the relative form the text printed. + const locations: ToolLocation[] = top.slice(0, MAX_TOOL_LOCATIONS).map((s) => ({ + path: s.p, + display: relative(ctx.cwd, s.p) || s.p, + })); + return { content: lines.join('\n') || '(no matches)', - data: { count: top.length, total: stamped.length, ...(withheld > 0 ? { withheld } : {}) }, + data: { + count: top.length, + total: stamped.length, + locations, + ...(withheld > 0 ? { withheld } : {}), + }, }; }, }; diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index ece8710..37299c7 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -149,6 +149,68 @@ describe('GrepTool', async () => { expect(r.data?.mode).toBe('files_with_matches'); }); + // Locations are built from the parsed rows, never re-parsed out of the + // formatted text — the `:` separator there is ambiguous when a path contains + // one, which is the same reason the withhold filter runs on rows. + describe('locations', () => { + const locs = (r: { data?: Record }) => + (r.data?.locations ?? []) as Array<{ + path: string; + line?: number; + preview?: string; + }>; + + it.skipIf(skipReason)('carries path, line and preview in content mode with -n', async () => { + const r = await GrepTool.execute( + { pattern: 'verifyToken', path: tmp, type: 'ts', '-n': true }, + { cwd: tmp }, + ); + const sorted = locs(r).sort((a, b) => a.path.localeCompare(b.path)); + expect(sorted).toEqual([ + { path: join(tmp, 'a.ts'), line: 1, preview: 'function verifyToken() {}' }, + { path: join(tmp, 'b.ts'), line: 1, preview: 'verifyToken(); // call site' }, + ]); + }); + + it.skipIf(skipReason)('attributes a single-file search to the search path', async () => { + // rg printed no filename at all here; the row's path is null and the + // file it came from is the search path itself. + const r = await GrepTool.execute( + { pattern: 'verifyToken', path: join(tmp, 'a.ts'), '-n': true }, + { cwd: tmp }, + ); + expect(locs(r)).toEqual([ + { path: join(tmp, 'a.ts'), line: 1, preview: 'function verifyToken() {}' }, + ]); + }); + + it.skipIf(skipReason)('lists bare paths in files_with_matches mode', async () => { + const r = await GrepTool.execute( + { pattern: 'verifyToken', path: tmp, output_mode: 'files_with_matches', type: 'ts' }, + { cwd: tmp }, + ); + const paths = locs(r).map((l) => l.path); + expect(paths.sort()).toEqual([join(tmp, 'a.ts'), join(tmp, 'b.ts')]); + expect(locs(r).every((l) => l.line === undefined && l.preview === undefined)).toBe(true); + }); + + it.skipIf(skipReason)( + 'slices with head_limit so entries line up with shown lines', + async () => { + const r = await GrepTool.execute( + { pattern: 'verifyToken', path: tmp, head_limit: 1 }, + { cwd: tmp }, + ); + expect(locs(r)).toHaveLength(1); + // The truncation marker counts everything found, not everything listed. + expect(r.content).toMatch(/\.\.\. \[1 of \d+\]/); + // The one location is the one shown. + const firstLine = (r.content as string).split('\n')[0]!; + expect(firstLine.startsWith(locs(r)[0]!.path)).toBe(true); + }, + ); + }); + // The pre-call gate adjudicates the *search root*. A search rooted at the // workspace is allowed, and then hands back the contents of every file it // matched — including the ones the contract says must never be read. @@ -168,6 +230,9 @@ describe('GrepTool', async () => { expect(r.content).not.toMatch(/hunter2/); // the secret itself expect(r.content).toMatch(/1 result withheld by the file contract/); expect(r.data?.withheld).toBe(1); + // The structured entries must not leak what the text withheld. + const paths = (r.data?.locations as Array<{ path: string }>).map((l) => l.path); + expect(paths.some((p) => p.includes('prod.key'))).toBe(false); }); it.skipIf(skipReason)('withholds in files_with_matches mode too', async () => { diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 8cbd3ac..551e0a0 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -5,6 +5,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { isAbsolute, resolve } from 'node:path'; import { withheldNotice, withholdDeniedReads } from '../config/contract-dispatch.js'; +import { MAX_TOOL_LOCATIONS, type ToolLocation } from './presentation.js'; import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; const execFileAsync = promisify(execFile); @@ -90,6 +91,7 @@ export const GrepTool: ToolHandler = { name: 'Grep', definition: { name: 'Grep', + render: 'locations', description: 'Searches for a regex pattern using ripgrep (rg). Supports globs, file types, case-insensitive matching.', inputSchema: { @@ -194,14 +196,15 @@ export const GrepTool: ToolHandler = { (row) => row.path ?? searchPath, ); - let lines = kept.map(formatRipgrepRow); - const matched = lines.length; - - if (input.head_limit && input.head_limit > 0) { - const truncated = lines.length > input.head_limit; - lines = lines.slice(0, input.head_limit); - if (truncated) lines.push(`... [${lines.length} of ${matched}]`); - } + const matched = kept.length; + // Slice the ROWS, not the formatted lines: the locations below must line up + // with what is shown, and rows still carry the exact path `--null` gave us. + const shown = + input.head_limit && input.head_limit > 0 && kept.length > input.head_limit + ? kept.slice(0, input.head_limit) + : kept; + const lines = shown.map(formatRipgrepRow); + if (shown.length < matched) lines.push(`... [${lines.length} of ${matched}]`); // Say that something was withheld, never what. Silence is worse than the // count: an agent that finds nothing goes looking through Bash, which the @@ -209,9 +212,31 @@ export const GrepTool: ToolHandler = { const notice = withheldNotice(withheld); if (notice) lines.push(notice); + // Structured entries for clients that render results as openable locations. + // Built from the parsed rows — the formatted text's `:` separator is not + // reversible (a path may contain one), which is why the text is never + // parsed back. A row without a path came from a single-file search, so the + // search path is the file. Line numbers exist only when rg printed them. + const locations: ToolLocation[] = shown.slice(0, MAX_TOOL_LOCATIONS).map((row) => { + const path = row.path ?? searchPath; + const entry: ToolLocation = { path }; + if (mode === 'content' && row.text !== null) { + if (input['-n']) { + const numbered = /^(\d+):(.*)$/s.exec(row.text); + if (numbered) { + entry.line = Number(numbered[1]); + entry.preview = numbered[2]; + return entry; + } + } + entry.preview = row.text; + } + return entry; + }); + return { content: lines.join('\n') || '(no matches)', - data: { mode, matches: matched, ...(withheld > 0 ? { withheld } : {}) }, + data: { mode, matches: matched, locations, ...(withheld > 0 ? { withheld } : {}) }, }; }, }; diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index b720135..e78dcc8 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -38,9 +38,12 @@ export { ToolRegistry, BUILTIN_TOOLS } from './registry.js'; export { presentToolCall, pickTarget, + readToolLocations, BUILTIN_RENDER_INTENTS, + MAX_TOOL_LOCATIONS, type ToolRenderKind, type ToolPresentation, type ToolDiffIntent, + type ToolLocation, } from './presentation.js'; export type { ToolDefinition, ToolContext, ToolResult, ToolHandler } from './types.js'; diff --git a/packages/core/src/tools/presentation.test.ts b/packages/core/src/tools/presentation.test.ts index 36c36f7..c84a7f1 100644 --- a/packages/core/src/tools/presentation.test.ts +++ b/packages/core/src/tools/presentation.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect } from 'vitest'; import { BUILTIN_TOOLS } from './registry.js'; import { BUILTIN_RENDER_INTENTS, + MAX_TOOL_LOCATIONS, pickTarget, presentToolCall, + readToolLocations, type ToolRenderKind, } from './presentation.js'; @@ -53,6 +55,19 @@ describe('presentToolCall', () => { expect(presentToolCall('Edit', input)).toEqual(presentToolCall('Edit', input)); }); + it('renders Grep and Glob as location lists, labelled by their pattern', () => { + // The intent alone: what was found lives in the result's data channel, so + // the presentation carries no entries — arguments cannot know the matches. + expect(presentToolCall('Grep', { pattern: 'TODO', path: 'src' })).toEqual({ + kind: 'locations', + target: 'TODO', + }); + expect(presentToolCall('Glob', { pattern: '**/*.ts' })).toEqual({ + kind: 'locations', + target: '**/*.ts', + }); + }); + 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( @@ -72,6 +87,46 @@ describe('pickTarget', () => { }); }); +describe('readToolLocations', () => { + it('reads well-formed entries and keeps only their known fields', () => { + expect( + readToolLocations({ + locations: [ + { path: '/a/b.ts', display: 'b.ts', line: 3, preview: 'hit', extra: 'dropped' }, + { path: '/c.ts' }, + ], + }), + ).toEqual([{ path: '/a/b.ts', display: 'b.ts', line: 3, preview: 'hit' }, { path: '/c.ts' }]); + }); + + it('degrades malformed payloads to no locations instead of throwing', () => { + // This is the one validating extractor every client shares, so a bad + // payload must fail the same quiet way everywhere. + expect(readToolLocations(undefined)).toEqual([]); + expect(readToolLocations('locations')).toEqual([]); + expect(readToolLocations({ locations: 'nope' })).toEqual([]); + expect( + readToolLocations({ locations: [null, 42, { display: 'no path' }, { path: '' }] }), + ).toEqual([]); + }); + + it('ignores a non-numeric line rather than inventing one', () => { + expect(readToolLocations({ locations: [{ path: '/a', line: 'seven' }] })).toEqual([ + { path: '/a' }, + ]); + expect(readToolLocations({ locations: [{ path: '/a', line: Infinity }] })).toEqual([ + { path: '/a' }, + ]); + }); + + it('caps the list at MAX_TOOL_LOCATIONS', () => { + const locations = Array.from({ length: MAX_TOOL_LOCATIONS + 50 }, (_, i) => ({ + path: `/f${i}`, + })); + expect(readToolLocations({ locations })).toHaveLength(MAX_TOOL_LOCATIONS); + }); +}); + 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 diff --git a/packages/core/src/tools/presentation.ts b/packages/core/src/tools/presentation.ts index b46ab33..33d6edd 100644 --- a/packages/core/src/tools/presentation.ts +++ b/packages/core/src/tools/presentation.ts @@ -22,7 +22,70 @@ export type ToolRenderKind = /** A shell command and its output. */ | 'terminal' /** A change to a file, shown as added and removed lines. */ - | 'diff'; + | 'diff' + /** A list of file locations the call found, each one openable. */ + | 'locations'; + +/** + * One place a search-shaped tool found, exact enough to open. + * + * The intent stays a pure function of the call's ARGUMENTS — a pattern does not + * say what it will match — so the entries ride the result's `data` channel + * instead, attached by the tool at execute time from its own parsed rows. That + * is the whole point: Grep resolves paths from ripgrep's `--null` output, where + * a path containing `:` is still unambiguous, and re-deriving the entries from + * the formatted result text would reintroduce exactly that ambiguity. + * + * A client replaying a session log has only the result text (session history + * stores what the model saw), so a replayed call falls back to the generic text + * body — same characters, minus the affordance to click them. + */ +export interface ToolLocation { + /** Absolute path, for opening. */ + path: string; + /** The path as the result text printed it (e.g. relative). Defaults to `path`. */ + display?: string; + /** 1-based line, when the tool knows one. */ + line?: number; + /** The matched text, when the tool has it (Grep content mode). */ + preview?: string; +} + +/** + * Upper bound on locations a tool attaches to one result. + * + * An unbounded content-mode Grep can match thousands of lines; the text output + * already carries them all for the model, and duplicating every one as a + * structured entry only bloats the wire for a list no card will usefully show. + */ +export const MAX_TOOL_LOCATIONS = 200; + +/** + * Read `data.locations` back out of a tool result's structured payload. + * + * The one validating extractor every client shares, so a malformed or + * hand-crafted payload degrades to "no locations" everywhere instead of + * crashing one renderer. Entries missing a string path are dropped; + * non-numeric lines are ignored rather than invented. + */ +export function readToolLocations(data: unknown): ToolLocation[] { + if (!data || typeof data !== 'object') return []; + const raw = (data as { locations?: unknown }).locations; + if (!Array.isArray(raw)) return []; + const out: ToolLocation[] = []; + for (const entry of raw.slice(0, MAX_TOOL_LOCATIONS)) { + if (!entry || typeof entry !== 'object') continue; + const { path, display, line, preview } = entry as Record; + if (typeof path !== 'string' || path === '') continue; + out.push({ + path, + ...(typeof display === 'string' ? { display } : {}), + ...(typeof line === 'number' && Number.isFinite(line) ? { line } : {}), + ...(typeof preview === 'string' ? { preview } : {}), + }); + } + return out; +} /** A file change derivable from a call's arguments alone. */ export interface ToolDiffIntent { @@ -73,6 +136,8 @@ export const BUILTIN_RENDER_INTENTS: Readonly> = Edit: 'diff', Write: 'diff', NotebookEdit: 'diff', + Grep: 'locations', + Glob: 'locations', }; /** @@ -115,5 +180,10 @@ export function presentToolCall( return { kind, target, diff: { path, before, after } }; } + // `locations` has no argument-derived payload: a pattern does not say what it + // will match. The entries arrive on the result's `data` channel, and a client + // that has none (a replay from the session log) falls back to the text body. + if (kind === 'locations') return { kind, target }; + return { kind: 'generic', target }; } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index e0a5be7..61c2dbe 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -68,7 +68,14 @@ export interface ToolCompletedEvent { threadId: string; turnId: string; itemId: string; - result: { content: string; isError?: boolean }; + /** + * `data` is the tool's structured payload (core `ToolResult.data`), passed + * through verbatim — the server has always serialized the whole result, so + * declaring it here documents the wire rather than widening it. Clients use + * it for richer rendering (e.g. Grep/Glob attach `locations`); it is + * transient and not part of the persisted thread snapshot. + */ + result: { content: string; isError?: boolean; data?: Record }; } export interface UsageUpdatedEvent {