142 lines
5.2 KiB
Markdown
142 lines
5.2 KiB
Markdown
# Stream Formatter Rewrite — Design
|
||
|
||
**Date:** 2026-04-21
|
||
**Scope:** `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||
|
||
## Problem
|
||
|
||
`StreamLineFormatter` converts Claude CLI stream-json lines into human-readable
|
||
text for the Details pane. The current implementation only recognizes:
|
||
|
||
- `type=stream_event` — dead code (requires `--include-partial-messages`, which
|
||
the Worker does not pass)
|
||
- `type=result` — shown as `--- Result ---` block
|
||
- `type=system` with `subtype=api_retry`
|
||
|
||
Everything else — notably `assistant` and `user` messages that carry the actual
|
||
conversation and tool activity — falls through to `default: return null` and is
|
||
silently dropped. The Details pane is therefore mostly empty during a run,
|
||
while the raw `.log` file retains the full JSON.
|
||
|
||
## Goal
|
||
|
||
Rewrite the formatter so every meaningful message type is rendered as one or
|
||
more compact text lines suitable for the live log in the Details pane. The
|
||
public API (`FormatLine(string)` / `FormatFile(string)`) and the existing
|
||
buffer/trim behavior in `DetailsIslandViewModel` stay the same.
|
||
|
||
## Input format
|
||
|
||
The Worker invokes the Claude CLI with:
|
||
|
||
```
|
||
claude -p --output-format stream-json --verbose --dangerously-skip-permissions ...
|
||
```
|
||
|
||
Each stdout line is one complete SDK message. Top-level shapes relevant to the
|
||
formatter:
|
||
|
||
```jsonc
|
||
// Session start
|
||
{"type":"system","subtype":"init","session_id":"…","model":"claude-…", …}
|
||
|
||
// API retry notification
|
||
{"type":"system","subtype":"api_retry", …}
|
||
|
||
// Assistant reply (text + tool calls)
|
||
{"type":"assistant","message":{"role":"assistant","content":[
|
||
{"type":"text","text":"…"},
|
||
{"type":"tool_use","id":"toolu_…","name":"Read","input":{"file_path":"…"}}
|
||
]}, …}
|
||
|
||
// Tool result fed back to the model
|
||
{"type":"user","message":{"role":"user","content":[
|
||
{"tool_use_id":"toolu_…","type":"tool_result","content":"… or [ {type,text} ] …","is_error":false}
|
||
]}, "tool_use_result":{…optional rich payload…}, …}
|
||
|
||
// Final result
|
||
{"type":"result","result":"…", …}
|
||
```
|
||
|
||
Notes on quirks already observed in captured output:
|
||
|
||
- `tool_result.content` is sometimes a plain string, sometimes an array of
|
||
`{type:"text", text:"…"}` blocks. Handle both.
|
||
- The envelope may include `tool_use_result.file.numLines` / `file.filePath`
|
||
for Read-style results.
|
||
- Assistant messages may contain `thinking` blocks (filtered, not displayed).
|
||
|
||
## Output format
|
||
|
||
One line per logical event. A trailing `\n` ends each line so the
|
||
`DetailsIslandViewModel` buffer splits cleanly.
|
||
|
||
| Input | Output |
|
||
|---|---|
|
||
| `system` / `init` | `[session <id8> · <model>]\n` |
|
||
| `system` / `api_retry` | `[Retrying API call...]\n` |
|
||
| `system` / other | `null` (filtered) |
|
||
| `assistant` text block | `<text>\n` (raw) |
|
||
| `assistant` tool_use block | `[<ToolLabel>] <arg>\n` (see below) |
|
||
| `assistant` thinking block | `null` (filtered) |
|
||
| `user` tool_result block | `→ <summary>\n` (see below) |
|
||
| `result` | `\n--- Result ---\n<text>\n` |
|
||
| unrecognized / parse failure | raw line (existing behavior for non-JSON) |
|
||
|
||
A single `assistant` message with N content blocks produces N output lines,
|
||
concatenated into one return string.
|
||
|
||
### Tool label + arg
|
||
|
||
Pick the most identifying input field per tool:
|
||
|
||
| Tool name | Display |
|
||
|---|---|
|
||
| `Read`, `Write`, `Edit`, `NotebookEdit` | `[<Tool>] <basename(file_path)>` |
|
||
| `Bash`, `PowerShell` | `[Bash] $ <command>` — truncate command at 120 chars, append `…` |
|
||
| `Grep` | `[Grep] "<pattern>"` |
|
||
| `Glob` | `[Glob] <pattern>` |
|
||
| `Task`, `Agent` | `[Task: <subagent_type>] <description>` (description truncated to 120) |
|
||
| `WebFetch` | `[WebFetch] <url>` |
|
||
| `WebSearch` | `[WebSearch] "<query>"` |
|
||
| `TodoWrite` | `[TodoWrite]` (no arg) |
|
||
| fallback | `[<name>]` |
|
||
|
||
Missing or empty input fields → emit the label only, no trailing text.
|
||
|
||
### tool_result summary
|
||
|
||
For each `tool_result` block in a `user` message, in priority order:
|
||
|
||
1. `is_error == true` → `→ error: <first non-empty line, trimmed, ≤120 chars>`
|
||
2. Envelope has `tool_use_result.file.numLines` → `→ <N> lines`
|
||
3. Content resolves to empty/whitespace string → `→ ok`
|
||
4. Otherwise → `→ <first non-empty line, ≤120 chars>` (append `…` if truncated)
|
||
|
||
Content resolution: if `content` is a string, use it; if it's an array, join
|
||
the `text` fields of `{type:"text"}` entries.
|
||
|
||
## Non-goals
|
||
|
||
- No changes to `DetailsIslandViewModel` or the Worker pipeline.
|
||
- No collapsible/rich rendering — tool results stay one-liners.
|
||
- No persistence changes — the raw `.log` file still contains full JSON for
|
||
debugging.
|
||
- No unit tests in this change (separate workload).
|
||
|
||
## Out of scope
|
||
|
||
- Partial-token streaming (`--include-partial-messages`). The existing
|
||
`stream_event` branch is removed as dead code.
|
||
- Structured output / `--json-schema` rendering beyond the final `result`.
|
||
|
||
## Risks / edge cases
|
||
|
||
- **Unknown tool names** — fallback label `[<name>]` keeps output readable.
|
||
- **Malformed JSON inside a valid envelope** (e.g. missing `message.content`)
|
||
— skip the broken block, emit what we can; never throw.
|
||
- **Very long Bash commands or search queries** — 120-char truncation with `…`
|
||
keeps lines reasonable while preserving the prefix.
|
||
- **Binary or huge tool_result content** — summary rules 2–4 cap output at a
|
||
single line; full content stays in the raw log.
|