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
@@ -24,15 +24,17 @@ public sealed class LogKindForegroundConverter : IValueConverter
{ {
var key = value is LogKind kind ? kind switch var key = value is LogKind kind ? kind switch
{ {
LogKind.Sys => "TextMuteBrush", LogKind.Sys => "TextMuteBrush",
LogKind.Tool => "SageBrush", LogKind.Tool => "SageBrush",
LogKind.Claude => "TextBrush", LogKind.Claude => "TextBrush",
LogKind.Stdout => "TextDimBrush", LogKind.Stdout => "TextDimBrush",
LogKind.Stderr => "BloodBrush", LogKind.Stderr => "BloodBrush",
LogKind.Done => "MossBrightBrush", LogKind.Done => "MossBrightBrush",
LogKind.Msg => "TextDimBrush", LogKind.Msg => "TextDimBrush",
LogKind.User => "AccentBrush", LogKind.User => "AccentBrush",
_ => "TextDimBrush", LogKind.Edit => "AmberBrush",
LogKind.ToolResult => "TextMuteBrush",
_ => "TextDimBrush",
} : "TextDimBrush"; } : "TextDimBrush";
return Resolve(key) ?? AvaloniaProperty.UnsetValue; return Resolve(key) ?? AvaloniaProperty.UnsetValue;
+29 -35
View File
@@ -1,14 +1,17 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Helpers; namespace ClaudeDo.Ui.Helpers;
/// <summary>Turns Claude CLI stream-json lines into kind-tagged log entries for the terminal views.</summary>
public class StreamLineFormatter public class StreamLineFormatter
{ {
private const int MaxLength = 50_000; private const int MaxLength = 50_000;
private const int MaxArgChars = 120; private const int MaxArgChars = 500;
public string? FormatLine(string line) /// <summary>Formats one stdout line; null when the event is filtered. Entry text may span several lines.</summary>
public IReadOnlyList<(LogKind Kind, string Text)>? FormatLine(string line)
{ {
JsonDocument doc; JsonDocument doc;
try try
@@ -17,7 +20,7 @@ public class StreamLineFormatter
} }
catch (JsonException) catch (JsonException)
{ {
return line; return [(LogKind.Stdout, line)];
} }
using (doc) using (doc)
@@ -28,7 +31,7 @@ public class StreamLineFormatter
if (!root.TryGetProperty("type", out var typeProp)) if (!root.TryGetProperty("type", out var typeProp))
return null; return null;
return typeProp.GetString() switch var entries = typeProp.GetString() switch
{ {
"system" => FormatSystem(root), "system" => FormatSystem(root),
"assistant" => FormatAssistant(root), "assistant" => FormatAssistant(root),
@@ -36,10 +39,11 @@ public class StreamLineFormatter
"result" => FormatResult(root), "result" => FormatResult(root),
_ => null, _ => 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)) if (!root.TryGetProperty("subtype", out var subtypeProp))
return null; return null;
@@ -48,7 +52,7 @@ public class StreamLineFormatter
switch (subtype) switch (subtype)
{ {
case "api_retry": case "api_retry":
return "[Retrying API call...]\n"; return [(LogKind.Sys, "[Retrying API call...]")];
case "init": case "init":
{ {
@@ -61,7 +65,7 @@ public class StreamLineFormatter
? sessionId[..8] ? sessionId[..8]
: sessionId ?? "?"; : sessionId ?? "?";
var modelPart = string.IsNullOrEmpty(model) ? "" : $" · {model}"; var modelPart = string.IsNullOrEmpty(model) ? "" : $" · {model}";
return $"[session {shortId}{modelPart}]\n"; return [(LogKind.Sys, $"[session {shortId}{modelPart}]")];
} }
default: 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)) if (!TryGetContentArray(root, out var content))
return null; return null;
var sb = new StringBuilder(); var entries = new List<(LogKind, string)>();
foreach (var block in content.EnumerateArray()) foreach (var block in content.EnumerateArray())
{ {
if (block.ValueKind != JsonValueKind.Object) continue; if (block.ValueKind != JsonValueKind.Object) continue;
@@ -87,16 +91,12 @@ public class StreamLineFormatter
{ {
var text = textProp.GetString(); var text = textProp.GetString();
if (!string.IsNullOrEmpty(text)) if (!string.IsNullOrEmpty(text))
{ entries.Add((LogKind.Claude, text));
sb.Append(text);
if (!text.EndsWith('\n')) sb.Append('\n');
}
} }
break; break;
case "tool_use": case "tool_use":
sb.Append(FormatToolUse(block)); entries.Add(FormatToolUse(block));
sb.Append('\n');
break; break;
case "thinking": 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) private static bool TryGetContentArray(JsonElement root, out JsonElement content)
@@ -120,35 +120,30 @@ public class StreamLineFormatter
return true; return true;
} }
private static string? FormatUser(JsonElement root) private static List<(LogKind, string)>? FormatUser(JsonElement root)
{ {
if (!TryGetContentArray(root, out var content)) if (!TryGetContentArray(root, out var content))
return null; return null;
var sb = new StringBuilder(); var entries = new List<(LogKind, string)>();
foreach (var block in content.EnumerateArray()) foreach (var block in content.EnumerateArray())
{ {
if (block.ValueKind != JsonValueKind.Object) continue; if (block.ValueKind != JsonValueKind.Object) continue;
if (!block.TryGetProperty("type", out var blockTypeProp)) continue; if (!block.TryGetProperty("type", out var blockTypeProp)) continue;
if (blockTypeProp.GetString() != "tool_result") 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)) if (!string.IsNullOrEmpty(summary))
{ entries.Add((isError ? LogKind.Stderr : LogKind.ToolResult, "→ " + summary));
sb.Append("→ ");
sb.Append(summary);
sb.Append('\n');
}
} }
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); var contentText = ResolveContentText(block);
if (isError) if (isError)
@@ -215,14 +210,14 @@ public class StreamLineFormatter
return ""; return "";
} }
private static string? FormatResult(JsonElement root) private static List<(LogKind, string)>? FormatResult(JsonElement root)
{ {
if (root.TryGetProperty("result", out var resultProp)) if (root.TryGetProperty("result", out var resultProp))
return $"\n--- Result ---\n{resultProp.GetString()}\n"; return [(LogKind.Done, $"--- Result ---\n{resultProp.GetString()}")];
return null; return null;
} }
private static string FormatToolUse(JsonElement block) private static (LogKind, string) FormatToolUse(JsonElement block)
{ {
var name = block.TryGetProperty("name", out var nameProp) var name = block.TryGetProperty("name", out var nameProp)
? nameProp.GetString() ?? "?" ? nameProp.GetString() ?? "?"
@@ -241,10 +236,9 @@ public class StreamLineFormatter
} }
string? arg = hasInput ? BuildToolArg(name, input) : null; string? arg = hasInput ? BuildToolArg(name, input) : null;
var kind = name is "Edit" or "Write" or "NotebookEdit" ? LogKind.Edit : LogKind.Tool;
return string.IsNullOrEmpty(arg) return (kind, string.IsNullOrEmpty(arg) ? $"[{label}]" : $"[{label}] {arg}");
? $"[{label}]"
: $"[{label}] {arg}";
} }
private static string? BuildToolArg(string toolName, JsonElement input) private static string? BuildToolArg(string toolName, JsonElement input)
@@ -1,6 +1,6 @@
namespace ClaudeDo.Ui.ViewModels.Islands; 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 public sealed class LogLineViewModel
{ {
@@ -9,26 +9,42 @@ public sealed class LogLineViewModel
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss"); public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
public string KindMarker => Kind switch public string KindMarker => Kind switch
{ {
LogKind.Sys => "sys", LogKind.Sys => "sys",
LogKind.Tool => "tool", LogKind.Tool => "tool",
LogKind.Claude => "claude", LogKind.Claude => "claude",
LogKind.Stdout => "out", LogKind.Stdout => "out",
LogKind.Stderr => "err", LogKind.Stderr => "err",
LogKind.Done => "done", LogKind.Done => "done",
LogKind.Msg => "claude", LogKind.Msg => "claude",
LogKind.User => "you", LogKind.User => "you",
_ => "", LogKind.Edit => "edit",
LogKind.ToolResult => "result",
_ => "",
}; };
public string ClassName => Kind switch public string ClassName => Kind switch
{ {
LogKind.Sys => "log-sys", LogKind.Sys => "log-sys",
LogKind.Tool => "log-tool", LogKind.Tool => "log-tool",
LogKind.Claude => "log-claude", LogKind.Claude => "log-claude",
LogKind.Stdout => "log-stdout", LogKind.Stdout => "log-stdout",
LogKind.Stderr => "log-stderr", LogKind.Stderr => "log-stderr",
LogKind.Done => "log-done", LogKind.Done => "log-done",
LogKind.Msg => "log-msg", LogKind.Msg => "log-msg",
LogKind.User => "log-user", LogKind.User => "log-user",
_ => "", LogKind.Edit => "log-edit",
LogKind.ToolResult => "log-result",
_ => "",
}; };
/// <summary>Splits formatter entries into one log line per non-blank text line, keeping each entry's kind.</summary>
public static IEnumerable<LogLineViewModel> 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 };
}
}
} }
@@ -1,5 +1,4 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Helpers; using ClaudeDo.Ui.Helpers;
@@ -13,7 +12,6 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
{ {
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly StreamLineFormatter _formatter = new(); private readonly StreamLineFormatter _formatter = new();
private readonly StringBuilder _prepClaudeBuf = new();
private readonly Action _onPrepStartedHandler; private readonly Action _onPrepStartedHandler;
private readonly Action<string> _onPrepLineHandler; private readonly Action<string> _onPrepLineHandler;
@@ -128,24 +126,8 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
private void AppendStdoutLine(string line) private void AppendStdoutLine(string line)
{ {
var formatted = _formatter.FormatLine(line); if (_formatter.FormatLine(line) is not { } entries) return;
if (formatted is null) return; foreach (var entry in LogLineViewModel.FromEntries(entries))
AppendClaudeText(formatted); PrepLog.Add(entry);
}
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)..]);
}
} }
} }
@@ -1,5 +1,4 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Text;
using System.Text.Json; using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -18,7 +17,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly StreamLineFormatter _formatter = new(); private readonly StreamLineFormatter _formatter = new();
private readonly StringBuilder _claudeBuf = new();
private string? _subscribedTaskId; private string? _subscribedTaskId;
public string? SubscribedTaskId => _subscribedTaskId; public string? SubscribedTaskId => _subscribedTaskId;
@@ -105,7 +103,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
_onTaskFinished = (slot, taskId, status, finishedAt) => _onTaskFinished = (slot, taskId, status, finishedAt) =>
{ {
if (taskId != _subscribedTaskId) return; if (taskId != _subscribedTaskId) return;
FlushClaudeBuffer();
Log.Add(new LogLineViewModel Log.Add(new LogLineViewModel
{ {
Kind = LogKind.Done, Kind = LogKind.Done,
@@ -182,7 +179,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
public void Reset() public void Reset()
{ {
Log.Clear(); Log.Clear();
_claudeBuf.Clear();
_subscribedTaskId = null; _subscribedTaskId = null;
AgentState = "idle"; AgentState = "idle";
SessionOutcome = null; SessionOutcome = null;
@@ -272,7 +268,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line; var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
OnTaskMessage(_subscribedTaskId, normalized); OnTaskMessage(_subscribedTaskId, normalized);
} }
FlushClaudeBuffer();
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch { /* best-effort replay */ } catch { /* best-effort replay */ }
@@ -289,8 +284,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
return; return;
} }
FlushClaudeBuffer();
var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys
: line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool : line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool
: line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude : line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
@@ -302,29 +295,9 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
private void AppendStdoutLine(string line) private void AppendStdoutLine(string line)
{ {
var formatted = _formatter.FormatLine(line); if (_formatter.FormatLine(line) is not { } entries) return;
if (formatted is null) return; foreach (var entry in LogLineViewModel.FromEntries(entries))
_claudeBuf.Append(formatted); Log.Add(entry);
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 });
} }
private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId) private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId)
@@ -1,4 +1,5 @@
using ClaudeDo.Ui.Helpers; using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Tests.Helpers; namespace ClaudeDo.Ui.Tests.Helpers;
@@ -6,22 +7,15 @@ public class StreamLineFormatterTests
{ {
private readonly StreamLineFormatter _formatter = new(); private readonly StreamLineFormatter _formatter = new();
private (LogKind Kind, string Text) Single(string line) => Assert.Single(_formatter.FormatLine(line)!);
// --- Assistant text blocks --- // --- Assistant text blocks ---
[Fact] [Fact]
public void FormatLine_AssistantTextBlock_ReturnsTextContent() public void FormatLine_AssistantTextBlock_ReturnsClaudeEntry()
{ {
var line = """{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}"""; var line = """{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}""";
Assert.Equal("Hello world\n", _formatter.FormatLine(line)); Assert.Equal((LogKind.Claude, "Hello world"), Single(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));
} }
[Fact] [Fact]
@@ -31,15 +25,60 @@ public class StreamLineFormatterTests
Assert.Null(_formatter.FormatLine(line)); 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] [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"}}]}}"""; 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] [Fact]
public void FormatLine_InputJsonDelta_ReturnsNull() public void FormatLine_InputJsonDelta_ReturnsNull()
{ {
@@ -48,17 +87,17 @@ public class StreamLineFormatterTests
} }
[Fact] [Fact]
public void FormatLine_Result_ReturnsFormattedResult() public void FormatLine_Result_ReturnsDoneEntry()
{ {
var line = """{"type":"result","result":"Done."}"""; var line = """{"type":"result","result":"Done."}""";
Assert.Equal("\n--- Result ---\nDone.\n", _formatter.FormatLine(line)); Assert.Equal((LogKind.Done, "--- Result ---\nDone."), Single(line));
} }
[Fact] [Fact]
public void FormatLine_ApiRetry_ReturnsRetryNotice() public void FormatLine_ApiRetry_ReturnsSysEntry()
{ {
var line = """{"type":"system","subtype":"api_retry"}"""; 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] [Fact]
@@ -76,10 +115,9 @@ public class StreamLineFormatterTests
} }
[Fact] [Fact]
public void FormatLine_MalformedJson_ReturnsRawLine() public void FormatLine_MalformedJson_ReturnsRawStdoutEntry()
{ {
var line = "not json at all"; Assert.Equal((LogKind.Stdout, "not json at all"), Single("not json at all"));
Assert.Equal("not json at all", _formatter.FormatLine(line));
} }
[Fact] [Fact]
@@ -91,5 +129,13 @@ public class StreamLineFormatterTests
Assert.Null(_formatter.FormatLine(delta)); 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));
}
} }