5.2 KiB
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 ---blocktype=systemwithsubtype=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:
// 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.contentis sometimes a plain string, sometimes an array of{type:"text", text:"…"}blocks. Handle both.- The envelope may include
tool_use_result.file.numLines/file.filePathfor Read-style results. - Assistant messages may contain
thinkingblocks (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:
is_error == true→→ error: <first non-empty line, trimmed, ≤120 chars>- Envelope has
tool_use_result.file.numLines→→ <N> lines - Content resolves to empty/whitespace string →
→ ok - 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
DetailsIslandViewModelor the Worker pipeline. - No collapsible/rich rendering — tool results stay one-liners.
- No persistence changes — the raw
.logfile still contains full JSON for debugging. - No unit tests in this change (separate workload).
Out of scope
- Partial-token streaming (
--include-partial-messages). The existingstream_eventbranch is removed as dead code. - Structured output /
--json-schemarendering beyond the finalresult.
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.