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. 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). diff --git a/src/kapacitor/Commands/HistoryCommand.cs b/src/kapacitor/Commands/HistoryCommand.cs index 1abc27e643..5ea75861bb 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,55 @@ 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. + 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 +83,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 +116,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 @@ -91,297 +132,440 @@ .. 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; 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)>(); - 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++; - - continue; - } + 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); - // Check server status via last-line API - HistorySessionStatus status; + continue; + } - var resumeFromLine = 0; + // Check server status via last-line API + HistorySessionStatus status; - try { - var resp = await httpClient.GetWithRetryAsync($"{baseUrl}/api/sessions/{sessionId}/last-line"); + var resumeFromLine = 0; - 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 { - await Console.Out.WriteLineAsync($"Skipping {sessionId} [server error: HTTP {(int)resp.StatusCode}]"); - errored++; - continue; + break; } - - break; } - } - } catch (HttpRequestException ex) { - await Console.Out.WriteLineAsync($"Skipping {sessionId} [server unreachable: {ex.Message}]"); - errored++; - - continue; - } - - if (status == HistorySessionStatus.AlreadyLoaded) { - await Console.Out.WriteLineAsync($"Skipping {sessionId} [already loaded]"); - skipped++; - - continue; - } + } catch (HttpRequestException ex) { + display.Line($"Skipping {sessionId} [server unreachable: {ex.Message}]"); + errored++; + display.Footer?.Increment(1); - // 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: - await Console.Out.WriteLineAsync($"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 InlineProgress(ev => { + switch (ev) { + // 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); + 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) { - await Console.Out.WriteLineAsync($"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)) { - await Console.Out.WriteLineAsync($"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) { - await Console.Out.WriteLineAsync($"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) { - await Console.Out.WriteLineAsync($"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, - baseUrl, - filePath, - sessionId, - meta, - 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"); - } - - // 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" - }; + // Import transcript with interleaved agent lifecycle events + var importResult = await SessionImporter.ImportSessionAsync( + httpClient, + baseUrl, + filePath, + sessionId, + meta, + encodedCwd, + perSessionProgress + ); - 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, _ => { }); - - if (wdResult == 0) Interlocked.Increment(ref summariesGenerated); - else Interlocked.Increment(ref summariesFailed); - } catch { - Interlocked.Increment(ref summariesFailed); + 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); + titleFailures.Add((titleSessionId, "generation error")); + break; + } } finally { concurrencyLimit.Release(); } } ) ); - } + titleTaskCount++; + + // 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); + summaryFailures.Add((titleSessionId, $"exit {wdResult}")); + } + } catch (Exception ex) { + Interlocked.Increment(ref summariesFailed); + summaryFailures.Add((titleSessionId, ex.Message)); + } finally { + concurrencyLimit.Release(); + } + } + ) + ); + summaryTaskCount++; + } - loaded++; + loaded++; + display.Footer?.Increment(1); - break; - } - 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}]"); - resumed++; - - break; + 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; + } } } } + 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) { - await Console.Out.WriteLineAsync($"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 => { + // 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; + + 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(); + + 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)}"); + } - try { - await Task.WhenAll(backgroundTasks); - } catch { - // Individual tasks already have try/catch; this guards against unexpected faults + 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(); @@ -391,13 +575,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; } diff --git a/src/kapacitor/Commands/ImportProgress.cs b/src/kapacitor/Commands/ImportProgress.cs new file mode 100644 index 0000000000..5067ed88b6 --- /dev/null +++ b/src/kapacitor/Commands/ImportProgress.cs @@ -0,0 +1,21 @@ +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. +/// 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; + +/// Fired after a subagent's transcript has been fully streamed. +public sealed record SubagentFinished(string AgentId, int LinesSent) : ImportProgress; diff --git a/src/kapacitor/Commands/SessionImporter.cs b/src/kapacitor/Commands/SessionImporter.cs index d0b6f0c6a9..e8c53ac48a 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(AgentId: null, 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(AgentId: null, 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(AgentId: null, 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,18 +426,21 @@ string sessionTranscriptPath } catch { // Best effort } + + return agentLines; } /// /// 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 +470,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(agentId, 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(agentId, flushed)); } return totalSent; diff --git a/src/kapacitor/Commands/SetupCommand.cs b/src/kapacitor/Commands/SetupCommand.cs index c881793029..f72e2ea568 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(); @@ -20,18 +20,19 @@ 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; } } // 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) { @@ -42,28 +43,28 @@ 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 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 +72,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,14 +92,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"); - 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(); + AnsiConsole.Write(new Rule("[yellow]Step 3/5 — Default session visibility[/]").LeftJustified()); string defaultVisibility; @@ -113,27 +107,22 @@ 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(); // 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(); @@ -143,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") { @@ -199,7 +176,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; @@ -208,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(); @@ -232,18 +210,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; } 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 @@ + diff --git a/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs b/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs new file mode 100644 index 0000000000..974c388145 --- /dev/null +++ b/test/kapacitor.Tests.Unit/SessionImporterProgressTests.cs @@ -0,0 +1,116 @@ +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); + await Assert.That(flushes.All(f => f.AgentId == null)).IsTrue(); + } finally { + 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); + + // 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); + } + } +}