From cda387015aa849e00d168a86a163a6d9b0b1b079 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 19:40:26 +0200 Subject: [PATCH 01/12] [DEV-1573] Spec: Spectre.Console for setup wizard + history import Design document for narrowing Spectre.Console adoption to the two most human-facing commands (setup, history), with explicit non-TTY fallbacks and no Spectre.Console.Cli dependency to keep the AOT/trim build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-04-23-spectre-console-cli-ux-design.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-23-spectre-console-cli-ux-design.md diff --git a/docs/superpowers/specs/2026-04-23-spectre-console-cli-ux-design.md b/docs/superpowers/specs/2026-04-23-spectre-console-cli-ux-design.md new file mode 100644 index 0000000000..53bf036a37 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-spectre-console-cli-ux-design.md @@ -0,0 +1,235 @@ +# DEV-1573 — Spectre.Console CLI UX: setup wizard + history import + +## Summary + +Adopt [Spectre.Console](https://github.com/spectreconsole/spectre.console) for the two most human-facing commands in the kapacitor CLI — `setup` and `history` — replacing ad-hoc `Console.Write*` calls with Spectre's rendering primitives (prompts, status spinners, rules, grids, and pinned progress). Scope is deliberately narrow: these two commands have the most prompts and the longest-running output, and they benefit the most from live feedback. All other commands (hook handlers, `watch`, daemon, MCP servers, etc.) remain untouched. + +Non-goals: + +- Adopting `Spectre.Console.Cli` (the command/binder framework). It is reflection-heavy, noisy under trim/AOT, and we have working hand-rolled arg parsing. +- Introducing an `IAnsiConsole` injection layer. Existing tests assert on file I/O and pure helpers — not stdout — so the static `AnsiConsole` API is sufficient. +- Reworking hook commands (`session-start`, `session-end`, `subagent-*`, `stop`, `notification`, `pre-compact`) or any command whose stdout is consumed by another process. + +## Context + +The CLI currently uses raw `Console.Write` / `Console.ReadLine` with manual `✓`/`✗` glyphs. The two commands that show the UX weakness most sharply: + +- **`setup`** — a 5-step wizard (`src/kapacitor/Commands/SetupCommand.cs`) that asks the user to enter a server URL, log in via OAuth, pick default visibility (1/2/3 prompt), pick plugin scope (1/2/3 prompt), and name the daemon. Section headers, choice prompts, and a final summary — all suitable for Spectre primitives. +- **`history`** — a long-running import that discovers transcripts, processes them one by one, and runs background title/summary generation. A recent 1.5K-session import showed the current one-line-per-session output is functional but lacks a sense of progress, and the agent-inline import count only appears as a trailing line instead of surfacing during the work. + +## Constraints + +1. **AOT / trim safety.** The CLI publishes with `PublishAot=true` and `TrimMode=full`. After wiring Spectre, the publish build must remain clean of `IL2026` and `IL3050` warnings. Spectre.Console (core) is AOT-annotated as of current versions; `Spectre.Console.Cli` is not adopted for exactly this reason. +2. **Non-TTY correctness.** `history` is frequently piped (`| tee import.log`, CI logs, redirected output). `history`'s existing code already inspects `Console.IsInputRedirected` to short-circuit an interactive prompt. We extend the same discipline: when `Console.IsOutputRedirected` is true, fall back to the current plain one-line-per-session output. `setup` already has `--no-prompt` as the non-interactive escape hatch. +3. **Preserve existing `history` output format when piped.** Current lines like `Loading {sid}... {N} lines [new]` and `Skipping {sid} [too short: 4 lines < 10 minimum]` are preserved verbatim in the non-TTY path so existing scripts don't break. +4. **Tests unaffected.** `HistoryCommandTests` and `SetupCommandTests` exercise file I/O and pure helpers (metadata extraction, plugin installer JSON). They do not capture stdout. No injection layer required. + +## Dependencies + +Add a single package reference to `src/kapacitor/kapacitor.csproj`: + +```xml + +``` + +Do **not** add `Spectre.Console.Cli`. + +## Setup wizard — design + +File: `src/kapacitor/Commands/SetupCommand.cs`. + +All interactive output routes through `AnsiConsole`. The flag-driven `--no-prompt` path uses the same rendering primitives (Rule, Grid) so CI logs are readable too; only the prompt calls are bypassed. + +### Structure + +- `AnsiConsole.Write(new FigletText("kapacitor").Color(Color.Green));` — one-time banner at the top (optional; omit if users find it noisy, easy to remove). +- Each step renders a section header: `AnsiConsole.Write(new Rule("[yellow]Step 1/5 — Server[/]").LeftJustified());`. + +### Step 1 — Server + +- Prompt: + ```csharp + serverUrl = AnsiConsole.Prompt( + new TextPrompt("Capacitor server URL:") + .Validate(u => !string.IsNullOrWhiteSpace(u), "[red]URL cannot be empty[/]")); + ``` +- Reachability probe inside a status spinner: + ```csharp + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Checking server…", async _ => { + provider = await HttpClientExtensions.DiscoverProviderAsync(serverUrl); + }); + AnsiConsole.MarkupLine($" [green]✓[/] Reachable · auth provider: [cyan]{provider}[/]"); + ``` + +### Step 2 — Login + +- If `provider == "None"`: `AnsiConsole.MarkupLine(" [dim]Auth provider is None — no login required.[/]");`. +- Otherwise the existing `OAuthLoginFlow.LoginWithDiscoveryAsync` runs. It already writes user-visible output; we don't wrap that in a spinner (the OAuth flow needs the user's attention in a browser). After success: `AnsiConsole.MarkupLine($" [green]✓[/] Logged in as [bold]{tokens.GitHubUsername}[/]");`. + +### Step 3 — Default visibility + +Replace the numbered prompt with a `SelectionPrompt`: + +```csharp +var visibility = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("How should your sessions be visible to others by default?") + .AddChoices("org_public", "private", "public") + .UseConverter(v => v switch { + "private" => "All private — only you can see your sessions", + "org_public" => "Org repos public, others private (default)", + "public" => "All public — everyone can see all your sessions", + _ => v + })); +``` + +The value stored is the canonical short form (`private` / `org_public` / `public`) — matching the current persisted values so config remains compatible. + +### Step 4 — Plugin scope + +Same `SelectionPrompt` shape with choices `user`, `project`, `skip`. Converter renders the long-form labels from today's code. Flow below the prompt (install vs. skip) is identical to current. + +### Step 5 — Daemon name + +`TextPrompt` with `DefaultValue(Environment.UserName.ToLowerInvariant())` and `ShowDefaultValue(true)`. + +### Final summary + +Replace the trailing series of `Console.WriteLine` calls with a `Grid`: + +```csharp +var grid = new Grid().AddColumn().AddColumn(); +grid.AddRow("[bold]Server[/]", serverUrl); +grid.AddRow("[bold]Visibility[/]", visibility); +grid.AddRow("[bold]Daemon[/]", daemonName); +if (finalTokens is not null) + grid.AddRow("[bold]Auth[/]", $"{finalTokens.GitHubUsername} ({finalTokens.Provider})"); +grid.AddRow("[bold]Config[/]", AppConfig.GetConfigPath()); + +AnsiConsole.Write(new Rule("[green]Setup complete[/]").LeftJustified()); +AnsiConsole.Write(grid); +AnsiConsole.MarkupLine("\n[dim]Optional:[/] start the agent daemon with [cyan]kapacitor agent start -d[/]"); +``` + +### `--no-prompt` behavior + +Unchanged at the logic level. Outputs are still rendered through `AnsiConsole` (Rule, Grid) so non-interactive CI output still looks coherent, but no `Prompt()` calls are made. Failure modes (missing required flags) call `AnsiConsole.MarkupLine("[red] --server-url is required with --no-prompt[/]");` and return non-zero. + +## History import — design + +File: `src/kapacitor/Commands/HistoryCommand.cs`, with a minor interface change in `src/kapacitor/Commands/SessionImporter.cs`. + +### Display model + +A single `AnsiConsole.Progress()` block wraps the session loop. Inside it, **one** top-level task is pinned as the footer. The task's `Description` is mutated as work progresses — we do NOT create one task per session (1.5K tasks would be hostile to Spectre and to the terminal scrollback). + +Footer description template (where `{sessionId8}` and `{agentId8}` are the first 8 characters of the respective GUID): + +``` +Importing {done}/{total} · {sessionId8}: {linesDone}/{linesTotal} lines{agentSuffix} +``` + +`agentSuffix` is empty except while importing a subagent transcript inline, in which case it reads ` ↳ subagent {agentId8}`. + +Completion lines are streamed above the pinned footer via `AnsiConsole.MarkupLine`. Formats preserved verbatim from today: + +- `Loading {sid}... {linesSent} lines [new]` +- `Skipping {sid} [already loaded]` +- `Skipping {sid} [too short: {n} lines < {min} minimum]` +- `Skipping {sid} [server error: HTTP {code}]` +- `Skipping {sid} [server unreachable: {reason}]` + +New streamed lines (replacing today's trailing `" N agents imported inline"` summary line): + +- ` ↳ imported subagent {agentId} ({N} lines)` — emitted once per subagent as it lands, not at the end of the parent session. + +### Importer progress hook + +Add an optional `IProgress? progress` parameter to `SessionImporter.ImportSessionAsync` and thread it into `SendTranscriptBatches` and `SendAgentTranscriptInline`. Events: + +```csharp +public abstract record ImportProgress; +public sealed record BatchFlushed(int LinesAdded) : ImportProgress; +public sealed record SubagentStarted(string AgentId) : ImportProgress; +public sealed record SubagentFinished(string AgentId, int LinesSent) : ImportProgress; +``` + +- `BatchFlushed` fires after each 100-line batch flush in `SendTranscriptBatches`. +- `SubagentStarted` fires when `SendAgentTranscriptInline` begins importing a subagent's transcript; `SubagentFinished` fires after the last batch. +- `progress` defaults to `null` so the importer remains usable outside `history` (e.g. any future caller) without forcing console dependencies on it. + +The `HistoryCommand` consumer maps events to footer updates and streamed lines: + +- `BatchFlushed` → update `task.Description`, incrementing the inner `linesDone` counter. +- `SubagentStarted` → set the agent suffix on the footer description. +- `SubagentFinished` → clear the agent suffix; emit a streamed line ` ↳ imported subagent {id} ({N} lines)`. + +### Background phase (titles / summaries) + +After the main import loop, if `backgroundTasks.Count > 0`, emit a separator and start a second `Progress` block: + +```csharp +AnsiConsole.Write(new Rule($"[dim]── Waiting for {backgroundTasks.Count} background task(s) ──[/]").LeftJustified()); +``` + +Inside this block, two tasks: + +- `Titles 0/{N}` +- `Summaries 0/{M}` + +Their `Increment(1)` is called from inside the existing `Task.Run` continuations (replacing the `Interlocked.Increment` counter-only tracking today). The counters (`titlesGenerated`, `summariesGenerated`, `titlesFailed`, `summariesFailed`) remain so the final summary line can print them. + +**Failures stream individually** below the bar so the user can inspect them — each is a `MarkupLine`: + +- ` [red]✗[/] title failed for {sessionId}: {reason}` +- ` [red]✗[/] summary failed for {sessionId}: {reason}` + +Successes do not stream (too noisy at scale). + +### Final summary + +One line (current format, slightly colorized): + +``` +Done: [green]{loaded}[/] loaded, [green]{resumed}[/] resumed, {skipped} skipped[, [red]{errored}[/] errored] +``` + +Followed by the title/summary counts only when the background phase ran. + +### Non-TTY fallback + +When `Console.IsOutputRedirected` is true: + +- Skip the Spectre `Progress` block entirely. +- Print the current plain lines (all the `Console.WriteLine` calls already there today), with the only addition being per-subagent completion lines: ` ↳ imported subagent {id} ({N} lines)`. +- The background phase header becomes a plain line: `Waiting for N background tasks (titles/summaries)...`. + +This path must remain the default for CI, piped output, and users who prefer the current behavior. + +## AOT verification + +After implementation, the following must produce zero output (matching current CLAUDE.md guidance): + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +``` + +If any `IL2026` / `IL3050` warning appears, the specific Spectre API that triggered it is either replaced with a non-reflection alternative or the feature is dropped. + +## Testing + +- `HistoryCommandTests` and `SetupCommandTests` continue to pass unchanged (they do not assert on stdout). +- Manual verification: + - `kapacitor setup` — run interactively, confirm arrow-key selection, status spinner, final grid. + - `kapacitor setup --no-prompt --server-url https://example.com --default-visibility private --plugin-scope skip` — confirm rendering in a non-TTY context is still coherent. + - `kapacitor history --min-lines 10` — confirm pinned footer updates, completion lines stream above, subagent `↳` indicator appears live, final summary matches today's counts. + - `kapacitor history --min-lines 10 > out.log` — confirm the fallback path emits today's plain lines. + - `kapacitor history --min-lines 10 --generate-summaries` — confirm background phase renders its own progress block and failures stream. + +## Out of scope (explicit) + +- `eval`, `review`, `status`, `repos`, `profile`, `config`, `agent start` — not touched in this change. Candidates for a follow-up scoped ticket if this pattern works well. +- Introducing a shared `IConsoleUx` abstraction. +- Localization / colorblind palette switches (Spectre respects `NO_COLOR` by default, which is sufficient for now). From 9a9762101d5c01ccecfcf7d48d65fb0e8db19bd8 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 19:48:15 +0200 Subject: [PATCH 02/12] [DEV-1573] Plan: Spectre.Console for setup wizard + history import Implementation plan for DEV-1573. Ten bite-sized tasks covering package addition + AOT gate, ImportProgress event hierarchy, TDD-driven importer wiring, setup wizard overhaul, HistoryDisplay abstraction with TTY vs piped gate, and background-phase progress with streaming failure lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-23-spectre-console-cli-ux.md | 1368 +++++++++++++++++ 1 file changed, 1368 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-spectre-console-cli-ux.md diff --git a/docs/superpowers/plans/2026-04-23-spectre-console-cli-ux.md b/docs/superpowers/plans/2026-04-23-spectre-console-cli-ux.md new file mode 100644 index 0000000000..86bbbcf376 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-spectre-console-cli-ux.md @@ -0,0 +1,1368 @@ +# Spectre.Console CLI UX 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:** Adopt Spectre.Console for kapacitor's two most human-facing commands — `setup` and `history` — replacing ad-hoc `Console.Write*` with prompts, status spinners, rules, grids, and pinned progress while preserving current behavior on piped/non-TTY output and keeping the AOT/trim publish clean. + +**Architecture:** Add the `Spectre.Console` rendering package (NOT `Spectre.Console.Cli`). Extend `SessionImporter` with an optional `IProgress` hook so the importer remains console-agnostic while `HistoryCommand` drives a live footer. Introduce a tiny private `HistoryDisplay` struct inside `HistoryCommand.cs` that branches once on `Console.IsOutputRedirected` and exposes completion-line/footer-update methods to the main loop. `SetupCommand` calls `AnsiConsole` directly (prompts + status + grid). No shared console abstraction across commands. + +**Tech Stack:** .NET 10, NativeAOT (`PublishAot=true`, `TrimMode=full`), TUnit test framework, WireMock.Net for HTTP mocking, Spectre.Console `0.*`. + +**Spec:** `docs/superpowers/specs/2026-04-23-spectre-console-cli-ux-design.md` + +--- + +## File map + +- **Create:** `src/kapacitor/Commands/ImportProgress.cs` — event record hierarchy for importer progress callbacks. +- **Modify:** `src/kapacitor/kapacitor.csproj` — add `Spectre.Console` package reference. +- **Modify:** `src/kapacitor/Commands/SessionImporter.cs` — add optional `IProgress?` parameter to `ImportSessionAsync` and `SendTranscriptBatches`; fire `BatchFlushed` / `SubagentStarted` / `SubagentFinished` events. +- **Modify:** `src/kapacitor/Commands/SetupCommand.cs` — replace `Console.Write`/`ReadLine` with Spectre prompts, rules, status spinner, final grid. +- **Modify:** `src/kapacitor/Commands/HistoryCommand.cs` — introduce private `HistoryDisplay` struct; branch TTY vs piped; Spectre `Progress` footer + streamed completion lines; background-phase progress block with individual failure streaming. +- **Create:** `test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs` — TDD tests for the new `IProgress` wiring. + +--- + +## Task 1: Add Spectre.Console package and baseline AOT gate + +**Files:** +- Modify: `src/kapacitor/kapacitor.csproj` + +- [ ] **Step 1: Capture baseline publish output (no changes yet)** + +Run: +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' | tee /tmp/kapacitor-aot-baseline.txt +``` +Expected: zero lines (baseline should already be clean per CLAUDE.md). + +If non-empty, stop — investigate existing warnings before adding Spectre. + +- [ ] **Step 2: Add the package reference** + +Edit `src/kapacitor/kapacitor.csproj`. In the existing `` that contains `` entries (the one with `IdentityModel.OidcClient`, `Microsoft.AspNetCore.SignalR.Client`, etc.), add: + +```xml + +``` + +- [ ] **Step 3: Restore and build** + +```bash +dotnet build src/kapacitor/kapacitor.csproj +``` +Expected: build succeeds. + +- [ ] **Step 4: Verify AOT publish is still clean** + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +``` +Expected: zero lines. If warnings appear, they will be specific to Spectre's annotations — capture the output, stop, and post in the ticket before proceeding. + +- [ ] **Step 5: Commit** + +```bash +git add src/kapacitor/kapacitor.csproj +git commit -m "[DEV-1573] Add Spectre.Console package reference + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Define ImportProgress event hierarchy + +**Files:** +- Create: `src/kapacitor/Commands/ImportProgress.cs` + +- [ ] **Step 1: Create the file** + +Write `src/kapacitor/Commands/ImportProgress.cs`: + +```csharp +namespace kapacitor.Commands; + +/// +/// Progress events emitted by +/// and for UI layers that +/// want to render a live view of an in-flight import. +/// +public abstract record ImportProgress; + +/// Fired after a transcript batch is flushed to the server. +public sealed record BatchFlushed(int LinesAdded) : ImportProgress; + +/// Fired when the importer begins streaming a subagent's transcript inline. +public sealed record SubagentStarted(string AgentId) : ImportProgress; + +/// Fired after a subagent's transcript has been fully streamed. +public sealed record SubagentFinished(string AgentId, int LinesSent) : ImportProgress; +``` + +- [ ] **Step 2: Build to check it compiles** + +```bash +dotnet build src/kapacitor/kapacitor.csproj +``` +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add src/kapacitor/Commands/ImportProgress.cs +git commit -m "[DEV-1573] Define ImportProgress event hierarchy + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Thread IProgress through SendTranscriptBatches (TDD — BatchFlushed) + +**Files:** +- Create: `test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs` +- Modify: `src/kapacitor/Commands/SessionImporter.cs:425-474` (signature + firing site inside the two flush points) + +- [ ] **Step 1: Write the failing test** + +Create `test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs`: + +```csharp +using kapacitor.Commands; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace kapacitor.Tests.Unit; + +public class SessionImporterProgressTests : IDisposable { + readonly WireMockServer _server = WireMockServer.Start(); + + public void Dispose() => _server.Stop(); + + [Test] + public async Task SendTranscriptBatches_fires_BatchFlushed_per_100_lines() { + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + // Write 250 non-blank JSONL lines → expect 3 flushes: 100, 100, 50 + var path = Path.GetTempFileName(); + try { + await File.WriteAllLinesAsync(path, Enumerable.Range(0, 250).Select(i => + $$"""{"type":"user","timestamp":"2026-03-15T10:00:00Z","message":{"content":"line-{{i}}"}}""" + )); + + var events = new List(); + var progress = new Progress(events.Add); + + using var client = new HttpClient(); + var totalSent = await SessionImporter.SendTranscriptBatches( + client, _server.Url!, sessionId: "test", filePath: path, + agentId: null, startLine: 0, progress: progress + ); + + await Assert.That(totalSent).IsEqualTo(250); + + // Progress marshals via SynchronizationContext; give it a tick + await Task.Delay(50); + + var flushes = events.OfType().ToList(); + await Assert.That(flushes.Count).IsEqualTo(3); + await Assert.That(flushes[0].LinesAdded).IsEqualTo(100); + await Assert.That(flushes[1].LinesAdded).IsEqualTo(100); + await Assert.That(flushes[2].LinesAdded).IsEqualTo(50); + } finally { + File.Delete(path); + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (method signature doesn't exist yet)** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/SessionImporterProgressTests/*" +``` +Expected: compilation error — `SendTranscriptBatches` has no `progress` parameter. + +- [ ] **Step 3: Update the signature and fire BatchFlushed at both flush sites** + +In `src/kapacitor/Commands/SessionImporter.cs`, change `SendTranscriptBatches` (currently lines 425-474). Add `IProgress? progress = null` as the last parameter, and call `progress?.Report(new BatchFlushed(batchLines.Count))` immediately after each `await PostTranscriptBatch(...)`. + +Replace the method body with: + +```csharp +internal static async Task SendTranscriptBatches( + HttpClient httpClient, + string baseUrl, + string sessionId, + string filePath, + string? agentId, + int startLine, + IProgress? progress = null + ) { + if (!File.Exists(filePath)) return 0; + + var totalSent = 0; + var batchLines = new List(); + var batchLineNumbers = new List(); + const int batchSize = 100; + + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + + var lineIndex = 0; + + while (await reader.ReadLineAsync() is { } line) { + if (lineIndex < startLine) { + lineIndex++; + + continue; + } + + if (!string.IsNullOrWhiteSpace(line)) { + batchLines.Add(line); + batchLineNumbers.Add(lineIndex); + } + + lineIndex++; + + if (batchLines.Count >= batchSize) { + await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId, batchLines, batchLineNumbers); + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); + batchLines.Clear(); + batchLineNumbers.Clear(); + } + } + + if (batchLines.Count > 0) { + await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId, batchLines, batchLineNumbers); + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); + } + + return totalSent; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/SessionImporterProgressTests/*" +``` +Expected: 1 test passed. + +- [ ] **Step 5: Run the full unit suite to confirm nothing else broke** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/kapacitor/Commands/SessionImporter.cs test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs +git commit -m "[DEV-1573] Emit BatchFlushed progress events from SendTranscriptBatches + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Wire progress into ImportSessionAsync and subagent lifecycle (TDD — SubagentStarted/Finished) + +**Files:** +- Modify: `test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs` (add test) +- Modify: `src/kapacitor/Commands/SessionImporter.cs:17-116` (add progress param to `ImportSessionAsync`, thread through batch flushes and `SendAgentLifecycle`) +- Modify: `src/kapacitor/Commands/SessionImporter.cs:369-420` (`SendAgentLifecycle` accepts progress, fires Subagent events around the transcript stream) + +- [ ] **Step 1: Write the failing test** + +Append to `test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs` (inside the class, after the first test): + +```csharp +[Test] +public async Task ImportSessionAsync_fires_subagent_events_around_inline_import() { + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + _server.Given(Request.Create().WithPath("/hooks/subagent-start").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + _server.Given(Request.Create().WithPath("/hooks/subagent-stop").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + // Layout: + // /.jsonl + // //subagents/agent-.jsonl + var tmp = Directory.CreateTempSubdirectory("kapacitor-import-test"); + try { + var sessionName = Guid.NewGuid().ToString("N"); + var sessionPath = Path.Combine(tmp.FullName, $"{sessionName}.jsonl"); + var agentsDir = Path.Combine(tmp.FullName, sessionName, "subagents"); + Directory.CreateDirectory(agentsDir); + + var agentId = Guid.NewGuid().ToString("N"); + var agentPath = Path.Combine(agentsDir, $"agent-{agentId}.jsonl"); + + // Parent transcript: one assistant tool_use launching a Task, plus an + // agent_progress event that pins the interleave line. + await File.WriteAllLinesAsync(sessionPath, [ + $$"""{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tu1","name":"Task","input":{"subagent_type":"code-reviewer"}}]}}""", + $$"""{"type":"progress","data":{"type":"agent_progress","agentId":"{{agentId}}"}}""", + $$"""{"type":"user","timestamp":"2026-03-15T10:00:00Z","message":{"content":"after"}}""" + ]); + + // Agent transcript: 3 lines → 1 BatchFlushed of 3. + await File.WriteAllLinesAsync(agentPath, [ + $$"""{"type":"user","timestamp":"2026-03-15T10:00:00Z","message":{"content":"a1"}}""", + $$"""{"type":"assistant","timestamp":"2026-03-15T10:00:01Z","message":{"content":"a2"}}""", + $$"""{"type":"user","timestamp":"2026-03-15T10:00:02Z","message":{"content":"a3"}}""" + ]); + + var events = new List(); + var progress = new Progress(events.Add); + + using var client = new HttpClient(); + var result = await SessionImporter.ImportSessionAsync( + client, _server.Url!, sessionPath, sessionId: "s1", + new SessionMetadata { Cwd = "/x" }, encodedCwd: null, + progress: progress + ); + + await Task.Delay(50); + + await Assert.That(result.AgentIds).Contains(agentId); + + var ordered = events.ToList(); + // Expect SubagentStarted(agentId) before any BatchFlushed from the agent, + // then SubagentFinished(agentId, 3), then parent BatchFlushed(s). + var startIdx = ordered.FindIndex(e => e is SubagentStarted s && s.AgentId == agentId); + var finishIdx = ordered.FindIndex(e => e is SubagentFinished f && f.AgentId == agentId); + + await Assert.That(startIdx).IsGreaterThanOrEqualTo(0); + await Assert.That(finishIdx).IsGreaterThan(startIdx); + await Assert.That(((SubagentFinished)ordered[finishIdx]).LinesSent).IsEqualTo(3); + } finally { + tmp.Delete(recursive: true); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/SessionImporterProgressTests/ImportSessionAsync_fires_subagent_events_around_inline_import" +``` +Expected: compilation error — `ImportSessionAsync` has no `progress` parameter. + +- [ ] **Step 3: Thread progress through `SendAgentLifecycle`** + +In `src/kapacitor/Commands/SessionImporter.cs`, change `SendAgentLifecycle` (currently starts at line 369). Add `IProgress? progress` as the last parameter. Fire `SubagentStarted` before the transcript call and `SubagentFinished` after it, capturing the returned line count. Replace the method with: + +```csharp +static async Task SendAgentLifecycle( + HttpClient httpClient, + string baseUrl, + string sessionId, + string agentId, + string? agentType, + string agentPath, + string cwd, + string sessionTranscriptPath, + IProgress? progress + ) { + var resolvedAgentType = agentType ?? "task"; + + var agentStartHook = new JsonObject { + ["session_id"] = sessionId, + ["transcript_path"] = sessionTranscriptPath, + ["cwd"] = cwd, + ["hook_event_name"] = "subagent_start", + ["agent_id"] = agentId, + ["agent_type"] = resolvedAgentType + }; + + try { + using var agentStartContent = new StringContent(agentStartHook.ToJsonString(), Encoding.UTF8, "application/json"); + await httpClient.PostWithRetryAsync($"{baseUrl}/hooks/subagent-start", agentStartContent); + } catch { + // Best effort + } + + progress?.Report(new SubagentStarted(agentId)); + var agentLines = await SendTranscriptBatches(httpClient, baseUrl, sessionId, agentPath, agentId, startLine: 0, progress: progress); + progress?.Report(new SubagentFinished(agentId, agentLines)); + + var agentStopHook = new JsonObject { + ["session_id"] = sessionId, + ["transcript_path"] = sessionTranscriptPath, + ["cwd"] = cwd, + ["hook_event_name"] = "subagent_stop", + ["agent_id"] = agentId, + ["agent_type"] = resolvedAgentType, + ["stop_hook_active"] = false, + ["agent_transcript_path"] = agentPath, + ["last_assistant_message"] = "" + }; + + try { + using var agentStopContent = new StringContent(agentStopHook.ToJsonString(), Encoding.UTF8, "application/json"); + await httpClient.PostWithRetryAsync($"{baseUrl}/hooks/subagent-stop", agentStopContent); + } catch { + // Best effort + } + + return agentLines; +} +``` + +- [ ] **Step 4: Thread progress through `ImportSessionAsync` and fire BatchFlushed on parent flushes** + +In `src/kapacitor/Commands/SessionImporter.cs`, change `ImportSessionAsync` (starting line 17). Add `IProgress? progress = null` as the last parameter. Pass it to every `PostTranscriptBatch` flush site (call `progress?.Report(new BatchFlushed(batchLines.Count))` right after each flush) and to `SendAgentLifecycle` calls. + +Replace the method body with: + +```csharp +internal static async Task ImportSessionAsync( + HttpClient httpClient, + string baseUrl, + string transcriptPath, + string sessionId, + SessionMetadata metadata, + string? encodedCwd, + IProgress? progress = null + ) { + if (!File.Exists(transcriptPath)) + return new(sessionId, [], 0); + + var cwd = metadata.Cwd ?? (encodedCwd is not null ? DecodeCwdFromDirName(encodedCwd) : null) ?? ""; + + var agentTranscripts = DiscoverAgentTranscripts(transcriptPath); + var agentMap = new Dictionary(StringComparer.Ordinal); + + foreach (var (agentId, agentPath) in agentTranscripts) { + agentMap[agentId] = agentPath; + } + + var scan = ScanAgentLifecycle(transcriptPath); + var agentFirstLine = scan.FirstLineByAgent; + var agentTypes = scan.AgentTypeByAgent; + + var sentAgents = new HashSet(StringComparer.Ordinal); + var agentIds = new List(); + var totalSent = 0; + + var batchLines = new List(); + var batchLineNumbers = new List(); + const int batchSize = 100; + + await using var stream = new FileStream(transcriptPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + + var lineIndex = 0; + + while (await reader.ReadLineAsync() is { } line) { + foreach (var (agentId, firstLine) in agentFirstLine) { + if (firstLine == lineIndex && !sentAgents.Contains(agentId) && agentMap.TryGetValue(agentId, out var agentPath)) { + if (batchLines.Count > 0) { + await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers); + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); + batchLines.Clear(); + batchLineNumbers.Clear(); + } + + agentTypes.TryGetValue(agentId, out var agentType); + await SendAgentLifecycle(httpClient, baseUrl, sessionId, agentId, agentType, agentPath, cwd, transcriptPath, progress); + sentAgents.Add(agentId); + agentIds.Add(agentId); + } + } + + if (!string.IsNullOrWhiteSpace(line)) { + batchLines.Add(line); + batchLineNumbers.Add(lineIndex); + } + + lineIndex++; + + if (batchLines.Count >= batchSize) { + await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers); + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); + batchLines.Clear(); + batchLineNumbers.Clear(); + } + } + + if (batchLines.Count > 0) { + await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers); + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); + } + + foreach (var (agentId, agentPath) in agentTranscripts) { + if (!sentAgents.Contains(agentId)) { + agentTypes.TryGetValue(agentId, out var agentType); + await SendAgentLifecycle(httpClient, baseUrl, sessionId, agentId, agentType, agentPath, cwd, transcriptPath, progress); + sentAgents.Add(agentId); + agentIds.Add(agentId); + } + } + + return new ImportResult(sessionId, agentIds, totalSent); +} +``` + +- [ ] **Step 5: Run the new test** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/SessionImporterProgressTests/*" +``` +Expected: 2 tests passed. + +- [ ] **Step 6: Run the full unit suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/kapacitor/Commands/SessionImporter.cs test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs +git commit -m "[DEV-1573] Emit subagent progress events from ImportSessionAsync + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Setup wizard — swap output primitives for Spectre (rules, status, grid) + +This task changes output only. Prompts still use `Console.Write`/`ReadLine` so the wizard's keyboard UX is unchanged and we can bisect if something regresses. + +**Files:** +- Modify: `src/kapacitor/Commands/SetupCommand.cs` + +- [ ] **Step 1: Add the using and wrap section headers in `Rule`** + +At the top of `src/kapacitor/Commands/SetupCommand.cs`, add: + +```csharp +using Spectre.Console; +``` + +Then in `HandleAsync`, replace each of the 5 lines like `await Console.Out.WriteLineAsync("Step 1/5: Server");` with a rule. The current lines are: + +- `"Step 1/5: Server"` at around line 34 +- `"Step 2/5: Login"` at around line 74 +- `"Step 3/5: Default session visibility"` at around line 94 +- `"Step 4/5: Claude Code Plugin"` at around line 136 +- `"Step 5/5: Agent Daemon"` at around line 202 + +Replace each line with: + +```csharp +AnsiConsole.Write(new Rule("[yellow]Step 1/5 — Server[/]").LeftJustified()); +``` + +(Substitute the per-step title.) + +Also replace the welcome lines at the top: + +```csharp +await Console.Out.WriteLineAsync(); +await Console.Out.WriteLineAsync("Welcome to Kapacitor!"); +await Console.Out.WriteLineAsync(); +``` + +with: + +```csharp +AnsiConsole.Write(new Rule("[bold green]Welcome to Kapacitor[/]").Centered()); +``` + +- [ ] **Step 2: Wrap the reachability probe in a `Status()` spinner** + +Find the block starting with `Console.Write(" Checking server... ");` (around line 59). Replace the whole try/catch block: + +```csharp +Console.Write(" Checking server... "); +string provider; + +try { + provider = await HttpClientExtensions.DiscoverProviderAsync(serverUrl); + await Console.Out.WriteLineAsync($"✓ Reachable. Auth provider: {provider}"); +} catch (Exception ex) { + await Console.Error.WriteLineAsync($"✗ Cannot reach server: {ex.Message}"); + + return 1; +} +``` + +with: + +```csharp +string provider; + +try { + provider = await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Checking server…", async _ => + await HttpClientExtensions.DiscoverProviderAsync(serverUrl)); + + AnsiConsole.MarkupLine($" [green]✓[/] Reachable · auth provider: [cyan]{Markup.Escape(provider)}[/]"); +} catch (Exception ex) { + AnsiConsole.MarkupLine($" [red]✗[/] Cannot reach server: {Markup.Escape(ex.Message)}"); + + return 1; +} +``` + +- [ ] **Step 3: Replace the final summary with a Grid** + +Find the trailing block starting `await Console.Out.WriteLineAsync("Setup complete!");` (around line 235). Replace it and the five subsequent `Console.Out.WriteLineAsync` calls through `" Optional: start the agent daemon…"` with: + +```csharp +AnsiConsole.Write(new Rule("[green]Setup complete[/]").LeftJustified()); + +var grid = new Grid().AddColumn().AddColumn(); +grid.AddRow("[bold]Server[/]", Markup.Escape(serverUrl)); +grid.AddRow("[bold]Visibility[/]", Markup.Escape(defaultVisibility)); +grid.AddRow("[bold]Daemon[/]", Markup.Escape(daemonName)); + +if (finalTokens is not null) { + grid.AddRow("[bold]Auth[/]", Markup.Escape($"{finalTokens.GitHubUsername} ({finalTokens.Provider})")); +} + +grid.AddRow("[bold]Config[/]", Markup.Escape(AppConfig.GetConfigPath())); + +AnsiConsole.Write(grid); +AnsiConsole.MarkupLine("\n[dim]Optional:[/] start the agent daemon with [cyan]kapacitor agent start -d[/]"); +``` + +- [ ] **Step 4: Build** + +```bash +dotnet build src/kapacitor/kapacitor.csproj +``` +Expected: success. + +- [ ] **Step 5: Run existing tests (they exercise file I/O and pure helpers; must still pass)** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/SetupCommandTests/*" +``` +Expected: all pass. + +- [ ] **Step 6: AOT publish gate** + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +``` +Expected: zero lines. + +- [ ] **Step 7: Commit** + +```bash +git add src/kapacitor/Commands/SetupCommand.cs +git commit -m "[DEV-1573] Setup wizard: Spectre rules, status spinner, final grid + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Setup wizard — replace prompts with Spectre prompts + +**Files:** +- Modify: `src/kapacitor/Commands/SetupCommand.cs` + +- [ ] **Step 1: Server URL prompt** + +Find the block (around line 44): + +```csharp +Console.Write(" Enter your Capacitor server URL: "); +serverUrl = Console.ReadLine()?.Trim() ?? ""; + +if (string.IsNullOrEmpty(serverUrl)) { + await Console.Error.WriteLineAsync(" Server URL is required."); + + return 1; +} +``` + +Replace with: + +```csharp +serverUrl = AnsiConsole.Prompt( + new TextPrompt("Capacitor server URL:") + .Validate(u => !string.IsNullOrWhiteSpace(u) + ? ValidationResult.Success() + : ValidationResult.Error("[red]URL cannot be empty[/]"))); +``` + +- [ ] **Step 2: Re-run confirmation prompt** + +Find the block (around line 22): + +```csharp +if (existing?.ServerUrl is not null && existingTokens is not null && !noPrompt) { + Console.Write($"Already configured for {existing.ServerUrl} as {existingTokens.GitHubUsername}. Re-run setup? [y/N] "); + var answer = Console.ReadLine()?.Trim().ToLowerInvariant(); + + if (answer is not "y" and not "yes") { + await Console.Out.WriteLineAsync("Setup cancelled."); + + return 0; + } +} +``` + +Replace with: + +```csharp +if (existing?.ServerUrl is not null && existingTokens is not null && !noPrompt) { + var rerun = AnsiConsole.Prompt( + new ConfirmationPrompt($"Already configured for [cyan]{Markup.Escape(existing.ServerUrl)}[/] as [cyan]{Markup.Escape(existingTokens.GitHubUsername ?? "?")}[/]. Re-run setup?") + { DefaultValue = false }); + + if (!rerun) { + AnsiConsole.MarkupLine("[dim]Setup cancelled.[/]"); + + return 0; + } +} +``` + +- [ ] **Step 3: Default visibility selection** + +Find the block (around lines 97-131) that prints the three numbered options and reads a choice. Replace the whole interactive branch (the else after `if (noPrompt)`) with: + +```csharp +} else { + defaultVisibility = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("How should your sessions be visible to others by default?") + .AddChoices("org_public", "private", "public") + .UseConverter(v => v switch { + "private" => "All private — only you can see your sessions", + "org_public" => "Org repos public, others private (default)", + "public" => "All public — everyone can see all your sessions", + _ => v + })); +} +``` + +(The preceding `AnsiConsole.Out.WriteLineAsync` calls that list the options can be deleted — the SelectionPrompt renders them.) + +- [ ] **Step 4: Plugin scope selection** + +Find the matching block (around lines 149-174). Replace the interactive branch with: + +```csharp +} else { + pluginScope = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Where should the plugin be installed?") + .AddChoices("user", "project", "skip") + .UseConverter(v => v switch { + "user" => "User-wide — all Claude Code sessions (recommended)", + "project" => "This project only", + "skip" => "Skip — I'll install it manually", + _ => v + })); +} +``` + +(Delete the preceding option-list `WriteLineAsync` calls.) + +- [ ] **Step 5: Daemon name prompt** + +Find (around line 210): + +```csharp +Console.Write($" Daemon name [{defaultName}]: "); +var input = Console.ReadLine()?.Trim(); +daemonName = string.IsNullOrEmpty(input) ? defaultName : input; +``` + +Replace with: + +```csharp +daemonName = AnsiConsole.Prompt( + new TextPrompt("Daemon name:") + .DefaultValue(defaultName) + .ShowDefaultValue()); +``` + +- [ ] **Step 6: Build and AOT check** + +```bash +dotnet build src/kapacitor/kapacitor.csproj && dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +``` +Expected: build succeeds, publish grep returns zero lines. + +- [ ] **Step 7: Smoke test interactively** + +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- setup +``` +Expected: wizard renders with colored rules, status spinner during server probe, arrow-key selection for visibility and plugin scope, defaulted daemon name, and a final two-column grid. + +Cancel with Ctrl+C partway through if you don't want to save config. + +- [ ] **Step 8: Smoke test `--no-prompt`** + +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- setup --no-prompt \ + --server-url https://example.invalid --default-visibility private --plugin-scope skip +``` +Expected: rules and grid render; reachability fails with `✗ Cannot reach server:` and exit code 1. (Using `.invalid` guarantees DNS failure.) + +- [ ] **Step 9: Run tests** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/SetupCommandTests/*" +``` +Expected: all pass. + +- [ ] **Step 10: Commit** + +```bash +git add src/kapacitor/Commands/SetupCommand.cs +git commit -m "[DEV-1573] Setup wizard: Spectre prompts (text, selection, confirmation) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: Introduce HistoryDisplay and gate TTY vs piped + +This task adds a private `HistoryDisplay` struct at the top of `HistoryCommand.cs` that owns all user-facing output during the import loop. Both modes (`Tty` vs `Plain`) route through the same methods — the TTY path is added in the next task, and for now both branches emit the current plain lines. This keeps the diff small and gives us a bisect point. + +**Files:** +- Modify: `src/kapacitor/Commands/HistoryCommand.cs` + +- [ ] **Step 1: Add usings** + +At the top of `src/kapacitor/Commands/HistoryCommand.cs`, add: + +```csharp +using Spectre.Console; +``` + +- [ ] **Step 2: Add the HistoryDisplay struct and factory** + +Immediately inside `static class HistoryCommand {` (at the top of the class body, above `HandleHistory`), add: + +```csharp +readonly struct HistoryDisplay { + public bool Tty { get; init; } + // Non-null in Tty mode, null in Plain mode. + public ProgressTask? Footer { get; init; } + + public void SetFooterSession(string sessionIdShort, int totalLines) { + if (Footer is null) return; + Footer.Description = $"[green]Importing[/] {(int)Footer.Value}/{(int)Footer.MaxValue} · {Markup.Escape(sessionIdShort)}: 0/{totalLines} lines"; + } + + public void AdvanceFooterLines(int linesDone, int linesTotal, string sessionIdShort, string? agentSuffixId) { + if (Footer is null) return; + var suffix = agentSuffixId is null ? "" : $" ↳ subagent {Markup.Escape(agentSuffixId)}"; + Footer.Description = $"[green]Importing[/] {(int)Footer.Value}/{(int)Footer.MaxValue} · {Markup.Escape(sessionIdShort)}: {linesDone}/{linesTotal} lines{suffix}"; + } + + public void Line(string plain, string? markup = null) { + if (Tty) AnsiConsole.MarkupLine(markup ?? Markup.Escape(plain)); + else Console.WriteLine(plain); + } + + public static HistoryDisplay Create() { + var tty = !Console.IsOutputRedirected; + + return new HistoryDisplay { Tty = tty, Footer = null }; + } +} +``` + +(`Footer` stays null in this task. The Progress block and its task are added in Task 8.) + +- [ ] **Step 3: Wire HistoryDisplay through HandleHistory output lines** + +Inside `HandleHistory`, near the top (right after `using var httpClient = …`), create the display: + +```csharp +var display = HistoryDisplay.Create(); +``` + +Then, throughout the method, replace user-facing `await Console.Out.WriteLineAsync(...)` and `Console.Write(...)` calls related to session processing with `display.Line(...)`. Specifically: + +- `await Console.Out.WriteLineAsync("Discovering sessions...");` → `display.Line("Discovering sessions...");` +- `await Console.Out.WriteLineAsync("No Claude Code projects directory found.");` → `display.Line("No Claude Code projects directory found.");` +- `await Console.Out.WriteLineAsync("No transcript files found.");` → `display.Line("No transcript files found.");` +- `await Console.Out.WriteLineAsync($"Found {transcriptFiles.Count} session…")` → `display.Line($"Found …");` +- Every `await Console.Out.WriteLineAsync($"Skipping {sessionId} [...]");` → `display.Line($"Skipping {sessionId} […]");` +- `Console.Write($"Loading {sessionId}... ");` and its paired `await Console.Out.WriteLineAsync($"{linesSent} lines [new]");` → one combined `display.Line($"Loading {sessionId}... {linesSent} lines [new]");` called **after** the import completes. +- `await Console.Out.WriteLineAsync($" {importResult.AgentIds.Count} agent{...} imported inline");` — **delete this line** entirely. The per-subagent streaming line is emitted from the progress callback in the next task; for this task, agents stream nothing (no regression — the current summary disappears but the non-TTY pipe still reports `Loading … N lines [new]`). We deliberately accept this one-task gap to keep the diff small; Task 8 restores subagent visibility. +- The progress/count bookkeeping (`loaded`, `resumed`, `skipped`, `errored`, etc.) is unchanged. +- Final summary lines at the bottom → `display.Line($"Done: {loaded} loaded, …");` +- `await Console.Out.WriteLineAsync($"Waiting for {backgroundTasks.Count} background task(s) (titles/summaries)...");` → `display.Line($"Waiting …");` +- The summary-parts line → `display.Line($" {string.Join(", ", parts)}");` + +Leave `Console.Error.WriteLineAsync` calls alone — stderr is not user-UX output. + +- [ ] **Step 4: Build** + +```bash +dotnet build src/kapacitor/kapacitor.csproj +``` +Expected: success. + +- [ ] **Step 5: Run tests** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` +Expected: all pass. + +- [ ] **Step 6: AOT publish check** + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +``` +Expected: zero lines. + +- [ ] **Step 7: Commit** + +```bash +git add src/kapacitor/Commands/HistoryCommand.cs +git commit -m "[DEV-1573] Introduce HistoryDisplay abstraction (plain-only for now) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 8: History — TTY mode with pinned Progress footer and streaming completion lines + +**Files:** +- Modify: `src/kapacitor/Commands/HistoryCommand.cs` + +- [ ] **Step 1: Extract the per-session loop into a local method** + +In `HandleHistory`, locate the `foreach (var (sessionId, filePath, encodedCwd) in transcriptFiles) { … }` loop. Extract the body into a local method `ProcessSession` that closes over counters, or keep it inline — the exact refactor is the author's choice, but the loop must be callable from inside a Spectre `Progress.Start` callback. + +The cleanest approach: convert `HandleHistory` so that after `display` is created, the session loop runs inside: + +```csharp +if (display.Tty) { + await AnsiConsole.Progress() + .AutoClear(false) + .HideCompleted(false) + .Columns(new SpinnerColumn(), new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) + .StartAsync(async ctx => { + var footer = ctx.AddTask("[green]Importing[/]", autoStart: true, maxValue: transcriptFiles.Count); + display = display with { Footer = footer }; + + await RunSessionLoop(display, transcriptFiles, httpClient, baseUrl, /*…all other state…*/); + }); +} else { + await RunSessionLoop(display, transcriptFiles, httpClient, baseUrl, /*…*/); +} +``` + +Here `RunSessionLoop` is a local function that contains the existing per-session loop body with `display.Line` calls and, in TTY mode, updates to `display.Footer`. + +Because `HandleHistory` has many local variables (counters, continuation map, exclusion flags), the cleanest refactor is to **keep everything inline** and wrap the whole `foreach` in the `Progress.StartAsync` branch. Concretely: + +```csharp +var display = HistoryDisplay.Create(); + +async Task RunLoop() { + foreach (var (sessionId, filePath, encodedCwd) in transcriptFiles) { + // …existing body, using display.Line, display.SetFooterSession, display.AdvanceFooterLines… + } +} + +if (display.Tty) { + await AnsiConsole.Progress() + .AutoClear(false) + .HideCompleted(false) + .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) + .StartAsync(async ctx => { + var footer = ctx.AddTask("[green]Importing[/]", maxValue: transcriptFiles.Count); + display = display with { Footer = footer }; + + await RunLoop(); + footer.Value = footer.MaxValue; + }); +} else { + await RunLoop(); +} +``` + +- [ ] **Step 2: Drive the footer from a per-session IProgress callback** + +Inside the per-session processing body, before calling `SessionImporter.ImportSessionAsync`, compute the pre-known line count and set the footer session description: + +```csharp +var sessionIdShort = sessionId.Length >= 8 ? sessionId[..8] : sessionId; +var totalLines = WatchCommand.CountFileLines(filePath); +var linesDone = 0; +string? currentSubagent = null; + +display.SetFooterSession(sessionIdShort, totalLines); + +var perSessionProgress = new Progress(ev => { + switch (ev) { + case BatchFlushed bf: + linesDone += bf.LinesAdded; + display.AdvanceFooterLines(linesDone, totalLines, sessionIdShort, currentSubagent); + break; + case SubagentStarted ss: + currentSubagent = ss.AgentId.Length >= 8 ? ss.AgentId[..8] : ss.AgentId; + display.AdvanceFooterLines(linesDone, totalLines, sessionIdShort, currentSubagent); + break; + case SubagentFinished sf: + display.Line( + $" ↳ imported subagent {sf.AgentId} ({sf.LinesSent} lines)", + $" [dim]↳[/] imported subagent [cyan]{Markup.Escape(sf.AgentId)}[/] ({sf.LinesSent} lines)"); + currentSubagent = null; + display.AdvanceFooterLines(linesDone, totalLines, sessionIdShort, null); + break; + } +}); +``` + +Pass `perSessionProgress` as the last argument of both importer calls. Current call sites are around line 268 (for new sessions) and line 368 (partial/resume). Change them to: + +```csharp +var importResult = await SessionImporter.ImportSessionAsync( + httpClient, baseUrl, filePath, sessionId, meta, encodedCwd, perSessionProgress +); +``` + +and + +```csharp +var linesSent = await SessionImporter.SendTranscriptBatches( + httpClient, baseUrl, sessionId, filePath, agentId: null, + startLine: resumeFromLine, progress: perSessionProgress +); +``` + +After a session completes, advance the footer's session counter: + +```csharp +display.Footer?.Increment(1); +``` + +- [ ] **Step 3: Build** + +```bash +dotnet build src/kapacitor/kapacitor.csproj +``` +Expected: success. + +- [ ] **Step 4: Run tests** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` +Expected: all pass. + +- [ ] **Step 5: AOT publish gate** + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +``` +Expected: zero lines. + +- [ ] **Step 6: Smoke test — TTY mode** + +Run against your own local server (or any reachable Capacitor server): + +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- history --min-lines 10 +``` +Expected: a pinned footer line at the bottom showing session count and per-session line progress; completion lines (`Loading …`, `Skipping …`, ` ↳ imported subagent …`) stream above the footer; when done, a plain `Done: …` line. + +- [ ] **Step 7: Smoke test — piped mode** + +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- history --min-lines 10 > /tmp/kapacitor-history.log +cat /tmp/kapacitor-history.log +``` +Expected: no ANSI, no progress bar, one line per session in today's format plus per-subagent ` ↳ imported subagent …` lines. + +- [ ] **Step 8: Commit** + +```bash +git add src/kapacitor/Commands/HistoryCommand.cs +git commit -m "[DEV-1573] History: TTY-mode Spectre progress footer + streaming lines + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 9: History — background phase progress block with individual failure streaming + +**Files:** +- Modify: `src/kapacitor/Commands/HistoryCommand.cs` + +- [ ] **Step 1: Capture per-task failure reasons** + +Currently the background tasks only `Interlocked.Increment` counters. Extend them to record failure reasons so the UI can stream each failure line. Introduce two thread-safe lists above the `foreach`: + +```csharp +var titleFailures = new System.Collections.Concurrent.ConcurrentBag<(string SessionId, string Reason)>(); +var summaryFailures = new System.Collections.Concurrent.ConcurrentBag<(string SessionId, string Reason)>(); +``` + +Inside the title-generation `Task.Run` at around line 322, where `TitleResult.Failed` is handled, also `titleFailures.Add((titleSessionId, "generation error"))` (the current code doesn't surface a reason; `"generation error"` is the best we have without further instrumentation). In the summary-generation `Task.Run` at around line 344, on failure path `summaryFailures.Add((titleSessionId, "generator exited non-zero"))`. + +Concretely, in the title task: + +```csharp +case TitleResult.Failed: + Interlocked.Increment(ref titlesFailed); + titleFailures.Add((titleSessionId, "generation error")); + break; +``` + +In the summary task: + +```csharp +if (wdResult == 0) { + Interlocked.Increment(ref summariesGenerated); +} else { + Interlocked.Increment(ref summariesFailed); + summaryFailures.Add((titleSessionId, $"exit {wdResult}")); +} +``` + +And the outer catch in that Task.Run: + +```csharp +catch (Exception ex) { + Interlocked.Increment(ref summariesFailed); + summaryFailures.Add((titleSessionId, ex.Message)); +} +``` + +- [ ] **Step 2: Replace the flat "Waiting for N background tasks…" block with a TTY Progress** + +Find the block starting `if (backgroundTasks.Count > 0)` (around line 378). Replace it with: + +```csharp +if (backgroundTasks.Count > 0) { + if (display.Tty) { + AnsiConsole.Write(new Rule($"[dim]── Waiting for {backgroundTasks.Count} background task(s) ──[/]").LeftJustified()); + + await AnsiConsole.Progress() + .AutoClear(false) + .HideCompleted(false) + .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) + .StartAsync(async ctx => { + var titleCount = backgroundTasks.Count; // overcount-safe: Progress caps at MaxValue + var titleTask = ctx.AddTask("[cyan]Titles[/]", maxValue: titleCount); + var summaryTask = ctx.AddTask("[cyan]Summaries[/]", maxValue: titleCount); + + // Poll counters while tasks run; drain failure bags as they populate. + var seenTitleFailures = 0; + var seenSummaryFailures = 0; + + while (backgroundTasks.Any(t => !t.IsCompleted)) { + titleTask.Value = titlesGenerated + titlesFailed + titlesSkipped; + summaryTask.Value = summariesGenerated + summariesFailed; + + foreach (var (sid, reason) in titleFailures.Skip(seenTitleFailures).ToList()) { + AnsiConsole.MarkupLine($" [red]✗[/] title failed for [cyan]{Markup.Escape(sid)}[/]: {Markup.Escape(reason)}"); + seenTitleFailures++; + } + foreach (var (sid, reason) in summaryFailures.Skip(seenSummaryFailures).ToList()) { + AnsiConsole.MarkupLine($" [red]✗[/] summary failed for [cyan]{Markup.Escape(sid)}[/]: {Markup.Escape(reason)}"); + seenSummaryFailures++; + } + + await Task.Delay(250); + } + + try { + await Task.WhenAll(backgroundTasks); + } catch { + // per-task try/catch handles individual failures + } + + // Final drain after tasks completed + foreach (var (sid, reason) in titleFailures.Skip(seenTitleFailures).ToList()) + AnsiConsole.MarkupLine($" [red]✗[/] title failed for [cyan]{Markup.Escape(sid)}[/]: {Markup.Escape(reason)}"); + foreach (var (sid, reason) in summaryFailures.Skip(seenSummaryFailures).ToList()) + AnsiConsole.MarkupLine($" [red]✗[/] summary failed for [cyan]{Markup.Escape(sid)}[/]: {Markup.Escape(reason)}"); + + titleTask.Value = titlesGenerated + titlesFailed + titlesSkipped; + summaryTask.Value = summariesGenerated + summariesFailed; + }); + } else { + display.Line($"Waiting for {backgroundTasks.Count} background task(s) (titles/summaries)..."); + try { + await Task.WhenAll(backgroundTasks); + } catch { + // per-task try/catch handles individual failures + } + + foreach (var (sid, reason) in titleFailures) + display.Line($" ✗ title failed for {sid}: {reason}"); + foreach (var (sid, reason) in summaryFailures) + display.Line($" ✗ summary failed for {sid}: {reason}"); + } + + var parts = new List(); + + if (titlesGenerated > 0) parts.Add($"{titlesGenerated} title{(titlesGenerated == 1 ? "" : "s")}"); + if (summariesGenerated > 0) parts.Add($"{summariesGenerated} summar{(summariesGenerated == 1 ? "y" : "ies")}"); + if (titlesSkipped > 0) parts.Add($"{titlesSkipped} skipped"); + if (titlesFailed + summariesFailed > 0) parts.Add($"{titlesFailed + summariesFailed} failed"); + + if (parts.Count > 0) display.Line($" {string.Join(", ", parts)}"); +} +``` + +- [ ] **Step 3: Build** + +```bash +dotnet build src/kapacitor/kapacitor.csproj +``` +Expected: success. + +- [ ] **Step 4: Run tests** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` +Expected: all pass. + +- [ ] **Step 5: AOT publish gate** + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +``` +Expected: zero lines. + +- [ ] **Step 6: Smoke test with `--generate-summaries`** + +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- history --min-lines 10 --generate-summaries +``` +Expected: after the main import finishes, a rule separator, then two progress bars (`Titles`, `Summaries`) that advance as background tasks complete. Any failures stream as red `✗` lines between the bars. Final summary counts unchanged. + +- [ ] **Step 7: Commit** + +```bash +git add src/kapacitor/Commands/HistoryCommand.cs +git commit -m "[DEV-1573] History: background-phase progress + streaming failure lines + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 10: Final verification pass + +**Files:** none (verification only). + +- [ ] **Step 1: Full unit test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` +Expected: all tests pass. + +- [ ] **Step 2: Integration test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Integration/kapacitor.Tests.Integration.csproj +``` +Expected: all tests pass. + +- [ ] **Step 3: Release publish AOT gate** + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +``` +Expected: zero lines. + +- [ ] **Step 4: End-to-end smoke (`setup`, interactive and `--no-prompt`)** + +Interactive: +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- setup +``` +Confirm: rules, arrow-key selection, status spinner, final grid. + +Non-interactive: +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- setup --no-prompt \ + --server-url https://example.invalid --default-visibility private --plugin-scope skip +``` +Confirm: rules and final grid render, reachability fails, exit code non-zero. + +- [ ] **Step 5: End-to-end smoke (`history`, TTY and piped)** + +TTY: +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- history --min-lines 10 +``` +Confirm: pinned footer, streaming `Loading … N lines [new]`, `↳ imported subagent …` lines, final summary. + +Piped: +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- history --min-lines 10 | head -50 +``` +Confirm: no ANSI, same content as today plus new per-subagent lines. + +With summaries: +```bash +dotnet run --project src/kapacitor/kapacitor.csproj -- history --min-lines 10 --generate-summaries +``` +Confirm: second progress block appears after main import. + +- [ ] **Step 6: Hook commands untouched — spot-check** + +Confirm one hook still works (it does not import Spectre). Pick any cached transcript line and feed it via stdin: + +```bash +echo '{"session_id":"00000000000000000000000000000001","hook_event_name":"session_start","cwd":"/tmp"}' \ + | dotnet run --project src/kapacitor/kapacitor.csproj -- session-start +``` +Expected: exits cleanly (success or clean HTTP error — not a crash; no Spectre output). + +- [ ] **Step 7: No plan-level commit — work is already committed across tasks** + +--- + +## Self-review notes (author) + +- **Spec coverage.** Every spec section maps to at least one task: + - Dependencies (`Spectre.Console` only) → Task 1. + - Setup wizard (rules/grid/status + prompts) → Tasks 5–6. + - History display model (single footer, streaming lines, subagent indicator) → Tasks 7–8. + - Importer `IProgress` hook + event records → Tasks 2–4. + - Background phase progress + streaming failures → Task 9. + - Non-TTY fallback → Task 7 establishes the gate; Tasks 8–9 route TTY vs piped through it. + - AOT gate → Tasks 1, 5, 6, 7, 8, 9, 10. +- **Type consistency.** `ImportProgress`, `BatchFlushed(int LinesAdded)`, `SubagentStarted(string AgentId)`, `SubagentFinished(string AgentId, int LinesSent)` are used consistently across Tasks 2, 3, 4, 8. `HistoryDisplay` methods `Line`, `SetFooterSession`, `AdvanceFooterLines` are defined in Task 7 and used in Tasks 7, 8, 9. +- **Known gap.** Task 7 temporarily removes the current `" N agents imported inline"` summary line; Task 8 restores per-subagent visibility via streaming `↳ imported subagent …` lines. If the plan stops mid-way (e.g. Task 7 merged but Task 8 not), the non-TTY pipe loses the inline agent count. This is flagged explicitly in Task 7 Step 3. Do not ship a release between Task 7 and Task 8. From 93e846f51375b93fc27a335fd84ac0c5d13d45db Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 19:52:21 +0200 Subject: [PATCH 03/12] [DEV-1573] Add Spectre.Console package reference Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/kapacitor.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/kapacitor/kapacitor.csproj b/src/kapacitor/kapacitor.csproj index 0652a445d2..5d7f98e0af 100644 --- a/src/kapacitor/kapacitor.csproj +++ b/src/kapacitor/kapacitor.csproj @@ -28,6 +28,7 @@ + From 2872a3ca39582ff6ff9ca6b0d797dc6595ede982 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 19:54:43 +0200 Subject: [PATCH 04/12] [DEV-1573] Define ImportProgress event hierarchy Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/Commands/ImportProgress.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/kapacitor/Commands/ImportProgress.cs diff --git a/src/kapacitor/Commands/ImportProgress.cs b/src/kapacitor/Commands/ImportProgress.cs new file mode 100644 index 0000000000..4bcb1f246e --- /dev/null +++ b/src/kapacitor/Commands/ImportProgress.cs @@ -0,0 +1,17 @@ +namespace kapacitor.Commands; + +/// +/// Progress events emitted by +/// and for UI layers that +/// want to render a live view of an in-flight import. +/// +public abstract record ImportProgress; + +/// Fired after a transcript batch is flushed to the server. +public sealed record BatchFlushed(int LinesAdded) : ImportProgress; + +/// Fired when the importer begins streaming a subagent's transcript inline. +public sealed record SubagentStarted(string AgentId) : ImportProgress; + +/// Fired after a subagent's transcript has been fully streamed. +public sealed record SubagentFinished(string AgentId, int LinesSent) : ImportProgress; From 69cc472b0edab167dcd84c10259900189a840338 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 19:56:37 +0200 Subject: [PATCH 05/12] [DEV-1573] Emit BatchFlushed progress events from SendTranscriptBatches Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/Commands/SessionImporter.cs | 22 +++++---- .../SessionImporterProgressTests.cs | 48 +++++++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs diff --git a/src/kapacitor/Commands/SessionImporter.cs b/src/kapacitor/Commands/SessionImporter.cs index d0b6f0c6a9..4e3a745fc3 100644 --- a/src/kapacitor/Commands/SessionImporter.cs +++ b/src/kapacitor/Commands/SessionImporter.cs @@ -423,12 +423,13 @@ string sessionTranscriptPath /// Send transcript lines in batches of 100 for a given file (main or agent). /// internal static async Task SendTranscriptBatches( - HttpClient httpClient, - string baseUrl, - string sessionId, - string filePath, - string? agentId, - int startLine + HttpClient httpClient, + string baseUrl, + string sessionId, + string filePath, + string? agentId, + int startLine, + IProgress? progress = null ) { if (!File.Exists(filePath)) return 0; @@ -458,16 +459,19 @@ int startLine if (batchLines.Count >= batchSize) { await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId, batchLines, batchLineNumbers); - totalSent += batchLines.Count; + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); batchLines.Clear(); batchLineNumbers.Clear(); } } - // Send remaining lines if (batchLines.Count > 0) { await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId, batchLines, batchLineNumbers); - totalSent += batchLines.Count; + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); } return totalSent; diff --git a/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs b/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs new file mode 100644 index 0000000000..1e48b0c753 --- /dev/null +++ b/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs @@ -0,0 +1,48 @@ +using kapacitor.Commands; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace kapacitor.Tests.Unit; + +public class SessionImporterProgressTests : IDisposable { + readonly WireMockServer _server = WireMockServer.Start(); + + public void Dispose() => _server.Stop(); + + [Test] + public async Task SendTranscriptBatches_fires_BatchFlushed_per_100_lines() { + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + // Write 250 non-blank JSONL lines → expect 3 flushes: 100, 100, 50 + var path = Path.GetTempFileName(); + try { + await File.WriteAllLinesAsync(path, Enumerable.Range(0, 250).Select(i => + $$$"""{"type":"user","timestamp":"2026-03-15T10:00:00Z","message":{"content":"line-{{{i}}}"}}""" + )); + + var events = new List(); + var progress = new Progress(events.Add); + + using var client = new HttpClient(); + var totalSent = await SessionImporter.SendTranscriptBatches( + client, _server.Url!, sessionId: "test", filePath: path, + agentId: null, startLine: 0, progress: progress + ); + + await Assert.That(totalSent).IsEqualTo(250); + + // Progress marshals via SynchronizationContext; give it a tick + await Task.Delay(50); + + var flushes = events.OfType().ToList(); + await Assert.That(flushes.Count).IsEqualTo(3); + await Assert.That(flushes[0].LinesAdded).IsEqualTo(100); + await Assert.That(flushes[1].LinesAdded).IsEqualTo(100); + await Assert.That(flushes[2].LinesAdded).IsEqualTo(50); + } finally { + File.Delete(path); + } + } +} From ceab3721d9d5ab3772919816eebc88181df20514 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 20:02:08 +0200 Subject: [PATCH 06/12] [DEV-1573] Emit subagent progress events from ImportSessionAsync Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/Commands/SessionImporter.cs | 55 +++++++++++------- .../SessionImporterProgressTests.cs | 57 +++++++++++++++++++ 2 files changed, 90 insertions(+), 22 deletions(-) diff --git a/src/kapacitor/Commands/SessionImporter.cs b/src/kapacitor/Commands/SessionImporter.cs index 4e3a745fc3..26cf64ae1e 100644 --- a/src/kapacitor/Commands/SessionImporter.cs +++ b/src/kapacitor/Commands/SessionImporter.cs @@ -15,12 +15,13 @@ static class SessionImporter { /// progress / agent_progress entries. /// internal static async Task ImportSessionAsync( - HttpClient httpClient, - string baseUrl, - string transcriptPath, - string sessionId, - SessionMetadata metadata, - string? encodedCwd + HttpClient httpClient, + string baseUrl, + string transcriptPath, + string sessionId, + SessionMetadata metadata, + string? encodedCwd, + IProgress? progress = null ) { if (!File.Exists(transcriptPath)) return new(sessionId, [], 0); @@ -67,14 +68,16 @@ internal static async Task ImportSessionAsync( // Flush the current batch before inserting agent lifecycle if (batchLines.Count > 0) { await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers); - totalSent += batchLines.Count; + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); batchLines.Clear(); batchLineNumbers.Clear(); } // Send agent lifecycle: start → transcript → stop agentTypes.TryGetValue(agentId, out var agentType); - await SendAgentLifecycle(httpClient, baseUrl, sessionId, agentId, agentType, agentPath, cwd, transcriptPath); + await SendAgentLifecycle(httpClient, baseUrl, sessionId, agentId, agentType, agentPath, cwd, transcriptPath, progress); sentAgents.Add(agentId); agentIds.Add(agentId); } @@ -89,7 +92,9 @@ internal static async Task ImportSessionAsync( if (batchLines.Count >= batchSize) { await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers); - totalSent += batchLines.Count; + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); batchLines.Clear(); batchLineNumbers.Clear(); } @@ -98,7 +103,9 @@ internal static async Task ImportSessionAsync( // Flush remaining main transcript lines if (batchLines.Count > 0) { await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers); - totalSent += batchLines.Count; + var flushed = batchLines.Count; + totalSent += flushed; + progress?.Report(new BatchFlushed(flushed)); } // Send any agents that had transcript files but NO progress marker in the @@ -106,7 +113,7 @@ internal static async Task ImportSessionAsync( foreach (var (agentId, agentPath) in agentTranscripts) { if (!sentAgents.Contains(agentId)) { agentTypes.TryGetValue(agentId, out var agentType); - await SendAgentLifecycle(httpClient, baseUrl, sessionId, agentId, agentType, agentPath, cwd, transcriptPath); + await SendAgentLifecycle(httpClient, baseUrl, sessionId, agentId, agentType, agentPath, cwd, transcriptPath, progress); sentAgents.Add(agentId); agentIds.Add(agentId); } @@ -366,15 +373,16 @@ static void TryExtractFromToolUseResult( /// Falls back to "task" when unknown — typically compact agents and transcripts /// discovered without a parent invocation. /// - static async Task SendAgentLifecycle( - HttpClient httpClient, - string baseUrl, - string sessionId, - string agentId, - string? agentType, - string agentPath, - string cwd, - string sessionTranscriptPath + static async Task SendAgentLifecycle( + HttpClient httpClient, + string baseUrl, + string sessionId, + string agentId, + string? agentType, + string agentPath, + string cwd, + string sessionTranscriptPath, + IProgress? progress ) { var resolvedAgentType = agentType ?? "task"; @@ -395,8 +403,9 @@ string sessionTranscriptPath // Best effort } - // Send agent transcript - await SendTranscriptBatches(httpClient, baseUrl, sessionId, agentPath, agentId, startLine: 0); + progress?.Report(new SubagentStarted(agentId)); + var agentLines = await SendTranscriptBatches(httpClient, baseUrl, sessionId, agentPath, agentId, startLine: 0, progress: progress); + progress?.Report(new SubagentFinished(agentId, agentLines)); // Stop agent var agentStopHook = new JsonObject { @@ -417,6 +426,8 @@ string sessionTranscriptPath } catch { // Best effort } + + return agentLines; } /// diff --git a/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs b/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs index 1e48b0c753..845524d088 100644 --- a/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs +++ b/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs @@ -45,4 +45,61 @@ await File.WriteAllLinesAsync(path, Enumerable.Range(0, 250).Select(i => File.Delete(path); } } + + [Test] + public async Task ImportSessionAsync_fires_subagent_events_around_inline_import() { + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + _server.Given(Request.Create().WithPath("/hooks/subagent-start").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + _server.Given(Request.Create().WithPath("/hooks/subagent-stop").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + var tmp = Directory.CreateTempSubdirectory("kapacitor-import-test"); + try { + var sessionName = Guid.NewGuid().ToString("N"); + var sessionPath = Path.Combine(tmp.FullName, $"{sessionName}.jsonl"); + var agentsDir = Path.Combine(tmp.FullName, sessionName, "subagents"); + Directory.CreateDirectory(agentsDir); + + var agentId = Guid.NewGuid().ToString("N"); + var agentPath = Path.Combine(agentsDir, $"agent-{agentId}.jsonl"); + + await File.WriteAllLinesAsync(sessionPath, [ + $$$"""{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tu1","name":"Task","input":{"subagent_type":"code-reviewer"}}]}}""", + $$$"""{"type":"progress","data":{"type":"agent_progress","agentId":"{{{agentId}}}"}}""", + $$$"""{"type":"user","timestamp":"2026-03-15T10:00:00Z","message":{"content":"after"}}""" + ]); + + await File.WriteAllLinesAsync(agentPath, [ + $$$"""{"type":"user","timestamp":"2026-03-15T10:00:00Z","message":{"content":"a1"}}""", + $$$"""{"type":"assistant","timestamp":"2026-03-15T10:00:01Z","message":{"content":"a2"}}""", + $$$"""{"type":"user","timestamp":"2026-03-15T10:00:02Z","message":{"content":"a3"}}""" + ]); + + var events = new List(); + var progress = new Progress(events.Add); + + using var client = new HttpClient(); + var result = await SessionImporter.ImportSessionAsync( + client, _server.Url!, sessionPath, sessionId: "s1", + new SessionMetadata { Cwd = "/x" }, encodedCwd: null, + progress: progress + ); + + await Task.Delay(50); + + await Assert.That(result.AgentIds).Contains(agentId); + + var ordered = events.ToList(); + var startIdx = ordered.FindIndex(e => e is SubagentStarted s && s.AgentId == agentId); + var finishIdx = ordered.FindIndex(e => e is SubagentFinished f && f.AgentId == agentId); + + await Assert.That(startIdx).IsGreaterThanOrEqualTo(0); + await Assert.That(finishIdx).IsGreaterThan(startIdx); + await Assert.That(((SubagentFinished)ordered[finishIdx]).LinesSent).IsEqualTo(3); + } finally { + tmp.Delete(recursive: true); + } + } } From ca442e3d3a313055b496c69dd47549d8b4e0cc22 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 20:06:44 +0200 Subject: [PATCH 07/12] [DEV-1573] Setup wizard: Spectre rules, status spinner, final grid Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/Commands/SetupCommand.cs | 46 +++++++++++++++----------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/kapacitor/Commands/SetupCommand.cs b/src/kapacitor/Commands/SetupCommand.cs index c881793029..bd3b747c67 100644 --- a/src/kapacitor/Commands/SetupCommand.cs +++ b/src/kapacitor/Commands/SetupCommand.cs @@ -2,6 +2,8 @@ using System.Text.Json.Nodes; using kapacitor.Auth; using kapacitor.Config; +using Spectre.Console; +using Profile = kapacitor.Config.Profile; namespace kapacitor.Commands; @@ -10,9 +12,7 @@ public static async Task HandleAsync(string[] args) { var serverUrlArg = GetArg(args, "--server-url"); var noPrompt = args.Contains("--no-prompt"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Welcome to Kapacitor!"); - await Console.Out.WriteLineAsync(); + AnsiConsole.Write(new Rule("[bold green]Welcome to Kapacitor[/]").Centered()); // Check if already configured var existingProfile = await AppConfig.LoadProfileConfig(); @@ -31,7 +31,7 @@ public static async Task HandleAsync(string[] args) { } // Step 1: Server URL - await Console.Out.WriteLineAsync("Step 1/5: Server"); + AnsiConsole.Write(new Rule("[yellow]Step 1/5 — Server[/]").LeftJustified()); string serverUrl; if (serverUrlArg is not null) { @@ -56,14 +56,17 @@ public static async Task HandleAsync(string[] args) { serverUrl = AppConfig.NormalizeUrl(serverUrl); // Validate server reachability - Console.Write(" Checking server... "); string provider; try { - provider = await HttpClientExtensions.DiscoverProviderAsync(serverUrl); - await Console.Out.WriteLineAsync($"✓ Reachable. Auth provider: {provider}"); + provider = await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Checking server…", async _ => + await HttpClientExtensions.DiscoverProviderAsync(serverUrl)); + + AnsiConsole.MarkupLine($" [green]✓[/] Reachable · auth provider: [cyan]{Markup.Escape(provider)}[/]"); } catch (Exception ex) { - await Console.Error.WriteLineAsync($"✗ Cannot reach server: {ex.Message}"); + AnsiConsole.MarkupLine($" [red]✗[/] Cannot reach server: {Markup.Escape(ex.Message)}"); return 1; } @@ -71,7 +74,7 @@ public static async Task HandleAsync(string[] args) { await Console.Out.WriteLineAsync(); // Step 2: Login - await Console.Out.WriteLineAsync("Step 2/5: Login"); + AnsiConsole.Write(new Rule("[yellow]Step 2/5 — Login[/]").LeftJustified()); if (provider == "None") { await Console.Out.WriteLineAsync(" Auth provider is None — no login required."); @@ -91,7 +94,7 @@ public static async Task HandleAsync(string[] args) { await Console.Out.WriteLineAsync(); // Step 3: Default session visibility - await Console.Out.WriteLineAsync("Step 3/5: Default session visibility"); + AnsiConsole.Write(new Rule("[yellow]Step 3/5 — Default session visibility[/]").LeftJustified()); await Console.Out.WriteLineAsync(); await Console.Out.WriteLineAsync(" How should your sessions be visible to others by default?"); await Console.Out.WriteLineAsync(); @@ -133,7 +136,7 @@ public static async Task HandleAsync(string[] args) { await Console.Out.WriteLineAsync(); // Step 4: Claude Code plugin - await Console.Out.WriteLineAsync("Step 4/5: Claude Code Plugin"); + AnsiConsole.Write(new Rule("[yellow]Step 4/5 — Claude Code Plugin[/]").LeftJustified()); await Console.Out.WriteLineAsync(" The Kapacitor plugin provides hooks, skills, and collaborative memory."); await Console.Out.WriteLineAsync(); @@ -199,7 +202,7 @@ public static async Task HandleAsync(string[] args) { await Console.Out.WriteLineAsync(); // Step 5: Daemon name + save - await Console.Out.WriteLineAsync("Step 5/5: Agent Daemon"); + AnsiConsole.Write(new Rule("[yellow]Step 5/5 — Agent Daemon[/]").LeftJustified()); var defaultName = Environment.UserName.ToLowerInvariant(); string daemonName; @@ -232,18 +235,21 @@ public static async Task HandleAsync(string[] args) { await AppConfig.SaveProfileConfig(profileConfig); var finalTokens = await TokenStore.LoadAsync(); - await Console.Out.WriteLineAsync("Setup complete!"); - await Console.Out.WriteLineAsync($" ✓ Server: {serverUrl}"); - await Console.Out.WriteLineAsync($" ✓ Visibility: {defaultVisibility}"); - await Console.Out.WriteLineAsync($" ✓ Daemon: {daemonName}"); + AnsiConsole.Write(new Rule("[green]Setup complete[/]").LeftJustified()); + + var grid = new Grid().AddColumn().AddColumn(); + grid.AddRow("[bold]Server[/]", Markup.Escape(serverUrl)); + grid.AddRow("[bold]Visibility[/]", Markup.Escape(defaultVisibility)); + grid.AddRow("[bold]Daemon[/]", Markup.Escape(daemonName)); if (finalTokens is not null) { - await Console.Out.WriteLineAsync($" ✓ Auth: {finalTokens.GitHubUsername} ({finalTokens.Provider})"); + grid.AddRow("[bold]Auth[/]", Markup.Escape($"{finalTokens.GitHubUsername} ({finalTokens.Provider})")); } - await Console.Out.WriteLineAsync($" Config saved to {AppConfig.GetConfigPath()}"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync(" Optional: start the agent daemon with `kapacitor agent start -d`"); + grid.AddRow("[bold]Config[/]", Markup.Escape(AppConfig.GetConfigPath())); + + AnsiConsole.Write(grid); + AnsiConsole.MarkupLine("\n[dim]Optional:[/] start the agent daemon with [cyan]kapacitor agent start -d[/]"); return 0; } From e1aee5eeb89692de5ecb17150251ba83669c8077 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 20:10:13 +0200 Subject: [PATCH 08/12] [DEV-1573] Setup wizard: Spectre prompts (text, selection, confirmation) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/Commands/SetupCommand.cs | 93 ++++++++++---------------- 1 file changed, 34 insertions(+), 59 deletions(-) diff --git a/src/kapacitor/Commands/SetupCommand.cs b/src/kapacitor/Commands/SetupCommand.cs index bd3b747c67..f72e2ea568 100644 --- a/src/kapacitor/Commands/SetupCommand.cs +++ b/src/kapacitor/Commands/SetupCommand.cs @@ -20,11 +20,12 @@ public static async Task HandleAsync(string[] args) { var existingTokens = await TokenStore.LoadAsync(); if (existing?.ServerUrl is not null && existingTokens is not null && !noPrompt) { - Console.Write($"Already configured for {existing.ServerUrl} as {existingTokens.GitHubUsername}. Re-run setup? [y/N] "); - var answer = Console.ReadLine()?.Trim().ToLowerInvariant(); + var rerun = AnsiConsole.Prompt( + new ConfirmationPrompt($"Already configured for [cyan]{Markup.Escape(existing.ServerUrl)}[/] as [cyan]{Markup.Escape(existingTokens.GitHubUsername ?? "?")}[/]. Re-run setup?") + { DefaultValue = false }); - if (answer is not "y" and not "yes") { - await Console.Out.WriteLineAsync("Setup cancelled."); + if (!rerun) { + AnsiConsole.MarkupLine("[dim]Setup cancelled.[/]"); return 0; } @@ -42,14 +43,11 @@ public static async Task HandleAsync(string[] args) { return 1; } else { - Console.Write(" Enter your Capacitor server URL: "); - serverUrl = Console.ReadLine()?.Trim() ?? ""; - - if (string.IsNullOrEmpty(serverUrl)) { - await Console.Error.WriteLineAsync(" Server URL is required."); - - return 1; - } + serverUrl = AnsiConsole.Prompt( + new TextPrompt("Capacitor server URL:") + .Validate(u => !string.IsNullOrWhiteSpace(u) + ? ValidationResult.Success() + : ValidationResult.Error("[red]URL cannot be empty[/]"))); } // Normalize: strip trailing slashes to avoid double-slash URLs @@ -95,13 +93,6 @@ public static async Task HandleAsync(string[] args) { // Step 3: Default session visibility AnsiConsole.Write(new Rule("[yellow]Step 3/5 — Default session visibility[/]").LeftJustified()); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync(" How should your sessions be visible to others by default?"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync(" 1) All private — only you can see your sessions"); - await Console.Out.WriteLineAsync(" 2) Org repos public, others private (current default)"); - await Console.Out.WriteLineAsync(" 3) All public — everyone can see all your sessions"); - await Console.Out.WriteLineAsync(); string defaultVisibility; @@ -116,21 +107,16 @@ public static async Task HandleAsync(string[] args) { await Console.Out.WriteLineAsync($" Default visibility: {defaultVisibility}"); } else { - while (true) { - Console.Write(" Choose [1-3] (default: 2): "); - var choice = Console.ReadLine()?.Trim(); - - defaultVisibility = choice switch { - "" or null or "2" => "org_public", - "1" => "private", - "3" => "public", - _ => "" - }; - - if (defaultVisibility != "") break; - - await Console.Out.WriteLineAsync(" Invalid choice. Please enter 1, 2, or 3."); - } + defaultVisibility = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("How should your sessions be visible to others by default?") + .AddChoices("org_public", "private", "public") + .UseConverter(v => v switch { + "private" => "All private — only you can see your sessions", + "org_public" => "Org repos public, others private (default)", + "public" => "All public — everyone can see all your sessions", + _ => v + })); } await Console.Out.WriteLineAsync(); @@ -146,34 +132,22 @@ public static async Task HandleAsync(string[] args) { // The plugin directory itself is the marketplace root (single-plugin marketplace) var marketplacePath = pluginPath; - await Console.Out.WriteLineAsync(" Where should the plugin be installed?"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync(" 1) User-wide — all Claude Code sessions (recommended)"); - await Console.Out.WriteLineAsync(" 2) This project only"); - await Console.Out.WriteLineAsync(" 3) Skip — I'll install it manually"); - await Console.Out.WriteLineAsync(); - string pluginScope; if (noPrompt) { pluginScope = GetArg(args, "--plugin-scope") ?? "user"; await Console.Out.WriteLineAsync($" Plugin scope: {pluginScope}"); } else { - while (true) { - Console.Write(" Choose [1-3] (default: 1): "); - var choice = Console.ReadLine()?.Trim(); - - pluginScope = choice switch { - "" or null or "1" => "user", - "2" => "project", - "3" => "skip", - _ => "" - }; - - if (pluginScope != "") break; - - await Console.Out.WriteLineAsync(" Invalid choice. Please enter 1, 2, or 3."); - } + pluginScope = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Where should the plugin be installed?") + .AddChoices("user", "project", "skip") + .UseConverter(v => v switch { + "user" => "User-wide — all Claude Code sessions (recommended)", + "project" => "This project only", + "skip" => "Skip — I'll install it manually", + _ => v + })); } if (pluginScope == "skip") { @@ -211,9 +185,10 @@ public static async Task HandleAsync(string[] args) { daemonName = GetArg(args, "--daemon-name") ?? defaultName; await Console.Out.WriteLineAsync($" Daemon name: {daemonName}"); } else { - Console.Write($" Daemon name [{defaultName}]: "); - var input = Console.ReadLine()?.Trim(); - daemonName = string.IsNullOrEmpty(input) ? defaultName : input; + daemonName = AnsiConsole.Prompt( + new TextPrompt("Daemon name:") + .DefaultValue(defaultName) + .ShowDefaultValue()); } await Console.Out.WriteLineAsync(); From 5dd7fea7a86d9572d86250795c178545507e0f4f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 20:13:51 +0200 Subject: [PATCH 09/12] [DEV-1573] Introduce HistoryDisplay abstraction (plain-only for now) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/Commands/HistoryCommand.cs | 77 +++++++++++++++--------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/src/kapacitor/Commands/HistoryCommand.cs b/src/kapacitor/Commands/HistoryCommand.cs index 1abc27e643..7e7c577647 100644 --- a/src/kapacitor/Commands/HistoryCommand.cs +++ b/src/kapacitor/Commands/HistoryCommand.cs @@ -1,3 +1,4 @@ +using Spectre.Console; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -6,15 +7,44 @@ namespace kapacitor.Commands; static class HistoryCommand { + readonly struct HistoryDisplay { + public bool Tty { get; init; } + // Non-null in Tty mode, null in Plain mode. + public ProgressTask? Footer { get; init; } + + public void SetFooterSession(string sessionIdShort, int totalLines) { + if (Footer is null) return; + Footer.Description = $"[green]Importing[/] {(int)Footer.Value}/{(int)Footer.MaxValue} · {Markup.Escape(sessionIdShort)}: 0/{totalLines} lines"; + } + + public void AdvanceFooterLines(int linesDone, int linesTotal, string sessionIdShort, string? agentSuffixId) { + if (Footer is null) return; + var suffix = agentSuffixId is null ? "" : $" ↳ subagent {Markup.Escape(agentSuffixId)}"; + Footer.Description = $"[green]Importing[/] {(int)Footer.Value}/{(int)Footer.MaxValue} · {Markup.Escape(sessionIdShort)}: {linesDone}/{linesTotal} lines{suffix}"; + } + + public void Line(string plain, string? markup = null) { + if (Tty) AnsiConsole.MarkupLine(markup ?? Markup.Escape(plain)); + else Console.WriteLine(plain); + } + + public static HistoryDisplay Create() { + var tty = !Console.IsOutputRedirected; + + return new HistoryDisplay { Tty = tty, Footer = null }; + } + } + public static async Task HandleHistory(string baseUrl, string? filterCwd, string? filterSession = null, int minLines = 10, bool generateSummaries = false) { using var httpClient = await HttpClientExtensions.CreateAuthenticatedClientAsync(); + var display = HistoryDisplay.Create(); - await Console.Out.WriteLineAsync("Discovering sessions..."); + display.Line("Discovering sessions..."); var projectsDir = ClaudePaths.Projects; if (!Directory.Exists(projectsDir)) { - await Console.Out.WriteLineAsync("No Claude Code projects directory found."); + display.Line("No Claude Code projects directory found."); return 0; } @@ -42,7 +72,7 @@ from jsonlFile in Directory.GetFiles(cwdDir, "*.jsonl") } if (transcriptFiles.Count == 0) { - await Console.Out.WriteLineAsync("No transcript files found."); + display.Line("No transcript files found."); return 0; } @@ -75,8 +105,8 @@ .. transcriptFiles } var projectCount = transcriptFiles.Select(t => t.EncodedCwd).Distinct().Count(); - await Console.Out.WriteLineAsync($"Found {transcriptFiles.Count} session{(transcriptFiles.Count == 1 ? "" : "s")} in {projectCount} project{(projectCount == 1 ? "" : "s")}"); - await Console.Out.WriteLineAsync(); + display.Line($"Found {transcriptFiles.Count} session{(transcriptFiles.Count == 1 ? "" : "s")} in {projectCount} project{(projectCount == 1 ? "" : "s")}"); + display.Line(""); // Build continuation map: group sessions by slug and order by timestamp // so we can link continuations during migration @@ -136,7 +166,7 @@ .. transcriptFiles status = HistorySessionStatus.AlreadyLoaded; } } else { - await Console.Out.WriteLineAsync($"Skipping {sessionId} [server error: HTTP {(int)resp.StatusCode}]"); + display.Line($"Skipping {sessionId} [server error: HTTP {(int)resp.StatusCode}]"); errored++; continue; @@ -146,14 +176,14 @@ .. transcriptFiles } } } catch (HttpRequestException ex) { - await Console.Out.WriteLineAsync($"Skipping {sessionId} [server unreachable: {ex.Message}]"); + display.Line($"Skipping {sessionId} [server unreachable: {ex.Message}]"); errored++; continue; } if (status == HistorySessionStatus.AlreadyLoaded) { - await Console.Out.WriteLineAsync($"Skipping {sessionId} [already loaded]"); + display.Line($"Skipping {sessionId} [already loaded]"); skipped++; continue; @@ -165,7 +195,7 @@ .. transcriptFiles switch (status) { // Skip short transcripts (likely trivial sessions with no meaningful work) case HistorySessionStatus.New when minLines > 0 && totalLines < minLines: - await Console.Out.WriteLineAsync($"Skipping {sessionId} [too short: {totalLines} lines < {minLines} minimum]"); + display.Line($"Skipping {sessionId} [too short: {totalLines} lines < {minLines} minimum]"); skipped++; continue; @@ -213,7 +243,7 @@ .. transcriptFiles && repo.RepoName is not null && excludedRepos.Contains($"{repo.Owner}/{repo.RepoName}", StringComparer.OrdinalIgnoreCase)) { if (Console.IsInputRedirected) { - await Console.Out.WriteLineAsync($"Skipping {sessionId} [repository {repo.Owner}/{repo.RepoName} is excluded]"); + display.Line($"Skipping {sessionId} [repository {repo.Owner}/{repo.RepoName} is excluded]"); skipped++; continue; @@ -223,7 +253,7 @@ .. transcriptFiles var answer = Console.ReadLine()?.Trim(); if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase)) { - await Console.Out.WriteLineAsync($"Skipping {sessionId}"); + display.Line($"Skipping {sessionId}"); skipped++; continue; @@ -250,20 +280,18 @@ .. transcriptFiles var startResp = await httpClient.PostWithRetryAsync($"{baseUrl}/hooks/session-start", startContent); if (!startResp.IsSuccessStatusCode) { - await Console.Out.WriteLineAsync($"Skipping {sessionId} [session-start failed: HTTP {(int)startResp.StatusCode}]"); + display.Line($"Skipping {sessionId} [session-start failed: HTTP {(int)startResp.StatusCode}]"); errored++; continue; } } catch (HttpRequestException ex) { - await Console.Out.WriteLineAsync($"Skipping {sessionId} [server unreachable: {ex.Message}]"); + display.Line($"Skipping {sessionId} [server unreachable: {ex.Message}]"); errored++; continue; } - Console.Write($"Loading {sessionId}... "); - // Import transcript with interleaved agent lifecycle events var importResult = await SessionImporter.ImportSessionAsync( httpClient, @@ -274,11 +302,7 @@ .. transcriptFiles encodedCwd ); - await Console.Out.WriteLineAsync($"{importResult.LinesSent} lines [new]"); - - if (importResult.AgentIds.Count > 0) { - await Console.Out.WriteLineAsync($" {importResult.AgentIds.Count} agent{(importResult.AgentIds.Count == 1 ? "" : "s")} imported inline"); - } + display.Line($"Loading {sessionId}... {importResult.LinesSent} lines [new]"); // POST synthesized session-end hook var lastTimestamp = ExtractLastTimestamp(filePath); @@ -364,9 +388,8 @@ .. transcriptFiles } default: { // Partial load — resume from where we left off - Console.Write($"Loading {sessionId}... "); var linesSent = await SessionImporter.SendTranscriptBatches(httpClient, baseUrl, sessionId, filePath, agentId: null, startLine: resumeFromLine); - await Console.Out.WriteLineAsync($"{linesSent} lines [resuming from line {resumeFromLine}]"); + display.Line($"Loading {sessionId}... {linesSent} lines [resuming from line {resumeFromLine}]"); resumed++; break; @@ -376,7 +399,7 @@ .. transcriptFiles // Wait for background title/summary generation to complete (best effort — never lose the final report) if (backgroundTasks.Count > 0) { - await Console.Out.WriteLineAsync($"Waiting for {backgroundTasks.Count} background task{(backgroundTasks.Count == 1 ? "" : "s")} (titles/summaries)..."); + display.Line($"Waiting for {backgroundTasks.Count} background task{(backgroundTasks.Count == 1 ? "" : "s")} (titles/summaries)..."); try { await Task.WhenAll(backgroundTasks); @@ -391,13 +414,11 @@ .. transcriptFiles if (titlesSkipped > 0) parts.Add($"{titlesSkipped} skipped"); if (titlesFailed + summariesFailed > 0) parts.Add($"{titlesFailed + summariesFailed} failed"); - if (parts.Count > 0) await Console.Out.WriteLineAsync($" {string.Join(", ", parts)}"); + if (parts.Count > 0) display.Line($" {string.Join(", ", parts)}"); } - await Console.Out.WriteLineAsync(); - Console.Write($"Done: {loaded} loaded, {resumed} resumed, {skipped} skipped"); - if (errored > 0) Console.Write($", {errored} errored"); - await Console.Out.WriteLineAsync(); + display.Line(""); + display.Line($"Done: {loaded} loaded, {resumed} resumed, {skipped} skipped{(errored > 0 ? $", {errored} errored" : "")}"); return 0; } From c82df5b8e4a5bfd6bb02390c671f6bccfb7febf6 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 20:18:10 +0200 Subject: [PATCH 10/12] [DEV-1573] History: TTY-mode Spectre progress footer + streaming lines Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/Commands/HistoryCommand.cs | 458 +++++++++++++---------- 1 file changed, 259 insertions(+), 199 deletions(-) diff --git a/src/kapacitor/Commands/HistoryCommand.cs b/src/kapacitor/Commands/HistoryCommand.cs index 7e7c577647..f612f4e794 100644 --- a/src/kapacitor/Commands/HistoryCommand.cs +++ b/src/kapacitor/Commands/HistoryCommand.cs @@ -130,273 +130,333 @@ .. transcriptFiles var summariesGenerated = 0; var summariesFailed = 0; - foreach (var (sessionId, filePath, encodedCwd) in transcriptFiles) { - // Skip transcripts that are kapacitor-spawned sub-sessions (title generation, what's-done summaries) - if (TitleGenerator.IsKapacitorSubSession(filePath)) { - skipped++; + async Task RunLoop() { + foreach (var (sessionId, filePath, encodedCwd) in transcriptFiles) { + // Skip transcripts that are kapacitor-spawned sub-sessions (title generation, what's-done summaries) + if (TitleGenerator.IsKapacitorSubSession(filePath)) { + skipped++; + display.Footer?.Increment(1); - continue; - } + continue; + } - // Check server status via last-line API - HistorySessionStatus status; + // Check server status via last-line API + HistorySessionStatus status; - var resumeFromLine = 0; + var resumeFromLine = 0; - try { - var resp = await httpClient.GetWithRetryAsync($"{baseUrl}/api/sessions/{sessionId}/last-line"); - - switch (resp.StatusCode) { - case System.Net.HttpStatusCode.NotFound: - // 404 = stream doesn't exist, full load needed - status = HistorySessionStatus.New; break; - case System.Net.HttpStatusCode.NoContent: - // 204 = stream exists but no line numbers, skip - status = HistorySessionStatus.AlreadyLoaded; break; - default: { - if (resp.IsSuccessStatusCode) { - // 200 = has line numbers, can resume - var json = await resp.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(json); - - if (doc.RootElement.Num("last_line_number") is { } lastLine) { - resumeFromLine = (int)lastLine + 1; - status = HistorySessionStatus.Partial; + try { + var resp = await httpClient.GetWithRetryAsync($"{baseUrl}/api/sessions/{sessionId}/last-line"); + + switch (resp.StatusCode) { + case System.Net.HttpStatusCode.NotFound: + // 404 = stream doesn't exist, full load needed + status = HistorySessionStatus.New; break; + case System.Net.HttpStatusCode.NoContent: + // 204 = stream exists but no line numbers, skip + status = HistorySessionStatus.AlreadyLoaded; break; + default: { + if (resp.IsSuccessStatusCode) { + // 200 = has line numbers, can resume + var json = await resp.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + + if (doc.RootElement.Num("last_line_number") is { } lastLine) { + resumeFromLine = (int)lastLine + 1; + status = HistorySessionStatus.Partial; + } else { + status = HistorySessionStatus.AlreadyLoaded; + } } else { - status = HistorySessionStatus.AlreadyLoaded; + display.Line($"Skipping {sessionId} [server error: HTTP {(int)resp.StatusCode}]"); + errored++; + display.Footer?.Increment(1); + + continue; } - } else { - display.Line($"Skipping {sessionId} [server error: HTTP {(int)resp.StatusCode}]"); - errored++; - continue; + break; } - - break; } - } - } catch (HttpRequestException ex) { - display.Line($"Skipping {sessionId} [server unreachable: {ex.Message}]"); - errored++; - - continue; - } + } catch (HttpRequestException ex) { + display.Line($"Skipping {sessionId} [server unreachable: {ex.Message}]"); + errored++; + display.Footer?.Increment(1); - if (status == HistorySessionStatus.AlreadyLoaded) { - display.Line($"Skipping {sessionId} [already loaded]"); - skipped++; - - continue; - } - - // Count total lines for progress display - var totalLines = WatchCommand.CountFileLines(filePath); + continue; + } - switch (status) { - // Skip short transcripts (likely trivial sessions with no meaningful work) - case HistorySessionStatus.New when minLines > 0 && totalLines < minLines: - display.Line($"Skipping {sessionId} [too short: {totalLines} lines < {minLines} minimum]"); + if (status == HistorySessionStatus.AlreadyLoaded) { + display.Line($"Skipping {sessionId} [already loaded]"); skipped++; + display.Footer?.Increment(1); continue; - case HistorySessionStatus.New: { - // Extract metadata from transcript for session-start hook - var meta = ExtractSessionMetadata(filePath); - - // POST synthesized session-start hook - continuationMap.TryGetValue(sessionId, out var prevSessionId); - - // Note: default_visibility is deliberately omitted for history imports. - // Historical sessions predate the user's visibility preference; null falls - // back to org_public behavior, which is the safest default for imported data. - var startHook = new JsonObject { - ["session_id"] = sessionId, - ["transcript_path"] = filePath, - ["cwd"] = meta.Cwd ?? DecodeCwdFromDirName(encodedCwd), - ["source"] = "Startup", - ["hook_event_name"] = "session_start", - ["model"] = meta.Model - }; - - if (meta.FirstTimestamp is not null) { - startHook["started_at"] = meta.FirstTimestamp.Value.ToString("O"); - } + } - // Pass continuation info directly (bypasses pending continuation mechanism) - if (prevSessionId is not null) { - startHook["previous_session_id"] = prevSessionId; + // Count total lines for progress display + var totalLines = WatchCommand.CountFileLines(filePath); + + var sessionIdShort = sessionId.Length >= 8 ? sessionId[..8] : sessionId; + var linesDone = 0; + string? currentSubagent = null; + + display.SetFooterSession(sessionIdShort, totalLines); + + var perSessionProgress = new Progress(ev => { + switch (ev) { + case BatchFlushed bf: + linesDone += bf.LinesAdded; + display.AdvanceFooterLines(linesDone, totalLines, sessionIdShort, currentSubagent); + break; + case SubagentStarted ss: + currentSubagent = ss.AgentId.Length >= 8 ? ss.AgentId[..8] : ss.AgentId; + display.AdvanceFooterLines(linesDone, totalLines, sessionIdShort, currentSubagent); + break; + case SubagentFinished sf: + display.Line( + $" ↳ imported subagent {sf.AgentId} ({sf.LinesSent} lines)", + $" [dim]↳[/] imported subagent [cyan]{Markup.Escape(sf.AgentId)}[/] ({sf.LinesSent} lines)"); + currentSubagent = null; + display.AdvanceFooterLines(linesDone, totalLines, sessionIdShort, null); + break; + } } + ); - if (meta.Slug is not null) { - startHook["slug"] = meta.Slug; - } + switch (status) { + // Skip short transcripts (likely trivial sessions with no meaningful work) + case HistorySessionStatus.New when minLines > 0 && totalLines < minLines: + display.Line($"Skipping {sessionId} [too short: {totalLines} lines < {minLines} minimum]"); + skipped++; + display.Footer?.Increment(1); - // Enrich with repository info if we have a cwd - var startCwd = meta.Cwd ?? DecodeCwdFromDirName(encodedCwd); + continue; + case HistorySessionStatus.New: { + // Extract metadata from transcript for session-start hook + var meta = ExtractSessionMetadata(filePath); + + // POST synthesized session-start hook + continuationMap.TryGetValue(sessionId, out var prevSessionId); + + // Note: default_visibility is deliberately omitted for history imports. + // Historical sessions predate the user's visibility preference; null falls + // back to org_public behavior, which is the safest default for imported data. + var startHook = new JsonObject { + ["session_id"] = sessionId, + ["transcript_path"] = filePath, + ["cwd"] = meta.Cwd ?? DecodeCwdFromDirName(encodedCwd), + ["source"] = "Startup", + ["hook_event_name"] = "session_start", + ["model"] = meta.Model + }; + + if (meta.FirstTimestamp is not null) { + startHook["started_at"] = meta.FirstTimestamp.Value.ToString("O"); + } - if (startCwd is not null) { - var repo = await RepositoryDetection.DetectRepositoryAsync(startCwd); + // Pass continuation info directly (bypasses pending continuation mechanism) + if (prevSessionId is not null) { + startHook["previous_session_id"] = prevSessionId; + } - // Check repo exclusion - if (excludedRepos is { Length: > 0 } - && repo?.Owner is not null - && repo.RepoName is not null - && excludedRepos.Contains($"{repo.Owner}/{repo.RepoName}", StringComparer.OrdinalIgnoreCase)) { - if (Console.IsInputRedirected) { - display.Line($"Skipping {sessionId} [repository {repo.Owner}/{repo.RepoName} is excluded]"); - skipped++; + if (meta.Slug is not null) { + startHook["slug"] = meta.Slug; + } - continue; - } + // Enrich with repository info if we have a cwd + var startCwd = meta.Cwd ?? DecodeCwdFromDirName(encodedCwd); - Console.Write($"Repository {repo.Owner}/{repo.RepoName} is excluded from tracking. Continue anyway? (y/N) "); - var answer = Console.ReadLine()?.Trim(); + if (startCwd is not null) { + var repo = await RepositoryDetection.DetectRepositoryAsync(startCwd); - if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase)) { - display.Line($"Skipping {sessionId}"); - skipped++; + // Check repo exclusion + if (excludedRepos is { Length: > 0 } + && repo?.Owner is not null + && repo.RepoName is not null + && excludedRepos.Contains($"{repo.Owner}/{repo.RepoName}", StringComparer.OrdinalIgnoreCase)) { + if (Console.IsInputRedirected) { + display.Line($"Skipping {sessionId} [repository {repo.Owner}/{repo.RepoName} is excluded]"); + skipped++; + display.Footer?.Increment(1); - continue; + continue; + } + + Console.Write($"Repository {repo.Owner}/{repo.RepoName} is excluded from tracking. Continue anyway? (y/N) "); + var answer = Console.ReadLine()?.Trim(); + + if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase)) { + display.Line($"Skipping {sessionId}"); + skipped++; + display.Footer?.Increment(1); + + continue; + } } - } - if (repo is not null) { - var repoNode = new JsonObject(); + if (repo is not null) { + var repoNode = new JsonObject(); #pragma warning disable IDE0011 - if (repo.UserName is not null) repoNode["user_name"] = repo.UserName; - if (repo.UserEmail is not null) repoNode["user_email"] = repo.UserEmail; - if (repo.RemoteUrl is not null) repoNode["remote_url"] = repo.RemoteUrl; - if (repo.Owner is not null) repoNode["owner"] = repo.Owner; - if (repo.RepoName is not null) repoNode["repo_name"] = repo.RepoName; - if (repo.Branch is not null) repoNode["branch"] = repo.Branch; + if (repo.UserName is not null) repoNode["user_name"] = repo.UserName; + if (repo.UserEmail is not null) repoNode["user_email"] = repo.UserEmail; + if (repo.RemoteUrl is not null) repoNode["remote_url"] = repo.RemoteUrl; + if (repo.Owner is not null) repoNode["owner"] = repo.Owner; + if (repo.RepoName is not null) repoNode["repo_name"] = repo.RepoName; + if (repo.Branch is not null) repoNode["branch"] = repo.Branch; #pragma warning restore IDE0011 - startHook["repository"] = repoNode; + startHook["repository"] = repoNode; + } } - } - try { - using var startContent = new StringContent(startHook.ToJsonString(), Encoding.UTF8, "application/json"); + try { + using var startContent = new StringContent(startHook.ToJsonString(), Encoding.UTF8, "application/json"); - var startResp = await httpClient.PostWithRetryAsync($"{baseUrl}/hooks/session-start", startContent); + var startResp = await httpClient.PostWithRetryAsync($"{baseUrl}/hooks/session-start", startContent); - if (!startResp.IsSuccessStatusCode) { - display.Line($"Skipping {sessionId} [session-start failed: HTTP {(int)startResp.StatusCode}]"); + if (!startResp.IsSuccessStatusCode) { + display.Line($"Skipping {sessionId} [session-start failed: HTTP {(int)startResp.StatusCode}]"); + errored++; + display.Footer?.Increment(1); + + continue; + } + } catch (HttpRequestException ex) { + display.Line($"Skipping {sessionId} [server unreachable: {ex.Message}]"); errored++; + display.Footer?.Increment(1); continue; } - } catch (HttpRequestException ex) { - display.Line($"Skipping {sessionId} [server unreachable: {ex.Message}]"); - errored++; - continue; - } + // Import transcript with interleaved agent lifecycle events + var importResult = await SessionImporter.ImportSessionAsync( + httpClient, + baseUrl, + filePath, + sessionId, + meta, + encodedCwd, + perSessionProgress + ); - // Import transcript with interleaved agent lifecycle events - var importResult = await SessionImporter.ImportSessionAsync( - httpClient, - baseUrl, - filePath, - sessionId, - meta, - encodedCwd - ); - - display.Line($"Loading {sessionId}... {importResult.LinesSent} lines [new]"); - - // POST synthesized session-end hook - var lastTimestamp = ExtractLastTimestamp(filePath); - - var endHook = new JsonObject { - ["session_id"] = sessionId, - ["transcript_path"] = filePath, - ["cwd"] = startCwd ?? "", - ["reason"] = "Other", - ["hook_event_name"] = "session_end" - }; - - if (lastTimestamp is not null) { - endHook["ended_at"] = lastTimestamp.Value.ToString("O"); - } + display.Line($"Loading {sessionId}... {importResult.LinesSent} lines [new]"); - var shouldGenerateWhatsDone = false; + // POST synthesized session-end hook + var lastTimestamp = ExtractLastTimestamp(filePath); - try { - using var endContent = new StringContent(endHook.ToJsonString(), Encoding.UTF8, "application/json"); - using var endResp = await httpClient.PostWithRetryAsync($"{baseUrl}/hooks/session-end", endContent); + var endHook = new JsonObject { + ["session_id"] = sessionId, + ["transcript_path"] = filePath, + ["cwd"] = startCwd ?? "", + ["reason"] = "Other", + ["hook_event_name"] = "session_end" + }; - if (generateSummaries && endResp.IsSuccessStatusCode) { - try { - var endBody = await endResp.Content.ReadAsStringAsync(); - var endRespNode = JsonNode.Parse(endBody); - shouldGenerateWhatsDone = endRespNode?["generate_whats_done"]?.GetValue() == true; - } catch { - // Best effort response parsing - } + if (lastTimestamp is not null) { + endHook["ended_at"] = lastTimestamp.Value.ToString("O"); } - } catch { - // Best effort for session end - } - // Generate Claude title in background (overlaps with next session's import) - var titleSessionId = sessionId; - var titleFilePath = filePath; + var shouldGenerateWhatsDone = false; - backgroundTasks.Add( - Task.Run(async () => { - await concurrencyLimit.WaitAsync(); + try { + using var endContent = new StringContent(endHook.ToJsonString(), Encoding.UTF8, "application/json"); + using var endResp = await httpClient.PostWithRetryAsync($"{baseUrl}/hooks/session-end", endContent); + if (generateSummaries && endResp.IsSuccessStatusCode) { try { - var result = await GenerateTitleForImportAsync(httpClient, baseUrl, titleSessionId, titleFilePath); - - switch (result) { - case TitleResult.Generated: Interlocked.Increment(ref titlesGenerated); break; - case TitleResult.Skipped: Interlocked.Increment(ref titlesSkipped); break; - case TitleResult.Failed: Interlocked.Increment(ref titlesFailed); break; - } - } finally { - concurrencyLimit.Release(); + var endBody = await endResp.Content.ReadAsStringAsync(); + var endRespNode = JsonNode.Parse(endBody); + shouldGenerateWhatsDone = endRespNode?["generate_whats_done"]?.GetValue() == true; + } catch { + // Best effort response parsing } } - ) - ); + } catch { + // Best effort for session end + } + + // Generate Claude title in background (overlaps with next session's import) + var titleSessionId = sessionId; + var titleFilePath = filePath; - // Generate what's-done summary in background if requested - if (shouldGenerateWhatsDone) { backgroundTasks.Add( Task.Run(async () => { await concurrencyLimit.WaitAsync(); try { - var wdResult = await WhatsDoneCommand.GenerateForSessionAsync(baseUrl, titleSessionId, _ => { }); + var result = await GenerateTitleForImportAsync(httpClient, baseUrl, titleSessionId, titleFilePath); - if (wdResult == 0) Interlocked.Increment(ref summariesGenerated); - else Interlocked.Increment(ref summariesFailed); - } catch { - Interlocked.Increment(ref summariesFailed); + switch (result) { + case TitleResult.Generated: Interlocked.Increment(ref titlesGenerated); break; + case TitleResult.Skipped: Interlocked.Increment(ref titlesSkipped); break; + case TitleResult.Failed: Interlocked.Increment(ref titlesFailed); break; + } } finally { concurrencyLimit.Release(); } } ) ); - } - loaded++; + // Generate what's-done summary in background if requested + if (shouldGenerateWhatsDone) { + backgroundTasks.Add( + Task.Run(async () => { + await concurrencyLimit.WaitAsync(); + + try { + var wdResult = await WhatsDoneCommand.GenerateForSessionAsync(baseUrl, titleSessionId, _ => { }); + + if (wdResult == 0) Interlocked.Increment(ref summariesGenerated); + else Interlocked.Increment(ref summariesFailed); + } catch { + Interlocked.Increment(ref summariesFailed); + } finally { + concurrencyLimit.Release(); + } + } + ) + ); + } - break; - } - default: { - // Partial load — resume from where we left off - var linesSent = await SessionImporter.SendTranscriptBatches(httpClient, baseUrl, sessionId, filePath, agentId: null, startLine: resumeFromLine); - display.Line($"Loading {sessionId}... {linesSent} lines [resuming from line {resumeFromLine}]"); - resumed++; + loaded++; + display.Footer?.Increment(1); + + break; + } + default: { + // Partial load — resume from where we left off + var linesSent = await SessionImporter.SendTranscriptBatches( + httpClient, baseUrl, sessionId, filePath, agentId: null, + startLine: resumeFromLine, progress: perSessionProgress + ); + display.Line($"Loading {sessionId}... {linesSent} lines [resuming from line {resumeFromLine}]"); + resumed++; + display.Footer?.Increment(1); - break; + break; + } } } } + if (display.Tty) { + await AnsiConsole.Progress() + .AutoClear(false) + .HideCompleted(false) + .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) + .StartAsync(async ctx => { + var footer = ctx.AddTask("[green]Importing[/]", maxValue: transcriptFiles.Count); + display = display with { Footer = footer }; + + await RunLoop(); + footer.Value = footer.MaxValue; + }); + } else { + await RunLoop(); + } + // Wait for background title/summary generation to complete (best effort — never lose the final report) if (backgroundTasks.Count > 0) { display.Line($"Waiting for {backgroundTasks.Count} background task{(backgroundTasks.Count == 1 ? "" : "s")} (titles/summaries)..."); From 20de44a39da5e4f5193b8012ecdce6278dcb4db5 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 20:21:56 +0200 Subject: [PATCH 11/12] [DEV-1573] History: background-phase progress + streaming failure lines Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/Commands/HistoryCommand.cs | 95 +++++++++++++++++++++--- 1 file changed, 86 insertions(+), 9 deletions(-) diff --git a/src/kapacitor/Commands/HistoryCommand.cs b/src/kapacitor/Commands/HistoryCommand.cs index f612f4e794..76ab3a6073 100644 --- a/src/kapacitor/Commands/HistoryCommand.cs +++ b/src/kapacitor/Commands/HistoryCommand.cs @@ -129,6 +129,8 @@ .. transcriptFiles var titlesFailed = 0; var summariesGenerated = 0; var summariesFailed = 0; + var titleFailures = new System.Collections.Concurrent.ConcurrentBag<(string SessionId, string Reason)>(); + var summaryFailures = new System.Collections.Concurrent.ConcurrentBag<(string SessionId, string Reason)>(); async Task RunLoop() { foreach (var (sessionId, filePath, encodedCwd) in transcriptFiles) { @@ -390,7 +392,10 @@ async Task RunLoop() { switch (result) { case TitleResult.Generated: Interlocked.Increment(ref titlesGenerated); break; case TitleResult.Skipped: Interlocked.Increment(ref titlesSkipped); break; - case TitleResult.Failed: Interlocked.Increment(ref titlesFailed); break; + case TitleResult.Failed: + Interlocked.Increment(ref titlesFailed); + titleFailures.Add((titleSessionId, "generation error")); + break; } } finally { concurrencyLimit.Release(); @@ -408,10 +413,15 @@ async Task RunLoop() { try { var wdResult = await WhatsDoneCommand.GenerateForSessionAsync(baseUrl, titleSessionId, _ => { }); - if (wdResult == 0) Interlocked.Increment(ref summariesGenerated); - else Interlocked.Increment(ref summariesFailed); - } catch { + if (wdResult == 0) { + Interlocked.Increment(ref summariesGenerated); + } else { + Interlocked.Increment(ref summariesFailed); + summaryFailures.Add((titleSessionId, $"exit {wdResult}")); + } + } catch (Exception ex) { Interlocked.Increment(ref summariesFailed); + summaryFailures.Add((titleSessionId, ex.Message)); } finally { concurrencyLimit.Release(); } @@ -459,12 +469,79 @@ await AnsiConsole.Progress() // Wait for background title/summary generation to complete (best effort — never lose the final report) if (backgroundTasks.Count > 0) { - display.Line($"Waiting for {backgroundTasks.Count} background task{(backgroundTasks.Count == 1 ? "" : "s")} (titles/summaries)..."); + if (display.Tty) { + AnsiConsole.Write(new Rule($"[dim]── Waiting for {backgroundTasks.Count} background task(s) ──[/]").LeftJustified()); + + await AnsiConsole.Progress() + .AutoClear(false) + .HideCompleted(false) + .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) + .StartAsync(async ctx => { + var maxVal = backgroundTasks.Count; + var titleTask = ctx.AddTask("[cyan]Titles[/]", maxValue: maxVal); + var summaryTask = ctx.AddTask("[cyan]Summaries[/]", maxValue: maxVal); + + var seenTitleFailures = 0; + var seenSummaryFailures = 0; + + while (backgroundTasks.Any(t => !t.IsCompleted)) { + titleTask.Value = titlesGenerated + titlesFailed + titlesSkipped; + summaryTask.Value = summariesGenerated + summariesFailed; + + var titleFailSnapshot = titleFailures.ToList(); + var summaryFailSnapshot = summaryFailures.ToList(); + + for (var i = seenTitleFailures; i < titleFailSnapshot.Count; i++) { + var (sid, reason) = titleFailSnapshot[i]; + AnsiConsole.MarkupLine($" [red]✗[/] title failed for [cyan]{Markup.Escape(sid)}[/]: {Markup.Escape(reason)}"); + } + seenTitleFailures = titleFailSnapshot.Count; + + for (var i = seenSummaryFailures; i < summaryFailSnapshot.Count; i++) { + var (sid, reason) = summaryFailSnapshot[i]; + AnsiConsole.MarkupLine($" [red]✗[/] summary failed for [cyan]{Markup.Escape(sid)}[/]: {Markup.Escape(reason)}"); + } + seenSummaryFailures = summaryFailSnapshot.Count; + + await Task.Delay(250); + } + + try { + await Task.WhenAll(backgroundTasks); + } catch { + // per-task try/catch handles individual failures + } + + var titleFinal = titleFailures.ToList(); + var summaryFinal = summaryFailures.ToList(); - try { - await Task.WhenAll(backgroundTasks); - } catch { - // Individual tasks already have try/catch; this guards against unexpected faults + for (var i = seenTitleFailures; i < titleFinal.Count; i++) { + var (sid, reason) = titleFinal[i]; + AnsiConsole.MarkupLine($" [red]✗[/] title failed for [cyan]{Markup.Escape(sid)}[/]: {Markup.Escape(reason)}"); + } + for (var i = seenSummaryFailures; i < summaryFinal.Count; i++) { + var (sid, reason) = summaryFinal[i]; + AnsiConsole.MarkupLine($" [red]✗[/] summary failed for [cyan]{Markup.Escape(sid)}[/]: {Markup.Escape(reason)}"); + } + + titleTask.Value = titlesGenerated + titlesFailed + titlesSkipped; + summaryTask.Value = summariesGenerated + summariesFailed; + }); + } else { + display.Line($"Waiting for {backgroundTasks.Count} background task(s) (titles/summaries)..."); + + try { + await Task.WhenAll(backgroundTasks); + } catch { + // per-task try/catch handles individual failures + } + + foreach (var (sid, reason) in titleFailures) { + display.Line($" ✗ title failed for {sid}: {reason}"); + } + foreach (var (sid, reason) in summaryFailures) { + display.Line($" ✗ summary failed for {sid}: {reason}"); + } } var parts = new List(); From 7292054d84f7ca3c1a8caa1808bac64a0a977bd2 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 23 Apr 2026 22:12:50 +0200 Subject: [PATCH 12/12] Address Qodo review: background progress, ordering, subagent line accounting - Tag BatchFlushed with the owning AgentId so HistoryCommand only counts parent-transcript lines against linesTotal (the file it measured with CountFileLines). Previously subagent batches could push linesDone past linesTotal. - Switch the per-session progress sink from Progress to a synchronous InlineProgress. new Progress marshals to the ThreadPool in a console app, so footer mutations and streamed lines could arrive after the sequential import loop moved on. - Track title and summary task counts separately and use them as the respective ProgressTask maxValue. Previously both bars used backgroundTasks.Count, so with both task types enqueued neither bar could reach 100%. Skip the summary bar entirely when no summaries are requested. - Update unit tests to assert AgentId attribution on BatchFlushed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kapacitor/Commands/HistoryCommand.cs | 44 ++++++++++++++----- src/kapacitor/Commands/ImportProgress.cs | 8 +++- src/kapacitor/Commands/SessionImporter.cs | 10 ++--- .../SessionImporterProgressTests.cs | 11 +++++ 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/kapacitor/Commands/HistoryCommand.cs b/src/kapacitor/Commands/HistoryCommand.cs index 76ab3a6073..5ea75861bb 100644 --- a/src/kapacitor/Commands/HistoryCommand.cs +++ b/src/kapacitor/Commands/HistoryCommand.cs @@ -7,6 +7,17 @@ namespace kapacitor.Commands; static class HistoryCommand { + /// + /// Synchronous whose invokes + /// the handler inline. new Progress<T> in a console app marshals to + /// the ThreadPool, so footer mutations and streamed lines could arrive after + /// the sequential import loop moved to the next session. Inline reporting + /// keeps UI updates ordered with the import they describe. + /// + sealed class InlineProgress(Action onReport) : IProgress { + public void Report(T value) => onReport(value); + } + readonly struct HistoryDisplay { public bool Tty { get; init; } // Non-null in Tty mode, null in Plain mode. @@ -121,8 +132,12 @@ .. transcriptFiles var errored = 0; var excludedRepos = (await AppConfig.Load())?.ExcludedRepos; - // Background tasks for title and summary generation (run in parallel with imports) + // Background tasks for title and summary generation (run in parallel with imports). + // Separate counts (not backgroundTasks.Count) drive the progress bars' maxValue so + // each bar can actually reach 100% when both title and summary tasks are enqueued. var backgroundTasks = new List(); + var titleTaskCount = 0; + var summaryTaskCount = 0; var concurrencyLimit = new SemaphoreSlim(3); var titlesGenerated = 0; var titlesSkipped = 0; @@ -205,12 +220,18 @@ async Task RunLoop() { display.SetFooterSession(sessionIdShort, totalLines); - var perSessionProgress = new Progress(ev => { + var perSessionProgress = new InlineProgress(ev => { switch (ev) { - case BatchFlushed bf: + // Only parent-transcript batches contribute to the + // footer's lines/total pair; subagent batches are + // surfaced via SubagentFinished so linesDone stays + // bounded by totalLines. + case BatchFlushed { AgentId: null } bf: linesDone += bf.LinesAdded; display.AdvanceFooterLines(linesDone, totalLines, sessionIdShort, currentSubagent); break; + case BatchFlushed: + break; case SubagentStarted ss: currentSubagent = ss.AgentId.Length >= 8 ? ss.AgentId[..8] : ss.AgentId; display.AdvanceFooterLines(linesDone, totalLines, sessionIdShort, currentSubagent); @@ -403,6 +424,7 @@ async Task RunLoop() { } ) ); + titleTaskCount++; // Generate what's-done summary in background if requested if (shouldGenerateWhatsDone) { @@ -428,6 +450,7 @@ async Task RunLoop() { } ) ); + summaryTaskCount++; } loaded++; @@ -477,16 +500,17 @@ await AnsiConsole.Progress() .HideCompleted(false) .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) .StartAsync(async ctx => { - var maxVal = backgroundTasks.Count; - var titleTask = ctx.AddTask("[cyan]Titles[/]", maxValue: maxVal); - var summaryTask = ctx.AddTask("[cyan]Summaries[/]", maxValue: maxVal); + // Skip summary bar when no summaries were requested — Spectre + // renders a zero-max bar as a permanent 0/0 which is noise. + var titleTask = titleTaskCount > 0 ? ctx.AddTask("[cyan]Titles[/]", maxValue: titleTaskCount) : null; + var summaryTask = summaryTaskCount > 0 ? ctx.AddTask("[cyan]Summaries[/]", maxValue: summaryTaskCount) : null; var seenTitleFailures = 0; var seenSummaryFailures = 0; while (backgroundTasks.Any(t => !t.IsCompleted)) { - titleTask.Value = titlesGenerated + titlesFailed + titlesSkipped; - summaryTask.Value = summariesGenerated + summariesFailed; + titleTask ?.Value = titlesGenerated + titlesFailed + titlesSkipped; + summaryTask?.Value = summariesGenerated + summariesFailed; var titleFailSnapshot = titleFailures.ToList(); var summaryFailSnapshot = summaryFailures.ToList(); @@ -524,8 +548,8 @@ await AnsiConsole.Progress() AnsiConsole.MarkupLine($" [red]✗[/] summary failed for [cyan]{Markup.Escape(sid)}[/]: {Markup.Escape(reason)}"); } - titleTask.Value = titlesGenerated + titlesFailed + titlesSkipped; - summaryTask.Value = summariesGenerated + summariesFailed; + titleTask ?.Value = titlesGenerated + titlesFailed + titlesSkipped; + summaryTask?.Value = summariesGenerated + summariesFailed; }); } else { display.Line($"Waiting for {backgroundTasks.Count} background task(s) (titles/summaries)..."); diff --git a/src/kapacitor/Commands/ImportProgress.cs b/src/kapacitor/Commands/ImportProgress.cs index 4bcb1f246e..5067ed88b6 100644 --- a/src/kapacitor/Commands/ImportProgress.cs +++ b/src/kapacitor/Commands/ImportProgress.cs @@ -7,8 +7,12 @@ namespace kapacitor.Commands; /// public abstract record ImportProgress; -/// Fired after a transcript batch is flushed to the server. -public sealed record BatchFlushed(int LinesAdded) : ImportProgress; +/// +/// Fired after a transcript batch is flushed to the server. +/// is non-null when the flushed batch belongs to a +/// subagent transcript, letting callers attribute lines to the right owner. +/// +public sealed record BatchFlushed(string? AgentId, int LinesAdded) : ImportProgress; /// Fired when the importer begins streaming a subagent's transcript inline. public sealed record SubagentStarted(string AgentId) : ImportProgress; diff --git a/src/kapacitor/Commands/SessionImporter.cs b/src/kapacitor/Commands/SessionImporter.cs index 26cf64ae1e..e8c53ac48a 100644 --- a/src/kapacitor/Commands/SessionImporter.cs +++ b/src/kapacitor/Commands/SessionImporter.cs @@ -70,7 +70,7 @@ internal static async Task ImportSessionAsync( await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers); var flushed = batchLines.Count; totalSent += flushed; - progress?.Report(new BatchFlushed(flushed)); + progress?.Report(new BatchFlushed(AgentId: null, flushed)); batchLines.Clear(); batchLineNumbers.Clear(); } @@ -94,7 +94,7 @@ internal static async Task ImportSessionAsync( await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers); var flushed = batchLines.Count; totalSent += flushed; - progress?.Report(new BatchFlushed(flushed)); + progress?.Report(new BatchFlushed(AgentId: null, flushed)); batchLines.Clear(); batchLineNumbers.Clear(); } @@ -105,7 +105,7 @@ internal static async Task ImportSessionAsync( await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId: null, batchLines, batchLineNumbers); var flushed = batchLines.Count; totalSent += flushed; - progress?.Report(new BatchFlushed(flushed)); + progress?.Report(new BatchFlushed(AgentId: null, flushed)); } // Send any agents that had transcript files but NO progress marker in the @@ -472,7 +472,7 @@ internal static async Task SendTranscriptBatches( await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId, batchLines, batchLineNumbers); var flushed = batchLines.Count; totalSent += flushed; - progress?.Report(new BatchFlushed(flushed)); + progress?.Report(new BatchFlushed(agentId, flushed)); batchLines.Clear(); batchLineNumbers.Clear(); } @@ -482,7 +482,7 @@ internal static async Task SendTranscriptBatches( await PostTranscriptBatch(httpClient, baseUrl, sessionId, agentId, batchLines, batchLineNumbers); var flushed = batchLines.Count; totalSent += flushed; - progress?.Report(new BatchFlushed(flushed)); + progress?.Report(new BatchFlushed(agentId, flushed)); } return totalSent; diff --git a/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs b/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs index 845524d088..974c388145 100644 --- a/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs +++ b/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs @@ -41,6 +41,7 @@ await File.WriteAllLinesAsync(path, Enumerable.Range(0, 250).Select(i => await Assert.That(flushes[0].LinesAdded).IsEqualTo(100); await Assert.That(flushes[1].LinesAdded).IsEqualTo(100); await Assert.That(flushes[2].LinesAdded).IsEqualTo(50); + await Assert.That(flushes.All(f => f.AgentId == null)).IsTrue(); } finally { File.Delete(path); } @@ -98,6 +99,16 @@ await File.WriteAllLinesAsync(agentPath, [ await Assert.That(startIdx).IsGreaterThanOrEqualTo(0); await Assert.That(finishIdx).IsGreaterThan(startIdx); await Assert.That(((SubagentFinished)ordered[finishIdx]).LinesSent).IsEqualTo(3); + + // Batch flushes between the subagent boundaries must carry the agent id + // so UI callers can attribute lines to parent vs subagent. + var subagentBatches = ordered + .Skip(startIdx) + .Take(finishIdx - startIdx) + .OfType() + .ToList(); + await Assert.That(subagentBatches.Count).IsGreaterThan(0); + await Assert.That(subagentBatches.All(b => b.AgentId == agentId)).IsTrue(); } finally { tmp.Delete(recursive: true); }