Files
ClaudeDo/docs/superpowers/plans/2026-04-21-stream-formatter-rewrite.md

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 FormatSystem method

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 FormatAssistant method

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_use case 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 FormatUser method

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.content sometimes absent → already guarded by TryGetContentArray
  • Unknown tool name → should render [<name>] 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: <sub>] <desc>, etc.).
  4. Public API surface (FormatLine, FormatFile, Trim, MaxLength behavior) is unchanged.
  5. No edits outside StreamLineFormatter.cs (per the spec's non-goals).