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.
This commit is contained in:
mika kuns
2026-10-01 10:49:52 +02:00
parent 0e4de9e4c0
commit 9bb0e67a97
6 changed files with 149 additions and 136 deletions
@@ -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));
}
}