# Stream Formatter Rewrite — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Rewrite `StreamLineFormatter` so Claude CLI stream-json messages (system/init, assistant text, assistant tool_use, user tool_result, result) render as compact readable lines in the Details pane. **Architecture:** Single-file rewrite of `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`. Public API (`FormatLine(string)` / `FormatFile(string)` / `Trim`) and constants unchanged. Internal dispatch switches on top-level `type`; per-type helpers return one or more `\n`-terminated display lines, concatenated into the return string. **Tech Stack:** C# 12, .NET 8, `System.Text.Json` (already in use). **Spec:** `docs/superpowers/specs/2026-04-21-stream-formatter-rewrite-design.md` **Testing:** Skipped per user decision; verification is a manual build after each task and a final end-to-end run of a real task. **Build command (repo uses csproj builds, not slnx, on .NET 8):** ```bash dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj ``` --- ## File Structure - **Modify:** `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` — complete rewrite of parsing logic; keeps public class surface. No other files change. `DetailsIslandViewModel` and the Worker pipeline are unaffected. --- ## Task 1: Replace the dispatch skeleton **Files:** - Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` Swap the old top-level `switch` for one that names every supported message type. Every branch returns `null` for now except `result` and `api_retry`, which keep their existing behavior. This gives us a clean compile before we fill in each branch. - [ ] **Step 1: Overwrite the file with the new skeleton** Replace the entire contents of `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` with: ```csharp using System.Text; using System.Text.Json; namespace ClaudeDo.Ui.Helpers; public class StreamLineFormatter { private const int MaxLength = 50_000; private const int MaxArgChars = 120; public string? FormatLine(string line) { JsonDocument doc; try { doc = JsonDocument.Parse(line); } catch (JsonException) { return line; } using (doc) { var root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object) return null; if (!root.TryGetProperty("type", out var typeProp)) return null; return typeProp.GetString() switch { "system" => FormatSystem(root), "assistant" => FormatAssistant(root), "user" => FormatUser(root), "result" => FormatResult(root), _ => null, }; } } private static string? FormatSystem(JsonElement root) { if (!root.TryGetProperty("subtype", out var subtypeProp)) return null; return subtypeProp.GetString() switch { "api_retry" => "[Retrying API call...]\n", _ => null, }; } private static string? FormatAssistant(JsonElement root) => null; private static string? FormatUser(JsonElement root) => null; private static string? FormatResult(JsonElement root) { if (root.TryGetProperty("result", out var resultProp)) return $"\n--- Result ---\n{resultProp.GetString()}\n"; return null; } public string FormatFile(string filePath) { var sb = new StringBuilder(); foreach (var line in File.ReadLines(filePath)) { var formatted = FormatLine(line); if (formatted is not null) sb.Append(formatted); } return Trim(sb.ToString()); } public static string Trim(string text) { if (text.Length <= MaxLength) return text; var trimStart = text.Length - MaxLength; var newlineAfter = text.IndexOf('\n', trimStart); if (newlineAfter >= 0 && newlineAfter < trimStart + 200) trimStart = newlineAfter + 1; return text[trimStart..]; } } ``` - [ ] **Step 2: Build** Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` Expected: build succeeds, 0 errors. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs git commit -m "refactor(ui): skeleton dispatch for StreamLineFormatter rewrite" ``` --- ## Task 2: Add system/init formatting **Files:** - Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` Emit `[session · ]` when the CLI announces the session at startup. - [ ] **Step 1: Replace the `FormatSystem` method** Find: ```csharp private static string? FormatSystem(JsonElement root) { if (!root.TryGetProperty("subtype", out var subtypeProp)) return null; return subtypeProp.GetString() switch { "api_retry" => "[Retrying API call...]\n", _ => null, }; } ``` Replace with: ```csharp private static string? FormatSystem(JsonElement root) { if (!root.TryGetProperty("subtype", out var subtypeProp)) return null; var subtype = subtypeProp.GetString(); switch (subtype) { case "api_retry": return "[Retrying API call...]\n"; case "init": { var sessionId = root.TryGetProperty("session_id", out var sid) ? sid.GetString() : null; var model = root.TryGetProperty("model", out var m) ? m.GetString() : null; var shortId = sessionId is { Length: >= 8 } ? sessionId[..8] : sessionId ?? "?"; var modelPart = string.IsNullOrEmpty(model) ? "" : $" · {model}"; return $"[session {shortId}{modelPart}]\n"; } default: return null; } } ``` - [ ] **Step 2: Build** Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` Expected: build succeeds, 0 errors. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs git commit -m "feat(ui): format system init message in StreamLineFormatter" ``` --- ## Task 3: Add assistant text + thinking filter **Files:** - Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` Iterate `message.content[]`. Emit each `text` block verbatim with a trailing `\n`; skip `thinking`. Leave `tool_use` for the next task (still returns nothing for now). - [ ] **Step 1: Replace the `FormatAssistant` method** Find: ```csharp private static string? FormatAssistant(JsonElement root) => null; ``` Replace with: ```csharp private static string? FormatAssistant(JsonElement root) { if (!TryGetContentArray(root, out var content)) return null; var sb = new StringBuilder(); foreach (var block in content.EnumerateArray()) { if (block.ValueKind != JsonValueKind.Object) continue; if (!block.TryGetProperty("type", out var blockTypeProp)) continue; switch (blockTypeProp.GetString()) { case "text": if (block.TryGetProperty("text", out var textProp)) { var text = textProp.GetString(); if (!string.IsNullOrEmpty(text)) { sb.Append(text); if (!text.EndsWith('\n')) sb.Append('\n'); } } break; case "tool_use": // Filled in by a later task. break; case "thinking": default: // Filtered. break; } } return sb.Length == 0 ? null : sb.ToString(); } private static bool TryGetContentArray(JsonElement root, out JsonElement content) { content = default; if (!root.TryGetProperty("message", out var message)) return false; if (message.ValueKind != JsonValueKind.Object) return false; if (!message.TryGetProperty("content", out var c)) return false; if (c.ValueKind != JsonValueKind.Array) return false; content = c; return true; } ``` - [ ] **Step 2: Build** Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` Expected: build succeeds, 0 errors. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs git commit -m "feat(ui): render assistant text blocks, skip thinking" ``` --- ## Task 4: Add tool_use block formatting **Files:** - Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` Fill in the `tool_use` case inside `FormatAssistant`. Per-tool label/arg logic lives in a dedicated helper. - [ ] **Step 1: Replace the `tool_use` case body** Find: ```csharp case "tool_use": // Filled in by a later task. break; ``` Replace with: ```csharp case "tool_use": sb.Append(FormatToolUse(block)); sb.Append('\n'); break; ``` - [ ] **Step 2: Add helper methods at the end of the class (before `FormatFile`)** Insert just above the `public string FormatFile(string filePath)` method: ```csharp private static string FormatToolUse(JsonElement block) { var name = block.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "?" : "?"; JsonElement input = default; var hasInput = block.TryGetProperty("input", out input) && input.ValueKind == JsonValueKind.Object; var label = name; if (hasInput && (name == "Task" || name == "Agent")) { var sub = GetStr(input, "subagent_type"); if (!string.IsNullOrEmpty(sub)) label = $"{name}: {sub}"; } string? arg = hasInput ? BuildToolArg(name, input) : null; return string.IsNullOrEmpty(arg) ? $"[{label}]" : $"[{label}] {arg}"; } private static string? BuildToolArg(string toolName, JsonElement input) { switch (toolName) { case "Read": case "Write": case "Edit": case "NotebookEdit": return Basename(GetStr(input, "file_path")); case "Bash": case "PowerShell": { var cmd = GetStr(input, "command"); return string.IsNullOrEmpty(cmd) ? null : "$ " + Truncate(cmd, MaxArgChars); } case "Grep": { var p = GetStr(input, "pattern"); return string.IsNullOrEmpty(p) ? null : $"\"{Truncate(p, MaxArgChars)}\""; } case "Glob": return Truncate(GetStr(input, "pattern"), MaxArgChars); case "Task": case "Agent": return Truncate(GetStr(input, "description"), MaxArgChars); case "WebFetch": return GetStr(input, "url"); case "WebSearch": { var q = GetStr(input, "query"); return string.IsNullOrEmpty(q) ? null : $"\"{Truncate(q, MaxArgChars)}\""; } case "TodoWrite": return null; default: return null; } } private static string? GetStr(JsonElement obj, string name) => obj.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; private static string Basename(string? path) { if (string.IsNullOrEmpty(path)) return ""; var i = path.LastIndexOfAny(new[] { '/', '\\' }); return i < 0 ? path : path[(i + 1)..]; } private static string Truncate(string? s, int max) { if (string.IsNullOrEmpty(s)) return ""; return s.Length <= max ? s : s[..max] + "…"; } ``` - [ ] **Step 3: Build** Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` Expected: build succeeds, 0 errors. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs git commit -m "feat(ui): render assistant tool_use blocks with per-tool args" ``` --- ## Task 5: Add user tool_result formatting **Files:** - Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` Iterate `message.content[]` for `tool_result` blocks and emit `→ ` lines per the spec rules. - [ ] **Step 1: Replace the `FormatUser` method** Find: ```csharp private static string? FormatUser(JsonElement root) => null; ``` Replace with: ```csharp private static string? FormatUser(JsonElement root) { if (!TryGetContentArray(root, out var content)) return null; var sb = new StringBuilder(); foreach (var block in content.EnumerateArray()) { if (block.ValueKind != JsonValueKind.Object) continue; if (!block.TryGetProperty("type", out var blockTypeProp)) continue; if (blockTypeProp.GetString() != "tool_result") continue; var summary = BuildToolResultSummary(root, block); if (!string.IsNullOrEmpty(summary)) { sb.Append("→ "); sb.Append(summary); sb.Append('\n'); } } return sb.Length == 0 ? null : sb.ToString(); } private static string BuildToolResultSummary(JsonElement root, JsonElement block) { var isError = block.TryGetProperty("is_error", out var errProp) && errProp.ValueKind == JsonValueKind.True; var contentText = ResolveContentText(block); if (isError) { var msg = FirstNonEmptyLine(contentText); return string.IsNullOrEmpty(msg) ? "error" : $"error: {Truncate(msg, MaxArgChars)}"; } // tool_use_result.file.numLines shortcut for Read-style results if (root.TryGetProperty("tool_use_result", out var tur) && tur.ValueKind == JsonValueKind.Object && tur.TryGetProperty("file", out var file) && file.ValueKind == JsonValueKind.Object && file.TryGetProperty("numLines", out var nl) && nl.ValueKind == JsonValueKind.Number && nl.TryGetInt32(out var lines)) { return $"{lines} lines"; } if (string.IsNullOrWhiteSpace(contentText)) return "ok"; var first = FirstNonEmptyLine(contentText); return Truncate(first, MaxArgChars); } private static string ResolveContentText(JsonElement block) { if (!block.TryGetProperty("content", out var c)) return ""; if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? ""; if (c.ValueKind == JsonValueKind.Array) { var sb = new StringBuilder(); foreach (var part in c.EnumerateArray()) { if (part.ValueKind != JsonValueKind.Object) continue; if (!part.TryGetProperty("type", out var pt)) continue; if (pt.GetString() != "text") continue; if (part.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String) { if (sb.Length > 0) sb.Append('\n'); sb.Append(t.GetString()); } } return sb.ToString(); } return ""; } private static string FirstNonEmptyLine(string s) { if (string.IsNullOrEmpty(s)) return ""; foreach (var raw in s.Split('\n')) { var line = raw.TrimEnd('\r').Trim(); if (line.Length > 0) return line; } return ""; } ``` - [ ] **Step 2: Build** Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` Expected: build succeeds, 0 errors. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs git commit -m "feat(ui): render user tool_result blocks as one-line summaries" ``` --- ## Task 6: Manual end-to-end verification **Files:** none (verification only). - [ ] **Step 1: Build everything the app needs** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` and `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: both succeed, 0 errors. - [ ] **Step 2: Start the Worker in one terminal** Run: `dotnet run --project src/ClaudeDo.Worker` Expected: SignalR hub bound to `127.0.0.1:47821`, no crash. - [ ] **Step 3: Start the App in another terminal** Run: `dotnet run --project src/ClaudeDo.App` Expected: UI opens, status bar shows online. - [ ] **Step 4: Run any task tagged "agent" (e.g. "create a README")** In the Details pane, verify the log shows: - A `[session …]` line at the top - Plain prose lines for assistant text - `[Read] `, `[Bash] $ …`, `[Write] ` etc. for tool calls - `→ lines` / `→ ok` / `→ error: …` lines after each tool call - A final `--- Result ---` block - **No raw JSON anywhere** - [ ] **Step 5: Spot-check the raw log file** Open `~/.todo-app/logs/.log` (or equivalent) and confirm the full JSON is still there for debugging — the formatter must not have altered persisted logs. - [ ] **Step 6: If any issues surface, fix inline and re-verify** Common gotchas to check for if you see blank lines or missing output: - `message.content` sometimes absent → already guarded by `TryGetContentArray` - Unknown tool name → should render `[]` with no arg - `tool_result.content` array form → covered by `ResolveContentText` No further commit unless a fix was needed. --- ## Post-Implementation Self-Review After the tasks above are done, verify: 1. Every message type listed in the spec's "Output format" table is implemented (`system/init`, `system/api_retry`, `system/other`, `assistant text`, `assistant tool_use`, `assistant thinking`, `user tool_result`, `result`, parse failure). 2. No `TODO` / `TBD` / commented-out stubs remain in `StreamLineFormatter.cs`. 3. Tool labels match the spec table exactly (`[Read]`, `[Bash] $ …`, `[Task: ] `, etc.). 4. Public API surface (`FormatLine`, `FormatFile`, `Trim`, `MaxLength` behavior) is unchanged. 5. No edits outside `StreamLineFormatter.cs` (per the spec's non-goals).