docs: add UI-rewrite notes, plans, and stream-formatter spec

This commit is contained in:
Mika Kuns
2026-04-21 15:56:19 +02:00
parent a180e8446c
commit 23f8fddc4d
16 changed files with 7165 additions and 0 deletions

View File

@@ -0,0 +1,705 @@
# Logic Bug Fixes 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:** Fix confirmed logic bugs across Worker, App/Ui, and Installer found in the 2026-04-17 three-agent review.
**Architecture:** Each bug is an isolated change to one or two files. Group by priority (Critical → High → Medium → Info). TDD where the bug is observable via xUnit integration test (Worker, Data); for UI/Installer bugs without test harness, do a focused manual repro and guard with a regression comment referencing the commit.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm, EF Core + SQLite, SignalR, xUnit (Worker tests only).
---
## File Map
**Worker:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — remove premature `RunCreated` broadcast
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — emit `RunCreated` after run row insert
- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs` — add slot-collision guard on `RunNow`/`ContinueTask`
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` — extend quoting to cover whitespace/newline
- Test: `tests/ClaudeDo.Worker.Tests/ClaudeArgsBuilderTests.cs` — regression for newline in system prompt
- Test: `tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs` — regression for RunNow-while-queued
**Ui:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — guard nullability in `AddTask`, harden `OnTaskUpdated`
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` — defer `_taskId` assignment until after cancel check
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — init TCS before dialog shown
**Installer:**
- Modify: `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs` — remove inline start; reject CurrentUser without password
- Modify: `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` — rename-before-extract rollback
- Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs` — add `removeAppData` parameter
- Modify: `src/ClaudeDo.Installer/Steps/WriteConfigStep.cs` — expand `~` in `UiDbPath`
- Verify: `src/ClaudeDo.Installer/App.xaml.cs` — confirm Avalonia vs WPF usings
---
## Critical
### Task 1: Worker — fix `RunCreated` broadcast ordering (W1)
Bug: `WorkerHub.RunNow` fires `RunCreated` before the run row is inserted by `RunOnceAsync`. UI can receive an event for a row that does not yet exist.
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:35-50`
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:234-256` (`RunOnceAsync`)
- [ ] **Step 1: Remove premature broadcast from WorkerHub**
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, replace the body of `RunNow`:
```csharp
public async Task RunNow(string taskId)
{
try
{
await _queue.RunNow(taskId);
}
catch (InvalidOperationException)
{
throw new HubException("override slot busy");
}
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
}
```
- [ ] **Step 2: Emit `RunCreated` inside `RunOnceAsync` after row insert**
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, find `RunOnceAsync`. After the `runRepo.AddAsync(run, ct);` block (~line 256), add:
```csharp
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
```
Then remove the existing `await _broadcaster.RunCreated(task.Id, 2, true);` on line 128 (inside the auto-retry block in `RunAsync`) and the `await _broadcaster.RunCreated(taskId, nextRunNumber, false);` on line 219 (in `ContinueAsync`), since `RunOnceAsync` now broadcasts unconditionally.
- [ ] **Step 3: Build and run Worker tests**
Run: `dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests`
Expected: all existing tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "fix(worker): emit RunCreated after run row exists"
```
---
### Task 2: Ui — harden `OnTaskUpdated` against async void crash (U2)
Bug: `TaskListViewModel.OnTaskUpdated` is `async void` with no try/catch. A DB error escapes to `TaskScheduler.UnobservedTaskException` and can crash the process.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs:328-332`
- [ ] **Step 1: Wrap handler body in try/catch**
Replace the existing method with:
```csharp
private async void OnTaskUpdated(string taskId)
{
if (CurrentListId is null) return;
try
{
await RefreshSingleAsync(taskId);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
}
```
- [ ] **Step 2: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
git commit -m "fix(ui): swallow DB errors in TaskListViewModel.OnTaskUpdated"
```
---
### Task 3: Installer — reject CurrentUser service registration without password (I1)
Bug: `RegisterServiceStep` passes `obj=.\<user>` to `sc.exe create` with no `password=`. SCM rejects it with exit 5 / 1069 and the user gets an opaque error.
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`
- [ ] **Step 1: Read the current file**
Read `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs` to confirm the exact shape of the `obj=` branch and the outer `StepResult` return.
- [ ] **Step 2: Replace CurrentUser branch with early failure**
Where the step builds `obj=".\\<username>"` for the `CurrentUser` account option, replace it with:
```csharp
if (ctx.ServiceAccount == ServiceAccountType.CurrentUser)
{
return StepResult.Fail(
"Service cannot run as Current User without a password. " +
"Select 'Local System' or extend ServicePage to capture a password.");
}
```
Keep the `LocalSystem` branch (which passes `obj= LocalSystem` with no password requirement) unchanged.
- [ ] **Step 3: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
git commit -m "fix(installer): reject CurrentUser service account without password"
```
---
## High
### Task 4: Worker — guard slot collision on `RunNow` and `ContinueTask` (W2)
Bug: Queue slot and override slot have no guard against operating on the same `taskId`. `TaskRunner.MarkRunningAsync` can overwrite `started_at`.
**Files:**
- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs:59-115`
- Test: `tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs` (new)
- [ ] **Step 1: Write failing test**
Create `tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Services;
using Xunit;
namespace ClaudeDo.Worker.Tests;
public class QueueServiceSlotGuardTests : WorkerTestBase
{
[Fact]
public async Task RunNow_rejects_task_already_active_in_queue_slot()
{
var queue = ServiceProvider.GetRequiredService<QueueService>();
var task = await SeedAgentTaskAsync(listId: await SeedListAsync(), title: "blocker");
// Prime queue slot by wake signal.
queue.WakeQueue();
await WaitForActiveSlotAsync("queue", task.Id);
// RunNow on the same id must throw InvalidOperationException.
await Assert.ThrowsAsync<InvalidOperationException>(() => queue.RunNow(task.Id));
}
}
```
(Helpers `WorkerTestBase`, `SeedAgentTaskAsync`, `SeedListAsync`, `WaitForActiveSlotAsync` exist in the test project — follow the pattern from existing tests.)
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~QueueServiceSlotGuardTests"`
Expected: FAIL — currently `RunNow` succeeds and creates a duplicate slot.
- [ ] **Step 3: Add guard in `RunNow`**
In `src/ClaudeDo.Worker/Services/QueueService.cs`, inside the `lock (_lock)` block in `RunNow` (~line 69), add before the existing override check:
```csharp
if (_queueSlot?.TaskId == taskId)
throw new InvalidOperationException("task is already running in queue slot");
```
- [ ] **Step 4: Add same guard in `ContinueTask`**
In the `lock (_lock)` block in `ContinueTask` (~line 97), add:
```csharp
if (_queueSlot?.TaskId == taskId)
throw new InvalidOperationException("task is already running in queue slot");
```
- [ ] **Step 5: Run tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Services/QueueService.cs tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs
git commit -m "fix(worker): guard against same task in queue and override slot"
```
---
### Task 5: Worker — quote CLI args with tab/newline/carriage-return (W3)
Bug: `ClaudeArgsBuilder.Escape` only quotes on space/quote. System prompts with newlines pass through unquoted and corrupt the argument list.
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs:56-64`
- Test: `tests/ClaudeDo.Worker.Tests/ClaudeArgsBuilderTests.cs` (new or existing)
- [ ] **Step 1: Write failing test**
If `ClaudeArgsBuilderTests.cs` does not exist, create it:
```csharp
using ClaudeDo.Worker.Runner;
using Xunit;
namespace ClaudeDo.Worker.Tests;
public class ClaudeArgsBuilderTests
{
[Fact]
public void Build_quotes_system_prompt_with_newline()
{
var builder = new ClaudeArgsBuilder();
var args = builder.Build(new ClaudeRunConfig(
Model: null,
SystemPrompt: "line1\nline2",
AgentPath: null,
ResumeSessionId: null));
Assert.Contains("--append-system-prompt \"line1\\nline2\"", args);
}
[Fact]
public void Build_quotes_system_prompt_with_tab()
{
var builder = new ClaudeArgsBuilder();
var args = builder.Build(new ClaudeRunConfig(
Model: null,
SystemPrompt: "col1\tcol2",
AgentPath: null,
ResumeSessionId: null));
Assert.Contains("\"col1", args);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilderTests"`
Expected: FAIL — newline is passed through unquoted.
- [ ] **Step 3: Extend `Escape` condition and escape newline/tab**
In `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs`, replace `Escape`:
```csharp
private static string Escape(string value)
{
if (value.Contains(' ') || value.Contains('"') || value.Contains('\'')
|| value.Contains('\t') || value.Contains('\n') || value.Contains('\r'))
{
var escaped = value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\n", "\\n")
.Replace("\r", "\\r")
.Replace("\t", "\\t");
return $"\"{escaped}\"";
}
return value;
}
```
- [ ] **Step 4: Run tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilderTests"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs tests/ClaudeDo.Worker.Tests/ClaudeArgsBuilderTests.cs
git commit -m "fix(worker): escape newline/tab in CLI args"
```
---
### Task 6: Installer — remove inline service start from `RegisterServiceStep` (I2)
Bug: `RegisterServiceStep` calls `sc.exe start` inline. `StartServiceStep` exists separately. If the update path ever wires both, the service is started twice.
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`
- Modify: `src/ClaudeDo.Installer/App.xaml.cs` (pipeline) — ensure `StartServiceStep` is in the fresh-install pipeline
- [ ] **Step 1: Read current pipeline wiring in App.xaml.cs**
Read `src/ClaudeDo.Installer/App.xaml.cs` around line 112 to confirm the list of steps passed into `InstallerService`.
- [ ] **Step 2: Remove inline `sc.exe start` from RegisterServiceStep**
Delete the block (~lines 72-77) that runs `sc.exe start <service>` when `ctx.AutoStart == true`.
- [ ] **Step 3: Add `StartServiceStep` to the fresh-install pipeline if missing**
In `App.xaml.cs`, append `new StartServiceStep(...)` after `RegisterServiceStep` in the step list. Gate its execution internally on `ctx.AutoStart` (it already handles exit code 1056).
- [ ] **Step 4: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs src/ClaudeDo.Installer/App.xaml.cs
git commit -m "fix(installer): move service start out of RegisterServiceStep"
```
---
### Task 7: Installer — rollback-safe extract in `DownloadAndExtractStep` (I3)
Bug: Old `app/` and `worker/` are deleted before extraction. If extraction throws, user is left with no binaries and no recovery path.
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs:70-95`
- [ ] **Step 1: Read the current delete/extract sequence**
Read `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` around lines 70-95 to identify the exact `Directory.Delete` and `ZipFile.ExtractToDirectory` calls and which `ctx` paths they reference.
- [ ] **Step 2: Replace delete-before-extract with rename-then-commit**
Wrap the delete+extract block:
```csharp
var appDir = Path.Combine(ctx.InstallRoot, "app");
var workDir = Path.Combine(ctx.InstallRoot, "worker");
var appBak = appDir + ".bak";
var workBak = workDir + ".bak";
// Stash existing dirs.
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
if (Directory.Exists(workBak)) Directory.Delete(workBak, recursive: true);
if (Directory.Exists(appDir)) Directory.Move(appDir, appBak);
if (Directory.Exists(workDir)) Directory.Move(workDir, workBak);
try
{
ZipFile.ExtractToDirectory(zipPath, ctx.InstallRoot, overwriteFiles: true);
}
catch
{
// Roll back to previous binaries.
if (Directory.Exists(appDir)) Directory.Delete(appDir, recursive: true);
if (Directory.Exists(workDir)) Directory.Delete(workDir, recursive: true);
if (Directory.Exists(appBak)) Directory.Move(appBak, appDir);
if (Directory.Exists(workBak)) Directory.Move(workBak, workDir);
throw;
}
// Success — drop stash.
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
if (Directory.Exists(workBak)) Directory.Delete(workBak, recursive: true);
```
- [ ] **Step 3: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
git commit -m "fix(installer): rollback-safe extract with .bak stash"
```
---
### Task 8: Installer — gate `~/.todo-app` deletion behind explicit consent (I4)
Bug: Uninstaller always deletes user data (db, logs, configs). Reinstalling a different version silently destroys all tasks.
**Files:**
- Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs:60-80`
- Modify: `src/ClaudeDo.Installer/Views/UninstallPage.xaml(.cs)` (or equivalent) — add a checkbox
- [ ] **Step 1: Read `UninstallRunner.RunAsync` signature**
Read `src/ClaudeDo.Installer/Core/UninstallRunner.cs` around lines 1-90 to get current signature.
- [ ] **Step 2: Add `removeAppData` parameter to `RunAsync`**
Change signature to:
```csharp
public async Task<UninstallResult> RunAsync(bool removeAppData, CancellationToken ct = default)
```
Guard the deletion:
```csharp
if (removeAppData)
{
var appData = Paths.Expand("~/.todo-app");
if (Directory.Exists(appData))
Directory.Delete(appData, recursive: true);
}
```
- [ ] **Step 3: Wire a "Remove user data" checkbox on the uninstall page**
In the uninstall view/VM, add `[ObservableProperty] private bool _removeAppData;` (default `false`) and pass it into `RunAsync(RemoveAppData)`.
- [ ] **Step 4: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Installer/Core/UninstallRunner.cs src/ClaudeDo.Installer/Views/UninstallPage.xaml src/ClaudeDo.Installer/Views/UninstallPage.xaml.cs
git commit -m "fix(installer): make user-data deletion on uninstall opt-in"
```
---
## Medium
### Task 9: Ui — guard `AddTask` against null `CurrentListId` after await (U1)
Bug: `AddTask` awaits `editor.LoadAgentsAsync`. Between `CanAddTask` and `listRepo.GetByIdAsync(CurrentListId)` on line 164, a concurrent `LoadAsync(null)` could null the id. Compiler warns CS8604.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs:157-170`
- [ ] **Step 1: Capture `CurrentListId` before the first `await`**
Replace the start of `AddTask`:
```csharp
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTask()
{
var listId = CurrentListId;
if (listId is null) return;
string defaultCommitType;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(listId);
defaultCommitType = list?.DefaultCommitType ?? "chore";
}
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(listId, defaultCommitType);
// …rest unchanged, but use `listId` consistently where CurrentListId was read
```
Audit the rest of the method: replace every subsequent read of `CurrentListId` with `listId`.
- [ ] **Step 2: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds with no CS8604 on this method.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
git commit -m "fix(ui): capture CurrentListId before await in AddTask"
```
---
### Task 10: Ui — defer `_taskId` assignment in `TaskDetailViewModel.LoadAsync` (U3)
Bug: `_taskId = taskId` is set at line 87, before the previous `_loadCts` is cancelled. If load is cancelled, `_taskId` has been clobbered but `HasWorktree` / `CanWorktreeAction` still reflect the previous task.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs:76-90`
- [ ] **Step 1: Reset stale worktree state when starting a new load**
Replace the start of `LoadAsync`:
```csharp
public async Task LoadAsync(string taskId)
{
var oldCts = _loadCts;
var cts = new CancellationTokenSource();
_loadCts = cts;
oldCts?.Cancel();
oldCts?.Dispose();
var ct = cts.Token;
_taskId = taskId;
// Clear stale worktree state so buttons don't act on the previous task.
HasWorktree = false;
WorktreeState = "";
BranchName = null;
DiffStat = null;
WorktreePath = null;
OnPropertyChanged(nameof(CanWorktreeAction));
LiveText = "";
_formatter = new StreamLineFormatter();
// …rest unchanged
```
- [ ] **Step 2: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
git commit -m "fix(ui): reset stale worktree state on TaskDetail reload"
```
---
### Task 11: Ui — initialize TCS before dialog shown in `TaskEditorViewModel` (U4)
Bug: `ShowAndWaitAsync` creates a fresh `_tcs` only when called. If `Save` fires before `ShowAndWaitAsync` (possible if `ShowDialogAsync` is ever awaited), the result is dropped.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs:72-80, 260-264`
- Modify: `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` (same pattern — apply identically)
- [ ] **Step 1: Reset `_tcs` at the start of `InitForCreate` and `InitForEditAsync`**
In `TaskEditorViewModel.cs`, at the top of `InitForCreate`:
```csharp
public void InitForCreate(string listId, string defaultCommitType = "chore")
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = null;
// …rest unchanged
```
Same first line at the top of `InitForEditAsync` and `InitForEdit`.
- [ ] **Step 2: Remove re-assignment in `ShowAndWaitAsync`**
```csharp
public Task<TaskEntity?> ShowAndWaitAsync() => _tcs.Task;
```
- [ ] **Step 3: Apply the same pattern to `ListEditorViewModel`**
Mirror the same three edits in `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` (reset TCS in its `InitForCreate` / `InitForEdit`, strip the creation in `ShowAndWaitAsync`).
- [ ] **Step 4: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
git commit -m "fix(ui): init editor TCS before dialog can complete"
```
---
### Task 12: Installer — expand `~` in `UiDbPath` (I5)
Bug: `workerCfg.DbPath = Paths.Expand(ctx.DbPath)` but `uiCfg.DbPath = ctx.UiDbPath` is stored as-is. If UI cannot expand `~` at runtime on Windows, DB path is unresolvable.
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/WriteConfigStep.cs:31-34`
- [ ] **Step 1: Expand UiDbPath symmetrically**
Change the assignment:
```csharp
uiCfg.DbPath = Paths.Expand(ctx.UiDbPath);
```
- [ ] **Step 2: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
git commit -m "fix(installer): expand ~ in UiDbPath"
```
---
## Info
### Task 13: Installer — verify App.xaml.cs WPF-vs-Avalonia usings (I6)
Suspected bug: `src/ClaudeDo.Installer/App.xaml.cs` uses `System.Windows` (WPF). If the project is Avalonia, wrong base class is inherited.
**Files:**
- Read: `src/ClaudeDo.Installer/App.xaml.cs`
- Read: `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
- [ ] **Step 1: Inspect the csproj for the UI framework SDK**
Read `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` and look for `<UseWPF>` or `<PackageReference Include="Avalonia" ... />`.
- [ ] **Step 2: Decision fork**
- If WPF (`<UseWPF>true</UseWPF>`): `System.Windows` is correct. Stop. No fix needed.
- If Avalonia: replace `using System.Windows;` with `using Avalonia;` and change `Application` / `StartupEventArgs` / `ExitEventArgs` to Avalonia equivalents (`Avalonia.Application`, lifetime `OnFrameworkInitializationCompleted`).
- [ ] **Step 3: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 4: Commit only if changed**
```bash
git add src/ClaudeDo.Installer/App.xaml.cs
git commit -m "fix(installer): use Avalonia application base class"
```
---
## Out of Scope
Deferred / not fixed in this plan:
- `TryScheduleTrampolineDelete` PID-less delay (I6 in review, low severity) — `ping -n 3` is flaky but rarely hit
- `AvailableAgents` being `List<T>` instead of `ObservableCollection<T>` (U5/info) — current `OnPropertyChanged` pattern works; revisit only if a bug manifests
---
## Self-Review Notes
- Every Worker bug (W1W3) has a regression test or tested path.
- Every UI fix names the exact file:line and shows the replacement snippet.
- Installer Task 3 (I1) does not guess a password-capture UI — it deliberately returns `StepResult.Fail`, leaving the UX change for a later plan.
- Task 13 (I6) is a conditional task with a decision fork; no speculative rewrite.
- Types are consistent: `RunCreated(taskId, runNumber, isRetry)` in Task 1 matches the existing `HubBroadcaster.RunCreated` signature used at `TaskRunner.cs:128,219`.

View File

@@ -0,0 +1,209 @@
# UI Polish — Design Parity Follow-up
> Follow-up to the islands rewrite. Closes visible gaps between the current state and the handoff mock. Execute with subagent-driven development; phases B/C/D can run in parallel.
**Goal:** Bring the rewrite to pixel-level parity with `docs/UI Rewrite/design_handoff_claudedo/ClaudeDo-standalone.html`.
**Tech stack:** Avalonia 12, CommunityToolkit.Mvvm. No new dependencies.
**Reference files:**
- Source of truth: `docs/UI Rewrite/design_handoff_claudedo/ClaudeDo-standalone.html`
- CSS measurements: `docs/UI Rewrite/design_handoff_claudedo/styles.css`
- JSX component structure: `docs/UI Rewrite/design_handoff_claudedo/islands.jsx`, `app.jsx`
- Tokens: `src/ClaudeDo.Ui/Design/Tokens.axaml`
- Styles: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
**Rules for all phases:**
- Use existing token brushes (`MossBrush`, `PeatBrush`, `AccentSoftBrush`, etc.) — do NOT hard-code hex.
- Use `Classes="foo"` + selectors in `IslandStyles.axaml` for reusable styling; inline AXAML setters for one-off values only.
- Icons: use `Projektion.Avalonia` `PathIcon` with `Data="{StaticResource IconKey}"`. Define new `StreamGeometry` resources in `IslandStyles.axaml` under an `<Icons>` section when needed. Pull the SVG paths from the JSX reference.
- Read the relevant JSX + CSS file in the handoff before implementing each component — those are the source of truth for exact measurements/paddings/colors.
- Do not touch the data layer, Worker, SignalR, or command wiring. This is a view/style-only pass.
---
## Phase A — Shell + title bar (sequential, run first)
One subagent. Small blast radius; prerequisite for the visual "feel."
### Task A1 — Custom title bar
**Files:**
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` + `.axaml.cs`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (add title-bar styles + window-control icon-button style)
- [ ] Replace the current title bar Grid with a 3-section layout:
- Left: brand block — checkbox-style green glyph + `CLAUDEDO` (mono, uppercase, tracking 1.4, 11px) + separator dot + current-list name eyebrow-style (mono uppercase, `TextDim`). Bind the list name to `Shell.Lists.SelectedList.Name.ToUpperInvariant()`.
- Middle: draggable strip (`PointerPressed → BeginMoveDrag`).
- Right: three frameless icon buttons (minimize / maximize-restore / close). Close button hover turns `BloodBrush`. Use `PathIcon` with inline `StreamGeometry` for the Lucide-style icons: `Minus`, `Square`, `X` — the exact SVG `d` strings are in `icons.jsx`.
- [ ] Title bar height: 36px, background `DeepBrush`, bottom border 1px `LineBrush`.
- [ ] Remove the character glyphs currently used for the window controls (`—`, `▢`, `✕`) — use PathIcons instead.
- [ ] Commit: `style(ui): custom title bar with brand and window controls`
### Task A2 — Background + island shadow
**Files:**
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (background layer)
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (island shadow adjust)
- [ ] Under the three-island Grid, add a `Border` filling the whole row with a subtle radial gradient from `DeepBrush` (center) to `VoidBrush` (edges). Use a `RadialGradientBrush` with 2 stops; keep opacity light.
- [ ] In `IslandStyles.axaml`, bump the `Border.island` `BoxShadow` to match the token `IslandShadow` value exactly (`0 20 40 #59000000, 0 2 4 #4D000000`). Verify by inspecting the current style — if it's already set, no-op.
- [ ] Commit: `style(ui): background gradient and stronger island shadow`
---
## Phase B — Lists island polish (parallel with C, D)
### Task B1 — Icon geometries + eyebrow rename + sections
**Files:**
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (add `StreamGeometry` icon resources at the top)
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` (map `IconKey` strings → resource keys, add section grouping)
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
- [ ] Extract the SVG path `d` strings from `icons.jsx` for: `Sun`, `Activity` (pulse), `Star`, `Calendar`, `Eye`, `Inbox`, `Folder`, `Search`, `Plus`, `MoreHorizontal`. Define each as an `x:Key="Icon.Sun"` `StreamGeometry` in `IslandStyles.axaml`.
- [ ] Change Lists eyebrow from `WORKSPACE` to `NAVIGATOR`.
- [ ] Add two section-header rows in the ItemsControl: `SMART LISTS` (above items of `Kind=Smart` + `Virtual`) and `MY LISTS` (above items of `Kind=User`). Simplest approach: two separate `ItemsControl`s bound to filtered subsets; or wrap items in a `CollectionViewSource` grouping. Pick the simplest working approach.
- [ ] Per-item icon: bind `PathIcon Data="{DynamicResource Icon.{IconKey}}"` via a tiny `IconGeometryConverter` (takes `IconKey` string → looks up resource). Icon color: `TextMute` default; `AccentBrush` (moss) when `IsActive`.
- [ ] User-list items: use a 6px circle with `MossBrush` / `PeatBrush` / `SageBrush` dot instead of folder icon (map per list index mod colors, or single color if simpler).
- [ ] Active state: remove solid fill. Use `AccentSoftBrush` (~10% moss) + left 2px accent bar + `AccentBrush` icon + `TextBrush` text.
- [ ] Commit: `style(ui): lists icons, section headers, active state`
### Task B2 — Search bar + keyboard hint + footer buttons
**Files:**
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (search style + kbd chip style)
- [ ] Search `TextBox`: wrap in a `Grid ColumnDefinitions="Auto,*,Auto"` — left `PathIcon Data="{Icon.Search}"` (14px, `TextFaint`), middle TextBox, right `Border Classes="kbd"` with `⌘K` (or `Ctrl K` on Win). The `kbd` chip: mono 10px, `Surface2` bg, `LineBrush` border, padding `6,2`, radius 4.
- [ ] Under the items list, add:
- `+ New list` button — plain icon+text row, `PathIcon Data="{Icon.Plus}"`, hover tint.
- User profile row — avatar circle (initials fallback, seed from `Environment.UserName`), name (`Environment.UserName`), subtitle `{MachineName} / local` mono dim, right `PathIcon Data="{Icon.MoreHorizontal}"`.
- [ ] Commit: `style(ui): lists search icon, kbd hint, footer actions`
---
## Phase C — Tasks island polish (parallel with B, D)
### Task C1 — Header + add-task row styling
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (subtitle format, header toolbar properties)
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (kbd-enter, add-task row)
- [ ] Subtitle format: change from `{open} open · {running} running · {review} in review` to `{Weekday}, {Month} {Day} · {open} open` to match the mock. Keep the running/review counts visible but move them into a right-aligned mono pill row next to the title (or drop if cleaner).
- [ ] Eyebrow: keep current `MONTAG · APR. 20` pattern. Title remains list name.
- [ ] Right-side icon toolbar: three `Button Classes="icon-btn"``Sort` icon, `Eye` icon (toggle completed), `MoreHorizontal`. Icons: pull paths from `icons.jsx`. Wire `Eye` to an `IsShowingCompleted` observable (persist in a private field for now; no DB change).
- [ ] Add-task row: wrap the `TextBox` in a `Border` with `Surface2` bg, rounded 8px, 14px padding. Prepend a circular `PathIcon Data="{Icon.Plus}"` (20px circle, `Surface3` bg). Append a `Border Classes="kbd"` with `ENTER` text (only visible when `NewTaskTitle` has focus — bind visibility to `TextBox.IsFocused`).
- [ ] Commit: `style(ui): tasks header toolbar and add-task row`
### Task C2 — Task row chips + states
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` (expose a few more flags: `IsOverdue`, `Tags`, `StepsCount`, `StepsCompleted`)
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (chip variants, selected accent, done state, live-tail meter)
- [ ] Chip set per row (ItemsControl or StackPanel):
- Status chip (already present) — ensure color maps per Status → token brush (idle/queued/running/review/error).
- List chip — small colored bullet (6px circle in `MossBrush` or similar) + list name.
- Branch chip — `PathIcon Data="{Icon.GitBranch}"` (12px) + branch name (mono 10px).
- Diff chip — `+N` moss + ` ` + `M` blood.
- Tags — one chip per tag (`#refactor` style, `Surface2` bg, mono 10px, `TextDim`).
- [ ] Selected state: add 2px `AccentBrush` left border on the row Border when `IsSelected=true` (style selector `Border.task-row.selected`). Background shifts to `AccentSoftBrush`.
- [ ] Done state: strike-through title + fade opacity to 0.5. Add `Border.task-row:has(.done)` equivalent via the existing `Done` binding — simpler: a `TextBlock` style selector that flips `TextDecorations`.
- [ ] Live-tail row (only visible when `Status == Running` and `LiveTail != null`): a `Border` under the chip row with mono 11px ellipsized text + a slim 3px progress `Rectangle` with `MossBrush`. For now the progress is static 30% — wire it to a future `ProgressFraction` property (leave as 0.3 fallback).
- [ ] Ensure `task-row` Border has `Transitions` for `Background` + `Margin` (smooth hover + select).
- [ ] Commit: `style(ui): task row chip set, selected/done states, live tail`
### Task C3 — Section dividers (OVERDUE / TASKS / COMPLETED)
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (group the ObservableCollection into sections)
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` (group headers)
- [ ] Add grouping: transform `Items` into three sub-collections:
- `OverdueItems` — tasks with `ScheduledFor < Today` and not Done.
- `OpenItems` — remaining not-Done tasks.
- `CompletedItems` — tasks with `Done=true`.
- [ ] Expose as three `ObservableCollection<TaskRowViewModel>` on the VM. Recompute inside `LoadForList`.
- [ ] View: three `ItemsControl`s stacked in a `StackPanel`, each preceded by a section header `TextBlock``OVERDUE` (only if non-empty), `TASKS`, `COMPLETED · {N}`. Eyebrow style, `TextFaint`.
- [ ] Commit: `style(ui): task section dividers overdue/tasks/completed`
---
## Phase D — Details island polish (parallel with B, C)
### Task D1 — Header + task row restyle
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (expose `TaskIdBadge` like `#T1`, computed from task id prefix)
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- [ ] Top header block:
- Eyebrow `LOGBOOK` + right-aligned `#T{shortId}` badge (first 3 hex chars of `Task.Id`, mono `TextFaint`).
- Title: keep editable title `TextBox` but reduce size and match mock.
- [ ] Under header, a new "task strip" row: `Ellipse` checkbox (bound to `Task.Done` toggle) + title + right-aligned star button. This is separate from the editable title (mock shows both title as editable heading AND a task-row-style strip with check/star).
- [ ] Commit: `style(ui): details header with logbook eyebrow and task-id badge`
### Task D2 — Agent strip v2
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (add `Turns`, `TokensFormatted`, `ElapsedFormatted`, `DiffAdditions`, `DiffDeletions`, `CommitsOnBranch` if not present — most exist)
- `src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (diff meter bar style)
- [ ] Layout (three rows):
- Row 1: pulsing status dot + status label (`RUNNING` etc.) + mono model name + right-aligned stop button (only visible when Running).
- Row 2: `WORKTREE` section label + worktree path mono, with a copy-to-clipboard `PathIcon Data="{Icon.Copy}"` button at the end.
- Row 3: Branch line — `PathIcon Data="{Icon.GitBranch}"` + branch mono + arrow `←` + `main` + commits count chip.
- Row 4: `DIFF` label + `+{additions}` (moss) + `{deletions}` (blood) + a slim 4px progress-meter `Rectangle` showing additions vs deletions ratio (moss-filled portion).
- [ ] Action buttons row: `Open diff`, `Worktree`, external-link `→` (opens file:// to worktree path in OS explorer).
- [ ] Agent strip should use `AgentStripStyle.Classes` bound to the running status so colors shift.
- [ ] Commit: `style(ui): agent strip with worktree panel and diff meter`
### Task D3 — Session terminal styling
**Files:**
- `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (terminal header, log-line columns, `LIVE` chip)
- `src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs` (add `TimestampFormatted` property)
- [ ] Top bar of the terminal `Border`: three colored dots (red/yellow/green, 8px `Ellipse`) + `claude-session · {branch}` mono text + right-aligned `LIVE` chip (moss bg, white text, pulsing animation when a task is actively running).
- [ ] Log lines: two-column layout — timestamp (mono 10px, `TextFaint`, fixed 70px width) + kind marker (e.g. `TOOL`, `CLAUDE`, `OUT`) + text. Kind marker uses attribute selector `[Tag=log-tool]`, color-mapped.
- [ ] Line number/timestamp: add `TimestampFormatted` to `LogLineViewModel` populated as `DateTime.Now.ToString("HH:mm:ss")` on construction. (If real timestamps arrive via SignalR later, swap source.)
- [ ] Ensure auto-scroll still works (existing logic).
- [ ] Commit: `style(ui): session terminal header, line columns, LIVE chip`
### Task D4 — Subtasks, notes, metadata footer
**Files:**
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (subtask row style)
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (delete-task command, close-detail command)
- [ ] Subtasks: each row is a compact `Border` with rounded 6px, hover background. Check is an `Ellipse` matching the task-row style (not default WinForms-style CheckBox). Completed items get strike-through + fade.
- [ ] Notes `TextBox`: `Surface2` bg, 12px padding, watermark `Notes...`, auto-saves on `LostFocus` (call repository `Update`).
- [ ] Bottom metadata bar (sticky at the bottom of the Details island — anchor via `DockPanel.Dock="Bottom"`):
- Left: `PathIcon Data="{Icon.Trash}"` delete button (prompts confirmation before calling `TaskRepository.DeleteAsync`).
- Middle: `Created {Month Day}` mono `TextFaint`.
- Right: close-details `PathIcon Data="{Icon.X}"` (clears `SelectedTask` on `TasksIslandViewModel`).
- [ ] Commit: `style(ui): subtasks, notes, details metadata footer`
---
## Execution order
```
Phase A (A1 → A2) [sequential, 1 subagent]
Phase B, C, D [parallel, 3 subagents, one per phase]
Final build + smoke
```
Phase A is sequential because it touches `MainWindow.axaml` and `IslandStyles.axaml` root setup.
Phases B, C, D each own a distinct island. Only potential conflict: all three add icon geometries to `IslandStyles.axaml`. Mitigation: Phase B is responsible for adding the `StreamGeometry` icon resources (it needs the most). Phases C and D reference those keys without redefining.
Final pass: run the app, eyeball against the mock, note remaining gaps.

File diff suppressed because it is too large Load Diff

View 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).

View File

@@ -0,0 +1,141 @@
# Stream Formatter Rewrite — Design
**Date:** 2026-04-21
**Scope:** `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
## Problem
`StreamLineFormatter` converts Claude CLI stream-json lines into human-readable
text for the Details pane. The current implementation only recognizes:
- `type=stream_event` — dead code (requires `--include-partial-messages`, which
the Worker does not pass)
- `type=result` — shown as `--- Result ---` block
- `type=system` with `subtype=api_retry`
Everything else — notably `assistant` and `user` messages that carry the actual
conversation and tool activity — falls through to `default: return null` and is
silently dropped. The Details pane is therefore mostly empty during a run,
while the raw `.log` file retains the full JSON.
## Goal
Rewrite the formatter so every meaningful message type is rendered as one or
more compact text lines suitable for the live log in the Details pane. The
public API (`FormatLine(string)` / `FormatFile(string)`) and the existing
buffer/trim behavior in `DetailsIslandViewModel` stay the same.
## Input format
The Worker invokes the Claude CLI with:
```
claude -p --output-format stream-json --verbose --dangerously-skip-permissions ...
```
Each stdout line is one complete SDK message. Top-level shapes relevant to the
formatter:
```jsonc
// Session start
{"type":"system","subtype":"init","session_id":"…","model":"claude-…", }
// API retry notification
{"type":"system","subtype":"api_retry", }
// Assistant reply (text + tool calls)
{"type":"assistant","message":{"role":"assistant","content":[
{"type":"text","text":"…"},
{"type":"tool_use","id":"toolu_…","name":"Read","input":{"file_path":"…"}}
]}, }
// Tool result fed back to the model
{"type":"user","message":{"role":"user","content":[
{"tool_use_id":"toolu_…","type":"tool_result","content":"… or [ {type,text} ] …","is_error":false}
]}, "tool_use_result":{optional rich payload}, }
// Final result
{"type":"result","result":"…", }
```
Notes on quirks already observed in captured output:
- `tool_result.content` is sometimes a plain string, sometimes an array of
`{type:"text", text:"…"}` blocks. Handle both.
- The envelope may include `tool_use_result.file.numLines` / `file.filePath`
for Read-style results.
- Assistant messages may contain `thinking` blocks (filtered, not displayed).
## Output format
One line per logical event. A trailing `\n` ends each line so the
`DetailsIslandViewModel` buffer splits cleanly.
| Input | Output |
|---|---|
| `system` / `init` | `[session <id8> · <model>]\n` |
| `system` / `api_retry` | `[Retrying API call...]\n` |
| `system` / other | `null` (filtered) |
| `assistant` text block | `<text>\n` (raw) |
| `assistant` tool_use block | `[<ToolLabel>] <arg>\n` (see below) |
| `assistant` thinking block | `null` (filtered) |
| `user` tool_result block | `→ <summary>\n` (see below) |
| `result` | `\n--- Result ---\n<text>\n` |
| unrecognized / parse failure | raw line (existing behavior for non-JSON) |
A single `assistant` message with N content blocks produces N output lines,
concatenated into one return string.
### Tool label + arg
Pick the most identifying input field per tool:
| Tool name | Display |
|---|---|
| `Read`, `Write`, `Edit`, `NotebookEdit` | `[<Tool>] <basename(file_path)>` |
| `Bash`, `PowerShell` | `[Bash] $ <command>` — truncate command at 120 chars, append `…` |
| `Grep` | `[Grep] "<pattern>"` |
| `Glob` | `[Glob] <pattern>` |
| `Task`, `Agent` | `[Task: <subagent_type>] <description>` (description truncated to 120) |
| `WebFetch` | `[WebFetch] <url>` |
| `WebSearch` | `[WebSearch] "<query>"` |
| `TodoWrite` | `[TodoWrite]` (no arg) |
| fallback | `[<name>]` |
Missing or empty input fields → emit the label only, no trailing text.
### tool_result summary
For each `tool_result` block in a `user` message, in priority order:
1. `is_error == true``→ error: <first non-empty line, trimmed, ≤120 chars>`
2. Envelope has `tool_use_result.file.numLines``→ <N> lines`
3. Content resolves to empty/whitespace string → `→ ok`
4. Otherwise → `→ <first non-empty line, ≤120 chars>` (append `…` if truncated)
Content resolution: if `content` is a string, use it; if it's an array, join
the `text` fields of `{type:"text"}` entries.
## Non-goals
- No changes to `DetailsIslandViewModel` or the Worker pipeline.
- No collapsible/rich rendering — tool results stay one-liners.
- No persistence changes — the raw `.log` file still contains full JSON for
debugging.
- No unit tests in this change (separate workload).
## Out of scope
- Partial-token streaming (`--include-partial-messages`). The existing
`stream_event` branch is removed as dead code.
- Structured output / `--json-schema` rendering beyond the final `result`.
## Risks / edge cases
- **Unknown tool names** — fallback label `[<name>]` keeps output readable.
- **Malformed JSON inside a valid envelope** (e.g. missing `message.content`)
— skip the broken block, emit what we can; never throw.
- **Very long Bash commands or search queries** — 120-char truncation with `…`
keeps lines reasonable while preserving the prefix.
- **Binary or huge tool_result content** — summary rules 24 cap output at a
single line; full content stays in the raw log.