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
78 changes: 78 additions & 0 deletions apps/desktop/src/components/ToolBody.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div
key={i}
className={
line.kind === 'add' ? 'diff-add' : line.kind === 'del' ? 'diff-del' : undefined
}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
{line.text}
</div>
))}
</>
);
}

/** A command and what it printed, styled as a shell transcript. */
function TerminalBody({ command, output }: { command: string; output?: string }): JSX.Element {
return (
<>
<div className="tc-prompt">
<span className="tc-sigil">$</span> {command}
</div>
{output ? <div className="tc-stream">{clip(output)}</div> : 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 <DiffBody before={presentation.diff.before} after={presentation.diff.after} />;
}
if (presentation.kind === 'terminal' && presentation.command !== undefined) {
return <TerminalBody command={presentation.command} output={resultText} />;
}
return resultText ? <>{clip(resultText)}</> : null;
}
19 changes: 15 additions & 4 deletions apps/desktop/src/components/ToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,27 @@ 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.
*/
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 (
<div className={'tool-card' + (onOpen ? ' openable' : '')}>
<div className="tc-head">
Expand All @@ -49,7 +60,7 @@ export function ToolCard({ name, target, status, body, diff, onOpen }: ToolCardP
))}
{status && <Badge kind={status.kind}>{status.label}</Badge>}
</div>
{body !== undefined && <div className={diff ? 'tc-body diff' : 'tc-body'}>{body}</div>}
{body !== undefined && <div className={`tc-body ${layout}`}>{body}</div>}
</div>
);
}
18 changes: 17 additions & 1 deletion apps/desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
15 changes: 6 additions & 9 deletions apps/desktop/src/lib/repl-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, unknown>): 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. */
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/preview-toolcards.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tool cards preview (dev only)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/preview-toolcards.tsx"></script>
</body>
</html>
105 changes: 105 additions & 0 deletions apps/desktop/src/preview-toolcards.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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:
'<stdout>\n RUN v4.1.10\n\n Test Files 2 passed (2)\n Tests 19 passed (19)\n</stdout>\nexit: 0',
status: 'ok',
},
{
name: 'Bash',
input: { command: 'cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml' },
result: '<stderr>\nerror: could not compile `deepcode-desktop`\n</stderr>\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 (
<div style={{ padding: 24, maxWidth: 860, margin: '0 auto' }}>
<h2 style={{ font: '600 15px/1.4 system-ui', color: 'var(--text-1)', marginBottom: 16 }}>
Tool cards by render intent
</h2>
{CALLS.map((call, i) => {
const presentation = presentToolCall(call.name, call.input);
return (
<div key={i} style={{ marginBottom: 14 }}>
<div style={{ font: '11px/1.6 system-ui', color: 'var(--text-3)', marginBottom: 4 }}>
{call.name} → {presentation.kind}
</div>
<ToolCard
name={call.name}
target={presentation.kind === 'terminal' ? undefined : presentation.target}
layout={presentation.kind}
status={{
kind: call.status === 'running' ? 'info' : call.status === 'ok' ? 'ok' : 'err',
label:
call.status === 'running'
? '… running'
: call.status === 'ok'
? '✓ done'
: '✕ error',
}}
body={<ToolBody presentation={presentation} resultText={call.result} />}
/>
</div>
);
})}
</div>
);
}

createRoot(document.getElementById('root') as HTMLElement).render(<Preview />);
Loading
Loading