|
| 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. |
0 commit comments