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 <noreply@anthropic.com>
This commit is contained in:
115
src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
Normal file
115
src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
Normal file
@@ -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..];
|
||||||
|
}
|
||||||
|
}
|
||||||
138
tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
Normal file
138
tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user