337 lines
10 KiB
C#
337 lines
10 KiB
C#
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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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":
|
|
sb.Append(FormatToolUse(block));
|
|
sb.Append('\n');
|
|
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;
|
|
}
|
|
|
|
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 "";
|
|
}
|
|
|
|
private static string? FormatResult(JsonElement root)
|
|
{
|
|
if (root.TryGetProperty("result", out var resultProp))
|
|
return $"\n--- Result ---\n{resultProp.GetString()}\n";
|
|
return null;
|
|
}
|
|
|
|
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 Truncate(GetStr(input, "url"), MaxArgChars);
|
|
|
|
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] + "…";
|
|
}
|
|
|
|
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..];
|
|
}
|
|
}
|