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:
Mika Kuns
2026-04-14 16:22:36 +02:00
parent aaaa93323c
commit 365ecba990
2 changed files with 253 additions and 0 deletions

View 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..];
}
}