docs: add UI-rewrite notes, plans, and stream-formatter spec
This commit is contained in:
614
docs/superpowers/plans/2026-04-21-stream-formatter-rewrite.md
Normal file
614
docs/superpowers/plans/2026-04-21-stream-formatter-rewrite.md
Normal file
@@ -0,0 +1,614 @@
|
||||
# Stream Formatter Rewrite — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rewrite `StreamLineFormatter` so Claude CLI stream-json messages (system/init, assistant text, assistant tool_use, user tool_result, result) render as compact readable lines in the Details pane.
|
||||
|
||||
**Architecture:** Single-file rewrite of `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`. Public API (`FormatLine(string)` / `FormatFile(string)` / `Trim`) and constants unchanged. Internal dispatch switches on top-level `type`; per-type helpers return one or more `\n`-terminated display lines, concatenated into the return string.
|
||||
|
||||
**Tech Stack:** C# 12, .NET 8, `System.Text.Json` (already in use).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-21-stream-formatter-rewrite-design.md`
|
||||
|
||||
**Testing:** Skipped per user decision; verification is a manual build after each task and a final end-to-end run of a real task.
|
||||
|
||||
**Build command (repo uses csproj builds, not slnx, on .NET 8):**
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify:** `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` — complete rewrite of parsing logic; keeps public class surface.
|
||||
|
||||
No other files change. `DetailsIslandViewModel` and the Worker pipeline are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Replace the dispatch skeleton
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||
|
||||
Swap the old top-level `switch` for one that names every supported message type. Every branch returns `null` for now except `result` and `api_retry`, which keep their existing behavior. This gives us a clean compile before we fill in each branch.
|
||||
|
||||
- [ ] **Step 1: Overwrite the file with the new skeleton**
|
||||
|
||||
Replace the entire contents of `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` with:
|
||||
|
||||
```csharp
|
||||
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;
|
||||
return subtypeProp.GetString() switch
|
||||
{
|
||||
"api_retry" => "[Retrying API call...]\n",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatAssistant(JsonElement root) => null;
|
||||
|
||||
private static string? FormatUser(JsonElement root) => null;
|
||||
|
||||
private static string? FormatResult(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("result", out var resultProp))
|
||||
return $"\n--- Result ---\n{resultProp.GetString()}\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
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..];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: build succeeds, 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||
git commit -m "refactor(ui): skeleton dispatch for StreamLineFormatter rewrite"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add system/init formatting
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||
|
||||
Emit `[session <id8> · <model>]` when the CLI announces the session at startup.
|
||||
|
||||
- [ ] **Step 1: Replace the `FormatSystem` method**
|
||||
|
||||
Find:
|
||||
|
||||
```csharp
|
||||
private static string? FormatSystem(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("subtype", out var subtypeProp))
|
||||
return null;
|
||||
return subtypeProp.GetString() switch
|
||||
{
|
||||
"api_retry" => "[Retrying API call...]\n",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: build succeeds, 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||
git commit -m "feat(ui): format system init message in StreamLineFormatter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add assistant text + thinking filter
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||
|
||||
Iterate `message.content[]`. Emit each `text` block verbatim with a trailing `\n`; skip `thinking`. Leave `tool_use` for the next task (still returns nothing for now).
|
||||
|
||||
- [ ] **Step 1: Replace the `FormatAssistant` method**
|
||||
|
||||
Find:
|
||||
|
||||
```csharp
|
||||
private static string? FormatAssistant(JsonElement root) => null;
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
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":
|
||||
// Filled in by a later task.
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: build succeeds, 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||
git commit -m "feat(ui): render assistant text blocks, skip thinking"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add tool_use block formatting
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||
|
||||
Fill in the `tool_use` case inside `FormatAssistant`. Per-tool label/arg logic lives in a dedicated helper.
|
||||
|
||||
- [ ] **Step 1: Replace the `tool_use` case body**
|
||||
|
||||
Find:
|
||||
|
||||
```csharp
|
||||
case "tool_use":
|
||||
// Filled in by a later task.
|
||||
break;
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
case "tool_use":
|
||||
sb.Append(FormatToolUse(block));
|
||||
sb.Append('\n');
|
||||
break;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add helper methods at the end of the class (before `FormatFile`)**
|
||||
|
||||
Insert just above the `public string FormatFile(string filePath)` method:
|
||||
|
||||
```csharp
|
||||
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 GetStr(input, "url");
|
||||
|
||||
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] + "…";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: build succeeds, 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||
git commit -m "feat(ui): render assistant tool_use blocks with per-tool args"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add user tool_result formatting
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||
|
||||
Iterate `message.content[]` for `tool_result` blocks and emit `→ <summary>` lines per the spec rules.
|
||||
|
||||
- [ ] **Step 1: Replace the `FormatUser` method**
|
||||
|
||||
Find:
|
||||
|
||||
```csharp
|
||||
private static string? FormatUser(JsonElement root) => null;
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
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 "";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: build succeeds, 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||
git commit -m "feat(ui): render user tool_result blocks as one-line summaries"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Manual end-to-end verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Build everything the app needs**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` and `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: both succeed, 0 errors.
|
||||
|
||||
- [ ] **Step 2: Start the Worker in one terminal**
|
||||
|
||||
Run: `dotnet run --project src/ClaudeDo.Worker`
|
||||
Expected: SignalR hub bound to `127.0.0.1:47821`, no crash.
|
||||
|
||||
- [ ] **Step 3: Start the App in another terminal**
|
||||
|
||||
Run: `dotnet run --project src/ClaudeDo.App`
|
||||
Expected: UI opens, status bar shows online.
|
||||
|
||||
- [ ] **Step 4: Run any task tagged "agent" (e.g. "create a README")**
|
||||
|
||||
In the Details pane, verify the log shows:
|
||||
- A `[session <id>…]` line at the top
|
||||
- Plain prose lines for assistant text
|
||||
- `[Read] <file>`, `[Bash] $ …`, `[Write] <file>` etc. for tool calls
|
||||
- `→ <N> lines` / `→ ok` / `→ error: …` lines after each tool call
|
||||
- A final `--- Result ---` block
|
||||
- **No raw JSON anywhere**
|
||||
|
||||
- [ ] **Step 5: Spot-check the raw log file**
|
||||
|
||||
Open `~/.todo-app/logs/<task>.log` (or equivalent) and confirm the full JSON is still there for debugging — the formatter must not have altered persisted logs.
|
||||
|
||||
- [ ] **Step 6: If any issues surface, fix inline and re-verify**
|
||||
|
||||
Common gotchas to check for if you see blank lines or missing output:
|
||||
- `message.content` sometimes absent → already guarded by `TryGetContentArray`
|
||||
- Unknown tool name → should render `[<name>]` with no arg
|
||||
- `tool_result.content` array form → covered by `ResolveContentText`
|
||||
|
||||
No further commit unless a fix was needed.
|
||||
|
||||
---
|
||||
|
||||
## Post-Implementation Self-Review
|
||||
|
||||
After the tasks above are done, verify:
|
||||
|
||||
1. Every message type listed in the spec's "Output format" table is implemented (`system/init`, `system/api_retry`, `system/other`, `assistant text`, `assistant tool_use`, `assistant thinking`, `user tool_result`, `result`, parse failure).
|
||||
2. No `TODO` / `TBD` / commented-out stubs remain in `StreamLineFormatter.cs`.
|
||||
3. Tool labels match the spec table exactly (`[Read]`, `[Bash] $ …`, `[Task: <sub>] <desc>`, etc.).
|
||||
4. Public API surface (`FormatLine`, `FormatFile`, `Trim`, `MaxLength` behavior) is unchanged.
|
||||
5. No edits outside `StreamLineFormatter.cs` (per the spec's non-goals).
|
||||
Reference in New Issue
Block a user