From 365ecba9908531ed61a2a7c524a4f5a4a3c5e48d Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 14 Apr 2026 16:22:36 +0200 Subject: [PATCH] feat(ui): add StreamLineFormatter for NDJSON stream parsing Parses Claude CLI stream-json output into human-readable text. Handles text deltas, tool use, results, API retries, and trims large files. All 13 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../Helpers/StreamLineFormatter.cs | 115 +++++++++++++++ .../Helpers/StreamLineFormatterTests.cs | 138 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs create mode 100644 tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs diff --git a/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs b/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs new file mode 100644 index 0000000..267e8c3 --- /dev/null +++ b/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs @@ -0,0 +1,115 @@ +using System.Text; +using System.Text.Json; + +namespace ClaudeDo.Ui.Helpers; + +public class StreamLineFormatter +{ + private const int MaxLength = 50_000; + + public string? FormatLine(string line) + { + JsonDocument doc; + try + { + doc = JsonDocument.Parse(line); + } + catch (JsonException) + { + return line; + } + + using (doc) + { + var root = doc.RootElement; + if (!root.TryGetProperty("type", out var typeProp)) + return null; + + var type = typeProp.GetString(); + + switch (type) + { + case "stream_event": + return FormatStreamEvent(root); + + case "result": + if (root.TryGetProperty("result", out var resultProp)) + return $"\n--- Result ---\n{resultProp.GetString()}\n"; + return null; + + case "system": + if (root.TryGetProperty("subtype", out var subtypeProp) && + subtypeProp.GetString() == "api_retry") + return "\n[Retrying API call...]\n"; + return null; + + default: + return null; + } + } + } + + private static string? FormatStreamEvent(JsonElement root) + { + if (!root.TryGetProperty("event", out var ev)) + return null; + if (!ev.TryGetProperty("type", out var evTypeProp)) + return null; + + var evType = evTypeProp.GetString(); + + switch (evType) + { + case "content_block_delta": + if (!ev.TryGetProperty("delta", out var delta)) + return null; + if (!delta.TryGetProperty("type", out var deltaTypeProp)) + return null; + var deltaType = deltaTypeProp.GetString(); + if (deltaType == "text_delta") + { + return delta.TryGetProperty("text", out var textProp) + ? textProp.GetString() + : null; + } + return null; // input_json_delta and others → skip + + case "content_block_stop": + return "\n"; + + case "content_block_start": + if (!ev.TryGetProperty("content_block", out var cb)) + return null; + if (cb.TryGetProperty("type", out var cbTypeProp) && + cbTypeProp.GetString() == "tool_use" && + cb.TryGetProperty("name", out var nameProp)) + return $"\n[Tool: {nameProp.GetString()}]\n"; + return null; + + default: + return null; // message_start, message_delta, etc. + } + } + + 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..]; + } +} diff --git a/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs b/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs new file mode 100644 index 0000000..fdcb601 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs @@ -0,0 +1,138 @@ +using ClaudeDo.Ui.Helpers; + +namespace ClaudeDo.Ui.Tests.Helpers; + +public class StreamLineFormatterTests +{ + private readonly StreamLineFormatter _formatter = new(); + + // --- Text deltas --- + + [Fact] + public void FormatLine_TextDelta_ReturnsTextContent() + { + var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello world"}}}"""; + Assert.Equal("Hello world", _formatter.FormatLine(line)); + } + + [Fact] + public void FormatLine_ConsecutiveTextDeltas_ReturnEachDelta() + { + var line1 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello "}}}"""; + var line2 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}"""; + Assert.Equal("Hello ", _formatter.FormatLine(line1)); + Assert.Equal("world", _formatter.FormatLine(line2)); + } + + [Fact] + public void FormatLine_ContentBlockStop_ReturnsNewline() + { + var line = """{"type":"stream_event","event":{"type":"content_block_stop","index":0}}"""; + Assert.Equal("\n", _formatter.FormatLine(line)); + } + + // --- Tool use, result, system, fallback --- + + [Fact] + public void FormatLine_ToolUseStart_ReturnsToolNameLine() + { + var line = """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"x","name":"bash"}}}"""; + Assert.Equal("\n[Tool: bash]\n", _formatter.FormatLine(line)); + } + + [Fact] + public void FormatLine_InputJsonDelta_ReturnsNull() + { + var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":"}}}"""; + Assert.Null(_formatter.FormatLine(line)); + } + + [Fact] + public void FormatLine_Result_ReturnsFormattedResult() + { + var line = """{"type":"result","result":"Done."}"""; + Assert.Equal("\n--- Result ---\nDone.\n", _formatter.FormatLine(line)); + } + + [Fact] + public void FormatLine_ApiRetry_ReturnsRetryNotice() + { + var line = """{"type":"system","subtype":"api_retry"}"""; + Assert.Equal("\n[Retrying API call...]\n", _formatter.FormatLine(line)); + } + + [Fact] + public void FormatLine_SystemNonRetry_ReturnsNull() + { + var line = """{"type":"system","subtype":"init"}"""; + Assert.Null(_formatter.FormatLine(line)); + } + + [Fact] + public void FormatLine_AssistantType_ReturnsNull() + { + var line = """{"type":"assistant","message":{}}"""; + Assert.Null(_formatter.FormatLine(line)); + } + + [Fact] + public void FormatLine_MalformedJson_ReturnsRawLine() + { + var line = "not json at all"; + Assert.Equal("not json at all", _formatter.FormatLine(line)); + } + + [Fact] + public void FormatLine_MessageStartAndDelta_ReturnsNull() + { + var start = """{"type":"stream_event","event":{"type":"message_start","message":{}}}"""; + var delta = """{"type":"stream_event","event":{"type":"message_delta","delta":{}}}"""; + Assert.Null(_formatter.FormatLine(start)); + Assert.Null(_formatter.FormatLine(delta)); + } + + // --- FormatFile and Trim --- + + [Fact] + public void FormatFile_ParsesAllLinesAndReturnsFormattedText() + { + var lines = new[] + { + """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}""", + """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"x","name":"bash"}}}""", + """{"type":"result","result":"Done."}""", + }; + var file = Path.GetTempFileName(); + try + { + File.WriteAllLines(file, lines); + var result = _formatter.FormatFile(file); + Assert.Contains("Hello", result); + Assert.Contains("[Tool: bash]", result); + Assert.Contains("Done.", result); + } + finally + { + File.Delete(file); + } + } + + [Fact] + public void FormatFile_TrimsLargeContent() + { + var chunk = new string('x', 1000); + var line = "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"" + chunk + "\"}}}"; + var lines = Enumerable.Repeat(line, 65).ToArray(); + var file = Path.GetTempFileName(); + try + { + File.WriteAllLines(file, lines); + var result = _formatter.FormatFile(file); + Assert.True(result.Length <= 50_200, $"Expected <= 50200 but got {result.Length}"); + } + finally + { + File.Delete(file); + } + } +}