Skip to content

Commit b56fefe

Browse files
committed
Add OpenSpec change for agent response rendering panel
Proposes an opt-in "AI Response" panel rendered alongside the existing xterm agent terminal. The backend tails the agent's structured session log (Claude Code and Amp at launch; Codex / Cursor as follow-ups) and forwards normalised events to a SolidJS panel that reuses the existing marked + shiki + DOMPurify + mermaid pipeline. Pty input flow, permissions UX, and non-supported agents are unchanged. https://claude.ai/code/session_01PNd94wGpSPm4bxcQsjWw3V
1 parent 0c93d63 commit b56fefe

4 files changed

Lines changed: 707 additions & 0 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# Design — Add Agent Response Rendering
2+
3+
## Why a parallel reader, not a replacement
4+
5+
The obvious alternative is to **replace** the pty + xterm view with a
6+
headless `claude -p --output-format stream-json` invocation, the way
7+
Warp does it. Two reasons we don't:
8+
9+
1. **Interactive flows live in the TUI.** Permission prompts ("Allow
10+
edit?"), inline diff confirms, mid-stream `/clear` / `/compact`
11+
commands, and the agent's own UI for tool selection all require a
12+
real terminal. Headless `-p` mode drops them on the floor.
13+
2. **Other agents have no equivalent.** Codex CLI, Cursor Agent, Aider,
14+
and arbitrary user-defined agents (`CustomAgentEditor.tsx`) all run
15+
as TUIs today. A redesign that pulled them out of xterm would touch
16+
every agent integration.
17+
18+
Tailing the structured session log that Claude Code (and others) already
19+
write to disk gives us the same data Warp uses, with **zero change to
20+
how the agent is invoked**. The panel is a second view onto the same
21+
session.
22+
23+
## Data source: Claude Code session JSONL
24+
25+
Claude Code writes one JSONL file per session at
26+
`~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`. Each line is one
27+
event:
28+
29+
- `{ "type": "user", "message": {...}, ... }` — user turn
30+
- `{ "type": "assistant", "message": { "content": [ ... ] }, ... }`
31+
assistant turn; `content` is the standard Anthropic block array
32+
(`{type:"text",text}`, `{type:"tool_use",id,name,input}`,
33+
`{type:"thinking",thinking}`)
34+
- `{ "type": "user", "message": { "content": [ { "type":
35+
"tool_result", "tool_use_id", "content" } ] } }` — tool result
36+
- `{ "type": "summary", ... }`, `{ "type": "system", ... }` — ignored
37+
by the panel
38+
39+
We **fix the session id at spawn time** by injecting
40+
`--session-id <uuid>` into the args we pass to `node-pty.spawn` for
41+
Claude agents. The exact path is then known upfront; no glob,
42+
no most-recently-modified guesswork.
43+
44+
## Adapter registry
45+
46+
```
47+
interface AgentResponseSource {
48+
kind: 'jsonl-file';
49+
// template string with {sessionId}, {encodedCwd}, {home} placeholders
50+
pathTemplate: string;
51+
// adapter id → which parser turns raw JSONL lines into AgentResponseEvent
52+
adapter: 'claude-code-v1';
53+
// CLI argv injection: how to force a stable session id we can tail
54+
sessionIdArg?: { flag: string; valueFromUuid: true };
55+
}
56+
```
57+
58+
Built-in defaults registered in `electron/ipc/agent-response.ts`:
59+
60+
- `claude`: `pathTemplate:
61+
'{home}/.claude/projects/{encodedCwd}/{sessionId}.jsonl'`, `adapter:
62+
'claude-code-v1'`, `sessionIdArg: { flag: '--session-id',
63+
valueFromUuid: true }`.
64+
65+
The `claude-code-v1` adapter also covers **Sourcegraph Amp** out of the
66+
box — Amp explicitly publishes a Claude-Code-compatible stream-json
67+
format. We expose it as a separate default `amp` entry referencing the
68+
same adapter id; differences are limited to where Amp writes its
69+
session log (resolved at first run by reading `AMP_SESSION_DIR` env or
70+
`$XDG_DATA_HOME/amp/sessions/`).
71+
72+
Custom agents in `CustomAgentEditor.tsx` may set the same fields; UI
73+
hidden behind an "Advanced" disclosure for v1.
74+
75+
Future adapters with confirmed protocols (`codex-v1` for `codex exec
76+
--json`'s `thread.*`/`turn.*`/`item.*` events; `cursor-agent-v1` for
77+
its `stream_event` / `tool_call` shapes; `opencode-v1` for its
78+
`Part`-array shape) plug in without changing the renderer. Gemini
79+
`stream-json`, Copilot, Aider, Crush, and goose have no stable JSON
80+
event stream as of 2026-05 and are deferred.
81+
82+
## Watcher lifecycle
83+
84+
One watcher per active task is started by the renderer when the user
85+
opens the response panel for that task (or on app boot if the panel was
86+
enabled at quit). The watcher is stopped when the panel is closed or
87+
the task is removed.
88+
89+
```
90+
StartAgentResponseWatcher { taskId, agentId, source, sessionId, cwd }
91+
→ backend opens fs.watch on the file path (or polls fs.stat at 250 ms
92+
if fs.watch is unavailable on the platform); reads forward-only
93+
from the last-known byte offset; parses each new complete line via
94+
the named adapter; pushes one AgentResponseEvent per line via
95+
`webContents.send(AgentResponseEvent, payload)`.
96+
97+
StopAgentResponseWatcher { taskId } → close the watcher.
98+
```
99+
100+
Crash semantics: the watcher process is the Electron main, same
101+
lifetime as the renderer. If the JSONL file rolls (new session id),
102+
the renderer issues a fresh Start with the new id; the backend reopens.
103+
104+
## Parser choice
105+
106+
Rather than hand-roll a JSONL parser for Claude Code, the backend
107+
adapter uses **`@anthropic-ai/claude-agent-sdk`** (latest published as
108+
of 2026-05). The SDK exports `SDKMessage` (a 25-member discriminated
109+
union covering `SDKAssistantMessage`, `SDKUserMessage`,
110+
`SDKResultMessage`, `SDKSystemMessage`, `SDKPartialAssistantMessage`,
111+
`SDKToolProgressMessage`, `SDKPermissionDeniedMessage`,
112+
`SDKCompactBoundaryMessage`, etc.) and helpers including
113+
`getSessionMessages(sessionId)` for one-shot reads. For the tail we
114+
still rely on `fs.watch` + line-buffer, but the *line → typed event*
115+
step uses the SDK's exported types and dedup logic to avoid drift when
116+
Claude Code ships protocol updates. Verbose-mode duplicate events
117+
(observed in `--output-format stream-json --verbose` and equally
118+
present in the session JSONL) are de-duped by `uuid`.
119+
120+
The lighter alternative is the community npm `claude-code-parser`
121+
(zero-dep TS, dedup built-in). We prefer the official SDK because it
122+
tracks new event types automatically; we will revisit if the SDK adds
123+
runtime weight we cannot afford in the Electron bundle.
124+
125+
The opencode `Part` model (`packages/opencode/src/session/message-v2.ts`)
126+
is the design influence for our normalised event shape below.
127+
128+
## Normalised event shape
129+
130+
```
131+
type AgentResponseEvent =
132+
| { type: 'message'; role: 'user' | 'assistant'; id: string;
133+
blocks: ResponseBlock[]; ts: number }
134+
| { type: 'tool_use'; id: string; name: string; input: unknown; ts: number }
135+
| { type: 'tool_result'; toolUseId: string; ok: boolean;
136+
content: ResponseBlock[]; ts: number }
137+
| { type: 'thinking'; id: string; text: string; ts: number }
138+
| { type: 'session_start'; sessionId: string; ts: number }
139+
| { type: 'session_end'; sessionId: string; ts: number };
140+
141+
type ResponseBlock =
142+
| { kind: 'text'; text: string }
143+
| { kind: 'image'; src: string; mime: string };
144+
```
145+
146+
Adapters convert their source format into this shape. Renderer code
147+
only sees this shape, so adding a new agent does not touch the
148+
component tree.
149+
150+
## Renderer surface
151+
152+
A new component `src/components/AgentResponsePanel.tsx`:
153+
154+
- Mounts under `TaskAITerminal.tsx` alongside `TerminalView`. Layout
155+
choice: a vertical split with the response panel on the right when
156+
enabled, controlled by a per-task signal `task.aiResponsePanelOpen`.
157+
Default closed.
158+
- Subscribes to `AgentResponseEvent` via the existing `Channel<T>`
159+
primitive (`src/lib/ipc.ts:21-45`).
160+
- Renders an ordered list of message bubbles. Each bubble:
161+
- **Assistant text block**`createHighlightedMarkdown` (existing
162+
signal from `src/lib/marked-shiki.ts`).
163+
- **Tool use** → collapsible row showing `name` + truncated `input`;
164+
expanded view shows full JSON.
165+
- **Tool result** → collapsible row attached to its `tool_use_id`;
166+
body rendered as markdown if string, as JSON tree if structured.
167+
- **Image block**`<img>` with safe `src` (see below).
168+
- **Thinking** → collapsed by default, italicised muted text when
169+
expanded.
170+
- Toolbar: copy-thread, jump-to-latest, expand-all, collapse-all.
171+
172+
Wiring point: `src/components/TaskAITerminal.tsx` already hosts the
173+
terminal toolbar; the panel toggle button goes there next to restart /
174+
close.
175+
176+
## Markdown extensions
177+
178+
`src/lib/marked-shiki.ts` today calls `DOMPurify.sanitize(raw, {
179+
ADD_ATTR: ['data-lang'] })` and does not allow `<img>`. We extend the
180+
config behind a new exported `renderMarkdownForAgentResponse` so the
181+
plan / notes viewers keep their stricter sanitiser:
182+
183+
- `ADD_ATTR: ['data-lang', 'src', 'alt', 'title']` for the agent-
184+
response renderer only.
185+
- A custom DOMPurify `uponSanitizeAttribute` hook on `<img src>` that
186+
accepts:
187+
- `data:image/(png|jpeg|gif|webp);base64,...`
188+
- `https://...`
189+
- `file://...` where the path resolves under the task `cwd` (passed
190+
as a runtime parameter; computed via `path.resolve` + prefix check
191+
in the renderer process via preload-exposed `path.isInside(cwd,
192+
target)`)
193+
- All other `src` schemes are stripped, leaving alt text visible.
194+
195+
Tables and mermaid already work (`PlanViewerDialog.tsx:104-122`
196+
mermaid post-process is lifted into a shared helper).
197+
198+
## Streaming and perf
199+
200+
The JSONL writer flushes line-at-a-time, so we get one event per line
201+
without partial-line risk if we buffer to `\n`. Each event triggers a
202+
single Solid signal update appending one message to a `createStore`-
203+
backed array. Shiki re-highlights only the new block, not the whole
204+
thread, because each block has its own `createHighlightedMarkdown`
205+
signal scoped to the block's text. This avoids the "re-highlight the
206+
whole transcript on every token" pitfall called out in the
207+
infrastructure audit.
208+
209+
The watcher pushes at most one event per JSONL line; no debouncing
210+
needed.
211+
212+
## What we are explicitly not doing in v1
213+
214+
- No headless `--output-format stream-json` mode. Pty stays primary.
215+
- No new input path. Users still type into xterm. A response-panel
216+
composer can come in a follow-up change once the read path is solid.
217+
- No support for agents other than Claude Code at GA. The adapter
218+
registry makes adding Codex / Cursor / Amp a localised follow-up.
219+
- No best-effort ANSI scraping fallback for Aider/Crush/Copilot. The
220+
panel simply says "Rich view not available for this agent."
221+
- No persistence of normalised events to our own state file. The
222+
underlying JSONL is the source of truth; on reload we re-tail from
223+
byte 0.
224+
- No agent-side write-back ("apply this diff button"). Tool-result
225+
blocks are read-only.
226+
227+
## References
228+
229+
- Claude Code CLI reference and stream-json schema (Anthropic docs).
230+
- `@anthropic-ai/claude-agent-sdk` TypeScript types (`SDKMessage`
231+
union, `query()`, `getSessionMessages`).
232+
- `claude-code-parser` (community npm; fallback parser option).
233+
- `sst/opencode` `packages/opencode/src/session/message-v2.ts` — Zod
234+
schemas for the `Part`-array model that informed our normalised
235+
shape.
236+
- `siteboon/claudecodeui` — web UI reference implementation consuming
237+
`--output-format stream-json --verbose`.
238+
- Codex `exec --json` event reference
239+
(`developers.openai.com/codex/noninteractive`,
240+
`openai/codex/docs/exec.md`); deferred adapter.
241+
- Cursor Agent `--output-format stream-json` reference
242+
(`docs.cursor.com/en/cli/reference/output-format`); deferred adapter.
243+
- Amp owner's manual (`ampcode.com/manual`); same parser as Claude.
244+
- Gemini CLI PR #8119 (stream-json mode, considered unstable);
245+
deferred.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Add Agent Response Rendering
2+
3+
## Why
4+
5+
Today, AI agent terminals in the app are pure `node-pty` + xterm.js
6+
streams. Agents like Claude Code, Codex, and Cursor draw a rich TUI with
7+
ANSI styling, cursor moves, and live redraws. That is great for
8+
interaction but bad for *reading*: markdown tables come through as
9+
pipe-and-dash ASCII, code fences are colorised but not line-numbered or
10+
copyable as blocks, mermaid fences stay as raw source, and image
11+
references render as `[image: /path.png]` literals. Once a task has run
12+
for an hour the scrollback is a wall of styled text that the user has to
13+
re-parse mentally.
14+
15+
Warp's recent release frames the same problem ("no more stray Markdown
16+
formatting to read") and solves it by routing the agent's response
17+
through a markdown renderer instead of the byte stream. We have most of
18+
the rendering plumbing already (`src/lib/marked-shiki.ts`, mermaid
19+
client-side rendering in `PlanViewerDialog.tsx`), but no structured
20+
input to feed it. This change adds that input and a panel to display
21+
it, opt-in per task.
22+
23+
## What changes
24+
25+
- A new per-task **AI Response panel** rendered alongside the existing
26+
agent terminal, toggled from the terminal toolbar. Default off.
27+
- For agents that publish a structured session log (initial support:
28+
**Claude Code**, and **Sourcegraph Amp** which uses the same
29+
stream-json format), the main process tails that log file and
30+
forwards parsed events to the renderer.
31+
- The panel renders assistant text as markdown (tables, syntax-
32+
highlighted code via Shiki, mermaid diagrams, sanitised inline
33+
images), and renders tool calls / tool results as collapsible blocks.
34+
- Agent definitions gain an optional `responseSource` descriptor
35+
declaring how to discover the structured log. Built-in defaults ship
36+
for `claude`; custom agents can opt in via `CustomAgentEditor`.
37+
- The existing pty + xterm flow is unchanged. The panel is a *parallel
38+
reader*; input still goes through the TUI. Agents without a known
39+
`responseSource` show a one-line "Rich view not available for this
40+
agent" notice in the panel.
41+
42+
## Impact
43+
44+
- New capability `agent-response-rendering`.
45+
- New IPC channels `StartAgentResponseWatcher`,
46+
`StopAgentResponseWatcher`, `AgentResponseEvent` in
47+
`electron/ipc/channels.ts`.
48+
- New shared types in `src/ipc/types.ts`: `AgentResponseSource`,
49+
`AgentResponseEvent`, `AgentResponseEventPayload`.
50+
- New backend module `electron/ipc/agent-response.ts` (JSONL tailer +
51+
adapter registry). New npm dependency
52+
`@anthropic-ai/claude-agent-sdk` (used only as the typed parser for
53+
the `claude-code-v1` adapter, not invoked as a runtime).
54+
- New frontend module `src/components/AgentResponsePanel.tsx` and a
55+
small renderer store slice `src/store/agentResponse.ts`.
56+
- Additions to `AgentDef` (`responseSource?: AgentResponseSource`) and
57+
to `PersistedState` (`agentResponsePanelEnabled?: Record<TaskId,
58+
boolean>`).
59+
- Extension of `src/lib/marked-shiki.ts` DOMPurify config to allow
60+
`<img>` with a strict `src` allowlist (`file://` under the task cwd,
61+
`https://`, `data:image/{png,jpeg,gif,webp}`).
62+
- No change to the existing `SpawnAgent` / `WriteToAgent` IPC contract.
63+
No change to xterm rendering or scrollback. No change to persistence
64+
of pty buffers. Non-Claude agents keep working exactly as today.

0 commit comments

Comments
 (0)