Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion apps/desktop/src/components/ToolBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div key={i} className="tc-loc-row">
{onOpenFile ? (
<button type="button" className="tc-loc" onClick={() => onOpenFile(loc.path)}>
{label}
</button>
) : (
<span className="tc-loc">{label}</span>
)}
{loc.preview !== undefined && loc.preview !== '' && (
<span className="tc-loc-preview"> {loc.preview}</span>
)}
</div>
);
})}
</>
);
}

/** A command and what it printed, styled as a shell transcript. */
function TerminalBody({ command, output }: { command: string; output?: string }): JSX.Element {
return (
Expand All @@ -59,20 +97,31 @@ 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 <DiffBody before={presentation.diff.before} after={presentation.diff.after} />;
}
if (presentation.kind === 'terminal' && presentation.command !== undefined) {
return <TerminalBody command={presentation.command} output={resultText} />;
}
if (presentation.kind === 'locations' && locations && locations.length > 0) {
return <LocationsBody locations={locations} onOpenFile={onOpenFile} />;
}
return resultText ? <>{clip(resultText)}</> : null;
}
7 changes: 4 additions & 3 deletions apps/desktop/src/components/ToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/lib/repl-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/lib/repl-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -22,6 +23,12 @@ export interface ToolInvocation {
input: Record<string, unknown>;
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 {
Expand Down Expand Up @@ -123,6 +130,7 @@ export function attachToolResult(
toolId: string,
content: string,
status: 'ok' | 'err',
locations?: ToolLocation[],
): Msg[] {
let messageIndex = -1;
let toolIndex = -1;
Expand Down Expand Up @@ -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 } };
});
}
Expand Down
56 changes: 52 additions & 4 deletions apps/desktop/src/preview-toolcards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +17,7 @@ const CALLS: Array<{
name: string;
input: Record<string, unknown>;
result?: string;
locations?: ToolLocation[];
status: 'ok' | 'err' | 'running';
}> = [
{
Expand Down Expand Up @@ -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',
},
{
Expand Down Expand Up @@ -93,7 +134,14 @@ function Preview(): JSX.Element {
? '✓ done'
: '✕ error',
}}
body={<ToolBody presentation={presentation} resultText={call.result} />}
body={
<ToolBody
presentation={presentation}
resultText={call.result}
locations={call.locations}
onOpenFile={(path) => console.log('open', path)}
/>
}
/>
</div>
);
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/src/screens/Repl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -197,7 +197,7 @@ interface AgentEvt {
text?: string;
name?: string;
input?: Record<string, unknown>;
result?: { content: string; isError?: boolean };
result?: { content: string; isError?: boolean; data?: Record<string, unknown> };
error?: string;
stopReason?: string;
// usage event — emitted per provider round-trip with that turn's token counts
Expand Down Expand Up @@ -404,6 +404,7 @@ export function ReplScreen({
e.id ?? '',
e.result?.content ?? '',
e.result?.isError ? 'err' : 'ok',
readToolLocations(e.result?.data),
),
);
break;
Expand Down Expand Up @@ -1151,7 +1152,14 @@ function renderMessage(
: '✕ error',
}}
layout={presentation.kind}
body={<ToolBody presentation={presentation} resultText={t.resultText} />}
body={
<ToolBody
presentation={presentation}
resultText={t.resultText}
locations={t.locations}
onOpenFile={onOpenFile}
/>
}
onOpen={
onOpenFile && typeof t.input?.file_path === 'string'
? () => onOpenFile(String(t.input.file_path))
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/tools/glob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading