18 KiB
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):
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:
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
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 <id8> · <model>] when the CLI announces the session at startup.
- Step 1: Replace the
FormatSystemmethod
Find:
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:
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
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
FormatAssistantmethod
Find:
private static string? FormatAssistant(JsonElement root) => null;
Replace with:
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
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_usecase body
Find:
case "tool_use":
// Filled in by a later task.
break;
Replace with:
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:
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
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 → <summary> lines per the spec rules.
- Step 1: Replace the
FormatUsermethod
Find:
private static string? FormatUser(JsonElement root) => null;
Replace with:
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
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 <id>…]line at the top -
Plain prose lines for assistant text
-
[Read] <file>,[Bash] $ …,[Write] <file>etc. for tool calls -
→ <N> 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/<task>.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.contentsometimes absent → already guarded byTryGetContentArray- Unknown tool name → should render
[<name>]with no arg tool_result.contentarray form → covered byResolveContentText
No further commit unless a fix was needed.
Post-Implementation Self-Review
After the tasks above are done, verify:
- 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). - No
TODO/TBD/ commented-out stubs remain inStreamLineFormatter.cs. - Tool labels match the spec table exactly (
[Read],[Bash] $ …,[Task: <sub>] <desc>, etc.). - Public API surface (
FormatLine,FormatFile,Trim,MaxLengthbehavior) is unchanged. - No edits outside
StreamLineFormatter.cs(per the spec's non-goals).