From 9bb0e67a971f8de4aae1d6c46327704803a8c598 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 10:49:52 +0200 Subject: [PATCH] feat(ui): colour agent output by kind and stop cutting tool calls at 120 chars StreamLineFormatter now returns kind-tagged entries (text, tool, edit, tool result, error, sys, result) instead of a flat string, so the existing LogKind colours finally apply to stream-json output. Tool argument/result limit raised from 120 to 500 chars. --- .../Converters/LogKindForegroundConverter.cs | 20 +++-- .../Helpers/StreamLineFormatter.cs | 64 ++++++------- .../ViewModels/Islands/LogLineViewModel.cs | 54 +++++++---- .../ViewModels/Islands/PrepPanelViewModel.cs | 24 +---- .../Islands/TaskMonitorViewModel.cs | 33 +------ .../Helpers/StreamLineFormatterTests.cs | 90 ++++++++++++++----- 6 files changed, 149 insertions(+), 136 deletions(-) diff --git a/src/ClaudeDo.Ui/Converters/LogKindForegroundConverter.cs b/src/ClaudeDo.Ui/Converters/LogKindForegroundConverter.cs index 35461002..8438beef 100644 --- a/src/ClaudeDo.Ui/Converters/LogKindForegroundConverter.cs +++ b/src/ClaudeDo.Ui/Converters/LogKindForegroundConverter.cs @@ -24,15 +24,17 @@ public sealed class LogKindForegroundConverter : IValueConverter { var key = value is LogKind kind ? kind switch { - LogKind.Sys => "TextMuteBrush", - LogKind.Tool => "SageBrush", - LogKind.Claude => "TextBrush", - LogKind.Stdout => "TextDimBrush", - LogKind.Stderr => "BloodBrush", - LogKind.Done => "MossBrightBrush", - LogKind.Msg => "TextDimBrush", - LogKind.User => "AccentBrush", - _ => "TextDimBrush", + LogKind.Sys => "TextMuteBrush", + LogKind.Tool => "SageBrush", + LogKind.Claude => "TextBrush", + LogKind.Stdout => "TextDimBrush", + LogKind.Stderr => "BloodBrush", + LogKind.Done => "MossBrightBrush", + LogKind.Msg => "TextDimBrush", + LogKind.User => "AccentBrush", + LogKind.Edit => "AmberBrush", + LogKind.ToolResult => "TextMuteBrush", + _ => "TextDimBrush", } : "TextDimBrush"; return Resolve(key) ?? AvaloniaProperty.UnsetValue; diff --git a/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs b/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs index 2a21f8de..7d893819 100644 --- a/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs +++ b/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs @@ -1,14 +1,17 @@ using System.Text; using System.Text.Json; +using ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.Helpers; +/// Turns Claude CLI stream-json lines into kind-tagged log entries for the terminal views. public class StreamLineFormatter { private const int MaxLength = 50_000; - private const int MaxArgChars = 120; + private const int MaxArgChars = 500; - public string? FormatLine(string line) + /// Formats one stdout line; null when the event is filtered. Entry text may span several lines. + public IReadOnlyList<(LogKind Kind, string Text)>? FormatLine(string line) { JsonDocument doc; try @@ -17,7 +20,7 @@ public class StreamLineFormatter } catch (JsonException) { - return line; + return [(LogKind.Stdout, line)]; } using (doc) @@ -28,7 +31,7 @@ public class StreamLineFormatter if (!root.TryGetProperty("type", out var typeProp)) return null; - return typeProp.GetString() switch + var entries = typeProp.GetString() switch { "system" => FormatSystem(root), "assistant" => FormatAssistant(root), @@ -36,10 +39,11 @@ public class StreamLineFormatter "result" => FormatResult(root), _ => null, }; + return entries is { Count: > 0 } ? entries : null; } } - private static string? FormatSystem(JsonElement root) + private static List<(LogKind, string)>? FormatSystem(JsonElement root) { if (!root.TryGetProperty("subtype", out var subtypeProp)) return null; @@ -48,7 +52,7 @@ public class StreamLineFormatter switch (subtype) { case "api_retry": - return "[Retrying API call...]\n"; + return [(LogKind.Sys, "[Retrying API call...]")]; case "init": { @@ -61,7 +65,7 @@ public class StreamLineFormatter ? sessionId[..8] : sessionId ?? "?"; var modelPart = string.IsNullOrEmpty(model) ? "" : $" · {model}"; - return $"[session {shortId}{modelPart}]\n"; + return [(LogKind.Sys, $"[session {shortId}{modelPart}]")]; } default: @@ -69,12 +73,12 @@ public class StreamLineFormatter } } - private static string? FormatAssistant(JsonElement root) + private static List<(LogKind, string)>? FormatAssistant(JsonElement root) { if (!TryGetContentArray(root, out var content)) return null; - var sb = new StringBuilder(); + var entries = new List<(LogKind, string)>(); foreach (var block in content.EnumerateArray()) { if (block.ValueKind != JsonValueKind.Object) continue; @@ -87,16 +91,12 @@ public class StreamLineFormatter { var text = textProp.GetString(); if (!string.IsNullOrEmpty(text)) - { - sb.Append(text); - if (!text.EndsWith('\n')) sb.Append('\n'); - } + entries.Add((LogKind.Claude, text)); } break; case "tool_use": - sb.Append(FormatToolUse(block)); - sb.Append('\n'); + entries.Add(FormatToolUse(block)); break; case "thinking": @@ -106,7 +106,7 @@ public class StreamLineFormatter } } - return sb.Length == 0 ? null : sb.ToString(); + return entries; } private static bool TryGetContentArray(JsonElement root, out JsonElement content) @@ -120,35 +120,30 @@ public class StreamLineFormatter return true; } - private static string? FormatUser(JsonElement root) + private static List<(LogKind, string)>? FormatUser(JsonElement root) { if (!TryGetContentArray(root, out var content)) return null; - var sb = new StringBuilder(); + var entries = new List<(LogKind, string)>(); 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); + var isError = block.TryGetProperty("is_error", out var errProp) + && errProp.ValueKind == JsonValueKind.True; + var summary = BuildToolResultSummary(root, block, isError); if (!string.IsNullOrEmpty(summary)) - { - sb.Append("→ "); - sb.Append(summary); - sb.Append('\n'); - } + entries.Add((isError ? LogKind.Stderr : LogKind.ToolResult, "→ " + summary)); } - return sb.Length == 0 ? null : sb.ToString(); + return entries; } - private static string BuildToolResultSummary(JsonElement root, JsonElement block) + private static string BuildToolResultSummary(JsonElement root, JsonElement block, bool isError) { - var isError = block.TryGetProperty("is_error", out var errProp) - && errProp.ValueKind == JsonValueKind.True; - var contentText = ResolveContentText(block); if (isError) @@ -215,14 +210,14 @@ public class StreamLineFormatter return ""; } - private static string? FormatResult(JsonElement root) + private static List<(LogKind, string)>? FormatResult(JsonElement root) { if (root.TryGetProperty("result", out var resultProp)) - return $"\n--- Result ---\n{resultProp.GetString()}\n"; + return [(LogKind.Done, $"--- Result ---\n{resultProp.GetString()}")]; return null; } - private static string FormatToolUse(JsonElement block) + private static (LogKind, string) FormatToolUse(JsonElement block) { var name = block.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "?" @@ -241,10 +236,9 @@ public class StreamLineFormatter } string? arg = hasInput ? BuildToolArg(name, input) : null; + var kind = name is "Edit" or "Write" or "NotebookEdit" ? LogKind.Edit : LogKind.Tool; - return string.IsNullOrEmpty(arg) - ? $"[{label}]" - : $"[{label}] {arg}"; + return (kind, string.IsNullOrEmpty(arg) ? $"[{label}]" : $"[{label}] {arg}"); } private static string? BuildToolArg(string toolName, JsonElement input) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs index 8b5b09c9..042166a3 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs @@ -1,6 +1,6 @@ namespace ClaudeDo.Ui.ViewModels.Islands; -public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg, User } +public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg, User, Edit, ToolResult } public sealed class LogLineViewModel { @@ -9,26 +9,42 @@ public sealed class LogLineViewModel public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss"); public string KindMarker => Kind switch { - LogKind.Sys => "sys", - LogKind.Tool => "tool", - LogKind.Claude => "claude", - LogKind.Stdout => "out", - LogKind.Stderr => "err", - LogKind.Done => "done", - LogKind.Msg => "claude", - LogKind.User => "you", - _ => "", + LogKind.Sys => "sys", + LogKind.Tool => "tool", + LogKind.Claude => "claude", + LogKind.Stdout => "out", + LogKind.Stderr => "err", + LogKind.Done => "done", + LogKind.Msg => "claude", + LogKind.User => "you", + LogKind.Edit => "edit", + LogKind.ToolResult => "result", + _ => "", }; public string ClassName => Kind switch { - LogKind.Sys => "log-sys", - LogKind.Tool => "log-tool", - LogKind.Claude => "log-claude", - LogKind.Stdout => "log-stdout", - LogKind.Stderr => "log-stderr", - LogKind.Done => "log-done", - LogKind.Msg => "log-msg", - LogKind.User => "log-user", - _ => "", + LogKind.Sys => "log-sys", + LogKind.Tool => "log-tool", + LogKind.Claude => "log-claude", + LogKind.Stdout => "log-stdout", + LogKind.Stderr => "log-stderr", + LogKind.Done => "log-done", + LogKind.Msg => "log-msg", + LogKind.User => "log-user", + LogKind.Edit => "log-edit", + LogKind.ToolResult => "log-result", + _ => "", }; + + /// Splits formatter entries into one log line per non-blank text line, keeping each entry's kind. + public static IEnumerable FromEntries(IEnumerable<(LogKind Kind, string Text)> entries) + { + foreach (var (kind, text) in entries) + foreach (var raw in text.Split('\n')) + { + var piece = raw.TrimEnd('\r'); + if (!string.IsNullOrWhiteSpace(piece)) + yield return new LogLineViewModel { Kind = kind, Text = piece }; + } + } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs index 3a55ed45..27ad6f29 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Text; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Ui.Helpers; @@ -13,7 +12,6 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable { private readonly IWorkerClient _worker; private readonly StreamLineFormatter _formatter = new(); - private readonly StringBuilder _prepClaudeBuf = new(); private readonly Action _onPrepStartedHandler; private readonly Action _onPrepLineHandler; @@ -128,24 +126,8 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable private void AppendStdoutLine(string line) { - var formatted = _formatter.FormatLine(line); - if (formatted is null) return; - AppendClaudeText(formatted); - } - - private void AppendClaudeText(string chunk) - { - _prepClaudeBuf.Append(chunk); - while (true) - { - var text = _prepClaudeBuf.ToString(); - var nl = text.IndexOf('\n'); - if (nl < 0) break; - var piece = text[..nl].TrimEnd('\r'); - if (!string.IsNullOrWhiteSpace(piece)) - PrepLog.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); - _prepClaudeBuf.Clear(); - _prepClaudeBuf.Append(text[(nl + 1)..]); - } + if (_formatter.FormatLine(line) is not { } entries) return; + foreach (var entry in LogLineViewModel.FromEntries(entries)) + PrepLog.Add(entry); } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs index a7d809f2..335b8627 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Text; using System.Text.Json; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -18,7 +17,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable private readonly IWorkerClient _worker; private readonly StreamLineFormatter _formatter = new(); - private readonly StringBuilder _claudeBuf = new(); private string? _subscribedTaskId; public string? SubscribedTaskId => _subscribedTaskId; @@ -105,7 +103,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable _onTaskFinished = (slot, taskId, status, finishedAt) => { if (taskId != _subscribedTaskId) return; - FlushClaudeBuffer(); Log.Add(new LogLineViewModel { Kind = LogKind.Done, @@ -182,7 +179,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable public void Reset() { Log.Clear(); - _claudeBuf.Clear(); _subscribedTaskId = null; AgentState = "idle"; SessionOutcome = null; @@ -272,7 +268,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line; OnTaskMessage(_subscribedTaskId, normalized); } - FlushClaudeBuffer(); } catch (OperationCanceledException) { throw; } catch { /* best-effort replay */ } @@ -289,8 +284,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable return; } - FlushClaudeBuffer(); - var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys : line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool : line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude @@ -302,29 +295,9 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable private void AppendStdoutLine(string line) { - var formatted = _formatter.FormatLine(line); - if (formatted is null) return; - _claudeBuf.Append(formatted); - while (true) - { - var text = _claudeBuf.ToString(); - var nl = text.IndexOf('\n'); - if (nl < 0) break; - var piece = text[..nl].TrimEnd('\r'); - if (!string.IsNullOrWhiteSpace(piece)) - Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); - _claudeBuf.Clear(); - _claudeBuf.Append(text[(nl + 1)..]); - } - } - - private void FlushClaudeBuffer() - { - if (_claudeBuf.Length == 0) return; - var piece = _claudeBuf.ToString().TrimEnd(); - _claudeBuf.Clear(); - if (!string.IsNullOrWhiteSpace(piece)) - Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); + if (_formatter.FormatLine(line) is not { } entries) return; + foreach (var entry in LogLineViewModel.FromEntries(entries)) + Log.Add(entry); } private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId) diff --git a/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs b/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs index c04d8ac3..4e49f9fa 100644 --- a/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs +++ b/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs @@ -1,4 +1,5 @@ using ClaudeDo.Ui.Helpers; +using ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.Tests.Helpers; @@ -6,22 +7,15 @@ public class StreamLineFormatterTests { private readonly StreamLineFormatter _formatter = new(); + private (LogKind Kind, string Text) Single(string line) => Assert.Single(_formatter.FormatLine(line)!); + // --- Assistant text blocks --- [Fact] - public void FormatLine_AssistantTextBlock_ReturnsTextContent() + public void FormatLine_AssistantTextBlock_ReturnsClaudeEntry() { var line = """{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}"""; - Assert.Equal("Hello world\n", _formatter.FormatLine(line)); - } - - [Fact] - public void FormatLine_AssistantConsecutiveTextBlocks_ReturnEachAppended() - { - var line1 = """{"type":"assistant","message":{"content":[{"type":"text","text":"Hello "}]}}"""; - var line2 = """{"type":"assistant","message":{"content":[{"type":"text","text":"world"}]}}"""; - Assert.Equal("Hello \n", _formatter.FormatLine(line1)); - Assert.Equal("world\n", _formatter.FormatLine(line2)); + Assert.Equal((LogKind.Claude, "Hello world"), Single(line)); } [Fact] @@ -31,15 +25,60 @@ public class StreamLineFormatterTests Assert.Null(_formatter.FormatLine(line)); } - // --- Tool use, result, system, fallback --- + [Fact] + public void FormatLine_MixedBlocks_KeepOrderAndKinds() + { + var line = """{"type":"assistant","message":{"content":[{"type":"text","text":"Fixing it."},{"type":"tool_use","id":"x","name":"Edit","input":{"file_path":"C:/repo/src/Foo.cs"}}]}}"""; + Assert.Equal( + [(LogKind.Claude, "Fixing it."), (LogKind.Edit, "[Edit] Foo.cs")], + _formatter.FormatLine(line)); + } + + // --- Tool use --- [Fact] - public void FormatLine_AssistantToolUseBlock_ReturnsToolNameLine() + public void FormatLine_AssistantToolUseBlock_ReturnsToolEntry() { var line = """{"type":"assistant","message":{"content":[{"type":"tool_use","id":"x","name":"Bash","input":{"command":"ls"}}]}}"""; - Assert.Equal("[Bash] $ ls\n", _formatter.FormatLine(line)); + Assert.Equal((LogKind.Tool, "[Bash] $ ls"), Single(line)); } + [Theory] + [InlineData("Write")] + [InlineData("NotebookEdit")] + public void FormatLine_FileMutatingTools_AreEditKind(string tool) + { + var line = $$$"""{"type":"assistant","message":{"content":[{"type":"tool_use","id":"x","name":"{{{tool}}}","input":{"file_path":"a/b.txt"}}]}}"""; + Assert.Equal((LogKind.Edit, $"[{tool}] b.txt"), Single(line)); + } + + [Fact] + public void FormatLine_LongBashCommand_IsNotCutAt120Chars() + { + var cmd = "cd \\\"C:/Private/.claudedo-worktrees/claude-do/385-unit-branch-teil-1-branch-naming-basis-b\\\" && dotnet build ClaudeDo.slnx -c Release --no-restore -v minimal"; + var line = $$$"""{"type":"assistant","message":{"content":[{"type":"tool_use","id":"x","name":"Bash","input":{"command":"{{{cmd}}}"}}]}}"""; + var (_, text) = Single(line); + Assert.EndsWith("-v minimal", text); + } + + // --- Tool results --- + + [Fact] + public void FormatLine_ToolResult_IsToolResultKind() + { + var line = """{"type":"user","message":{"content":[{"type":"tool_result","content":"Build succeeded."}]}}"""; + Assert.Equal((LogKind.ToolResult, "→ Build succeeded."), Single(line)); + } + + [Fact] + public void FormatLine_ToolResultError_IsStderrKind() + { + var line = """{"type":"user","message":{"content":[{"type":"tool_result","is_error":true,"content":"boom"}]}}"""; + Assert.Equal((LogKind.Stderr, "→ error: boom"), Single(line)); + } + + // --- Result, system, fallback --- + [Fact] public void FormatLine_InputJsonDelta_ReturnsNull() { @@ -48,17 +87,17 @@ public class StreamLineFormatterTests } [Fact] - public void FormatLine_Result_ReturnsFormattedResult() + public void FormatLine_Result_ReturnsDoneEntry() { var line = """{"type":"result","result":"Done."}"""; - Assert.Equal("\n--- Result ---\nDone.\n", _formatter.FormatLine(line)); + Assert.Equal((LogKind.Done, "--- Result ---\nDone."), Single(line)); } [Fact] - public void FormatLine_ApiRetry_ReturnsRetryNotice() + public void FormatLine_ApiRetry_ReturnsSysEntry() { var line = """{"type":"system","subtype":"api_retry"}"""; - Assert.Equal("[Retrying API call...]\n", _formatter.FormatLine(line)); + Assert.Equal((LogKind.Sys, "[Retrying API call...]"), Single(line)); } [Fact] @@ -76,10 +115,9 @@ public class StreamLineFormatterTests } [Fact] - public void FormatLine_MalformedJson_ReturnsRawLine() + public void FormatLine_MalformedJson_ReturnsRawStdoutEntry() { - var line = "not json at all"; - Assert.Equal("not json at all", _formatter.FormatLine(line)); + Assert.Equal((LogKind.Stdout, "not json at all"), Single("not json at all")); } [Fact] @@ -91,5 +129,13 @@ public class StreamLineFormatterTests Assert.Null(_formatter.FormatLine(delta)); } - // --- Trim --- + // --- Line splitting --- + + [Fact] + public void FromEntries_SplitsMultilineText_KeepingKind_AndDroppingBlanks() + { + var lines = LogLineViewModel.FromEntries([(LogKind.Done, "--- Result ---\n\nDone.\r\n")]).ToList(); + Assert.Equal(["--- Result ---", "Done."], lines.Select(l => l.Text)); + Assert.All(lines, l => Assert.Equal(LogKind.Done, l.Kind)); + } }