From 4e24ea18d321dd9e01daf17f022a92e526a63e03 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 17 May 2026 09:36:24 +0200 Subject: [PATCH 1/2] docs(spinner): add design spec and implementation plan --- .../plans/2026-05-16-session-spinners.md | 494 ++++++++++++++++++ .../2026-05-16-session-spinners-design.md | 185 +++++++ 2 files changed, 679 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-session-spinners.md create mode 100644 docs/superpowers/specs/2026-05-16-session-spinners-design.md diff --git a/docs/superpowers/plans/2026-05-16-session-spinners.md b/docs/superpowers/plans/2026-05-16-session-spinners.md new file mode 100644 index 0000000..9d68244 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-session-spinners.md @@ -0,0 +1,494 @@ +# Session Spinners 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:** Add a per-session "starting…" spinner that lives in the xterm host until the first PTY byte arrives, and a full-window "Shutting down…" overlay during app exit. + +**Architecture:** The launch spinner is a CSS overlay inside `terminal.html` / `terminal-transparent.html`, visible by default and hidden via a WebView2 message (`bootDone`) on first PTY output. The shutdown overlay is a WPF `Grid` on `MainWindow`, made visible at the top of `OnClosing` followed by a single `Dispatcher.InvokeAsync(... Background)` yield so the overlay paints before disposal blocks the UI thread. + +**Tech Stack:** WPF (.NET 10) + WebView2 + xterm.js + plain CSS/JS. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-05-16-session-spinners-design.md` + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `src/CodeShellManager/Assets/terminal.html` | Boot overlay markup + CSS (opaque variant) | +| `src/CodeShellManager/Assets/terminal-transparent.html` | Boot overlay markup + CSS (transparent variant — only difference: backdrop is `#1e1e2e` here too because the spinner is opaque even when xterm is transparent) | +| `src/CodeShellManager/Assets/terminal-init.js` | Add `setBootState` and `bootDone` handlers to the existing `message` event listener | +| `src/CodeShellManager/Terminal/TerminalBridge.cs` | New `SetBootContext(label, accentHex)` API; post `setBootState` after `NavigationCompleted`; post `bootDone` on first byte from `OnPtyData`; set `CoreWebView2Controller.DefaultBackgroundColor` | +| `src/CodeShellManager/MainWindow.xaml.cs` | In `LaunchSessionAsync`: call `SetBootContext` before `InitializeAsync`, move `terminalWrapper.Visibility = Visible` earlier. In `OnClosing`: show overlay + yield | +| `src/CodeShellManager/MainWindow.xaml` | Add `ShutdownOverlay` grid as last child of the root grid | + +No new test files — the work is XAML / HTML / JS / WebView2 integration and is verified manually. + +--- + +### Task 1: Add boot overlay markup + CSS to both terminal HTML files + +**Files:** +- Modify: `src/CodeShellManager/Assets/terminal.html` +- Modify: `src/CodeShellManager/Assets/terminal-transparent.html` + +- [ ] **Step 1: Add boot overlay CSS + markup to `terminal.html`** + +In `src/CodeShellManager/Assets/terminal.html`, **inside the `` tag (after the `body.retro::before` rule): + +```css + /* Boot overlay — visible until terminal-init.js receives bootDone */ + #bootOverlay { + position: fixed; inset: 0; + background: #1e1e2e; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 14px; + font-family: 'Segoe UI', sans-serif; + color: #cdd6f4; + transition: opacity 200ms ease-out; + } + #bootOverlay.hidden { opacity: 0; pointer-events: none; } + #bootSpinner { + width: 44px; height: 44px; + --boot-accent: #89b4fa; + } + #bootSpinner circle { + fill: none; + stroke: var(--boot-accent); + stroke-width: 4; + stroke-linecap: round; + stroke-dasharray: 90 150; + transform-origin: center; + animation: bootSpin 1.2s linear infinite; + } + @keyframes bootSpin { to { transform: rotate(360deg); } } + #bootLabel { font-size: 13px; opacity: 0.85; } +``` + +Then **in the ``**, immediately after `
` and before `
…`, insert: + +```html +
+ +
Initializing terminal…
+
+``` + +- [ ] **Step 2: Add the same overlay to `terminal-transparent.html`** + +Make the identical CSS + markup edit in `src/CodeShellManager/Assets/terminal-transparent.html`. Same rules, same position. The overlay uses an opaque `#1e1e2e` background even on the transparent variant — that's intentional so the spinner is readable. + +- [ ] **Step 3: Visually verify by opening one of the HTML files in a browser** + +Open `src/CodeShellManager/Assets/terminal.html` directly in Chrome / Edge (drag onto the address bar). The xterm content area won't render (no JS bundling needed for this check) but the boot overlay should be visible: rotating arc + "Initializing terminal…" label, centered. + +Expected: spinner spins, label visible, full-page dark background. + +- [ ] **Step 4: Commit** + +```bash +git add src/CodeShellManager/Assets/terminal.html src/CodeShellManager/Assets/terminal-transparent.html +git commit -m "feat(spinner): add boot overlay markup + CSS to xterm host pages" +``` + +--- + +### Task 2: Add `setBootState` / `bootDone` message handlers to `terminal-init.js` + +**Files:** +- Modify: `src/CodeShellManager/Assets/terminal-init.js` (the existing `message` event listener block around lines 48–73) + +- [ ] **Step 1: Add handlers inside the existing WebView2 message listener** + +Find the `window.chrome.webview.addEventListener('message', e => { … })` block. Inside the `try { const msg = JSON.parse(e.data); … }` `if`/`else if` chain — after the existing `dropOverlayClear` handler and before the closing `} catch {}` — add two new `else if` branches: + +```javascript + else if (msg.type === 'setBootState') { + const label = document.getElementById('bootLabel'); + const spinner = document.getElementById('bootSpinner'); + if (label && typeof msg.label === 'string') label.textContent = msg.label; + if (spinner && typeof msg.accentHex === 'string') { + spinner.style.setProperty('--boot-accent', msg.accentHex); + } + } + else if (msg.type === 'bootDone') { + const overlay = document.getElementById('bootOverlay'); + if (overlay && !overlay.classList.contains('hidden')) { + overlay.classList.add('hidden'); + overlay.addEventListener('transitionend', () => { + try { overlay.parentNode && overlay.parentNode.removeChild(overlay); } catch {} + }, { once: true }); + } + } +``` + +The `transitionend` listener uses `{ once: true }` so it auto-detaches after firing. The guard `!overlay.classList.contains('hidden')` makes `bootDone` idempotent — second invocation is a no-op. + +- [ ] **Step 2: Verify the JS file is syntactically valid** + +```bash +node --check src/CodeShellManager/Assets/terminal-init.js +``` + +Expected: no output (silent success). If Node isn't installed, skip this check — the next task's build step will catch syntax errors via the WebView2 console at runtime. + +- [ ] **Step 3: Commit** + +```bash +git add src/CodeShellManager/Assets/terminal-init.js +git commit -m "feat(spinner): add setBootState/bootDone handlers in terminal-init.js" +``` + +--- + +### Task 3: Bridge — add `SetBootContext`, post `setBootState`, set WebView2 default background + +**Files:** +- Modify: `src/CodeShellManager/Terminal/TerminalBridge.cs` + +This task adds the API and one of the two posting paths. The second (post `bootDone` on first byte) lands in Task 4 together with the MainWindow wiring so the feature ships end-to-end in one commit. + +- [ ] **Step 1: Add boot context fields and `SetBootContext` method** + +In `src/CodeShellManager/Terminal/TerminalBridge.cs`, find the field declarations near the top of the class (after `_lastSize` at ~line 25 and before `_outputBuffer` at ~line 28). Add: + +```csharp + // Boot overlay — set by MainWindow before InitializeAsync; posted as setBootState after + // navigation completes, and hidden via bootDone on the first PTY byte (see OnPtyData). + private string? _bootLabel; + private string? _bootAccentHex; + private int _bootDoneFlag; // 0 = overlay still visible, 1 = bootDone already posted +``` + +Then, **after** the `public TerminalBridge(WebView2 webView)` constructor (~line 80), add the public API: + +```csharp + /// + /// Sets the boot-overlay label and accent color. Must be called before + /// — the bridge posts a setBootState message to the + /// page as soon as navigation completes. + /// + public void SetBootContext(string label, string accentHex) + { + _bootLabel = label; + _bootAccentHex = accentHex; + } +``` + +- [ ] **Step 2: Set WebView2 default background color before navigation** + +Inside `InitializeAsync`, **after** `await _webView.EnsureCoreWebView2Async(env);` (~line 96) and **before** `var settings = _webView.CoreWebView2.Settings;` (~line 99), add: + +```csharp + // Match the boot overlay background so the WebView2 init flicker (the gap between + // the control becoming visible and terminal.html rendering) is invisible. + try { _webView.DefaultBackgroundColor = System.Drawing.Color.FromArgb(0x1e, 0x1e, 0x2e); } + catch { } +``` + +Note: `WebView2.DefaultBackgroundColor` is on the WPF `WebView2` control itself (not `CoreWebView2Controller`), which is what's referenced in the spec — the WPF wrapper exposes it directly. Wrapped in try/catch because the property's availability historically varied across WebView2 SDK versions. + +- [ ] **Step 3: Post `setBootState` inside `NavCompleted`** + +Inside the existing `NavCompleted` local function in `InitializeAsync`, **after** `_ready = true;` and **before** the `// Flush any PTY output…` block, add: + +```csharp + // Apply boot-overlay state if MainWindow called SetBootContext before init. + if (_bootLabel != null && _bootAccentHex != null) + { + var bootJson = JsonSerializer.Serialize(new + { + type = "setBootState", + label = _bootLabel, + accentHex = _bootAccentHex + }); + try { _webView.CoreWebView2?.PostWebMessageAsString(bootJson); } + catch { } + } +``` + +- [ ] **Step 4: Build the project** + +```bash +dotnet build src/CodeShellManager/CodeShellManager.csproj +``` + +Expected: build succeeds with the pre-existing CS8123 warnings only. No new errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/CodeShellManager/Terminal/TerminalBridge.cs +git commit -m "feat(spinner): add SetBootContext + post setBootState in TerminalBridge" +``` + +--- + +### Task 4: Wire `bootDone` on first PTY byte + MainWindow visibility + SetBootContext call + +**Files:** +- Modify: `src/CodeShellManager/Terminal/TerminalBridge.cs` +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` + +This is the task that makes the launching spinner work end-to-end. + +- [ ] **Step 1: Add a `PostBootDoneIfNeeded` helper in TerminalBridge** + +In `src/CodeShellManager/Terminal/TerminalBridge.cs`, near the `Trace` / `Log` helpers (around line 60), add: + +```csharp + // Posts a one-shot bootDone message to the WebView2. Safe to call from any thread. + private void PostBootDoneIfNeeded() + { + if (System.Threading.Interlocked.CompareExchange(ref _bootDoneFlag, 1, 0) != 0) return; + WpfApplication.Current?.Dispatcher.BeginInvoke(() => + { + try { _webView.CoreWebView2?.PostWebMessageAsString("{\"type\":\"bootDone\"}"); } + catch { } + }); + } +``` + +- [ ] **Step 2: Call `PostBootDoneIfNeeded` from `OnPtyData`** + +In the existing `OnPtyData` method (~line 180), find the line `RawOutputReceived?.Invoke(rawData);`. Immediately after it, add: + +```csharp + PostBootDoneIfNeeded(); +``` + +Place it AFTER `RawOutputReceived` (so listeners see the raw bytes) and BEFORE the `!_ready` check (so even buffered output triggers the dismiss — once the page navigates the overlay will fade). The helper is idempotent so calling it repeatedly is safe. + +- [ ] **Step 3: Call `PostBootDoneIfNeeded` defensively from `Dispose`** + +In `TerminalBridge.Dispose` (~line 404), at the very start of the method body (before the `if (_pty != null)` line), add: + +```csharp + PostBootDoneIfNeeded(); +``` + +This covers the case where a bridge is being torn down mid-launch — the page might still be alive momentarily before the WebView2 is reclaimed, and we don't want it to ship with a half-faded overlay if it somehow revives. + +- [ ] **Step 4: Build to verify the bridge changes compile** + +```bash +dotnet build src/CodeShellManager/CodeShellManager.csproj +``` + +Expected: build succeeds, no new errors. + +- [ ] **Step 5: Call `SetBootContext` from `LaunchSessionAsync`** + +Open `src/CodeShellManager/MainWindow.xaml.cs`. Find `LaunchSessionAsync` — the method that creates `SessionViewModel`, `WebView2`, `TerminalBridge` and calls `bridge.InitializeAsync(htmlPath)`. + +Locate the line that calls `bridge.InitializeAsync(...)`. **Immediately before** that call, add: + +```csharp + string bootLabel = session.IsRemote + ? $"Connecting to {session.SshHost}…" + : $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…"; + bridge.SetBootContext(bootLabel, GetAccentForSession(session)); +``` + +`GetAccentForSession` already exists in `MainWindow.xaml.cs` (used by `BuildLaunchingSidebarItem` at line 4101 — same accessor pattern). + +If you cannot locate the call to `bridge.InitializeAsync` quickly: search for `InitializeAsync(htmlPath` or `InitializeAsync(html` across `MainWindow.xaml.cs`. There should be one call site in `LaunchSessionAsync`. + +- [ ] **Step 6: Move `terminalWrapper.Visibility = Visibility.Visible` earlier** + +Still in `LaunchSessionAsync`. Currently the wrapper is made visible at MainWindow.xaml.cs:1083 — after the PTY is attached. Cut that line (`terminalWrapper.Visibility = Visibility.Visible;` and its trailing `Log(...)` line if there is one) from its current location, and paste it immediately after the line that adds the wrapper to `TerminalGrid.Children`. + +The exact target location: search for `TerminalGrid.Children.Add(terminalWrapper)` — set visibility on the next line. This makes the spinner visible from the moment the WebView2 host is in the layout, instead of after PTY spawn. + +If there's both a `Log("terminalWrapper visible, …")` line and the `Visibility = Visible` assignment, keep the Log near the original position (it'll fire after a successful PTY attach, which is still a meaningful event) but ensure the `Visibility = Visible` itself is moved to right after the `Children.Add`. + +- [ ] **Step 7: Build + run the app** + +```bash +dotnet build src/CodeShellManager/CodeShellManager.csproj +dotnet run --project src/CodeShellManager/CodeShellManager.csproj +``` + +In the app: +1. Click + New Session. Pick any local command (e.g. `pwsh`). +2. Observe: the new terminal pane should briefly show a rotating arc + "Starting pwsh…" label, then fade out as the prompt appears. +3. Try creating an SSH session if you have access to one — label should read "Connecting to {host}…". +4. Close the app for now (don't worry about the shutdown overlay yet — that lands in Task 6). + +If the spinner never appears: the wrapper visibility move didn't take. Re-check Step 6. +If the spinner never disappears: `bootDone` isn't being posted. Check `OnPtyData` for the new line + verify `terminal-init.js` was saved with the handler. +If the label is always "Initializing terminal…": `SetBootContext` isn't being called or the page navigates faster than the message ships — verify the call site in `LaunchSessionAsync` is before `InitializeAsync`. + +- [ ] **Step 8: Commit** + +```bash +git add src/CodeShellManager/Terminal/TerminalBridge.cs src/CodeShellManager/MainWindow.xaml.cs +git commit -m "feat(spinner): show launching overlay until first PTY byte" +``` + +--- + +### Task 5: Add `ShutdownOverlay` grid to `MainWindow.xaml` + +**Files:** +- Modify: `src/CodeShellManager/MainWindow.xaml` + +- [ ] **Step 1: Find the root Grid's closing tag** + +Open `src/CodeShellManager/MainWindow.xaml`. The root content of the `` is a Grid (or DockPanel containing a Grid). Find the **last** `` before `` — the outermost layout container's closing tag. + +If the layout is nested (e.g. DockPanel wrapping a Grid wrapping more grids), the shutdown overlay should be a sibling at the topmost level so it can cover the entire window. The simplest target: if the root is a `` containing all UI, add this as the last child of that Grid (siblings render on top in Z-order). + +If you can't find a single root Grid, the safest fallback is to wrap the existing root in a new Grid with two children: the existing content and the `ShutdownOverlay`. Do this only if no clearer target exists — the file is unfamiliar. + +- [ ] **Step 2: Insert the overlay XAML** + +As the last child of the root Grid (or new outer Grid per fallback in Step 1), add: + +```xml + + + + + + + + + + + + + + + + + + + + + +``` + +Notes on the XAML: +- `Background="#cc1e1e2e"` is `#1e1e2e` at 80% alpha — the existing UI shows through at low contrast. +- `Panel.ZIndex="100"` is belt-and-braces; the overlay being the last child of the root Grid already puts it on top. +- The `Storyboard` starts once when the Path is first loaded into the visual tree (at window startup) and runs for the lifetime of the window. A single `DoubleAnimation` on a rotation transform is cheap (<0.1% CPU) so we don't bother gating it on visibility — the simpler XAML is worth more than the marginal saving. + +- [ ] **Step 3: Build to confirm the XAML is valid** + +```bash +dotnet build src/CodeShellManager/CodeShellManager.csproj +``` + +Expected: build succeeds. If XAML parsing fails, the compiler will pinpoint the line. + +- [ ] **Step 4: Commit** + +```bash +git add src/CodeShellManager/MainWindow.xaml +git commit -m "feat(spinner): add ShutdownOverlay grid to MainWindow.xaml" +``` + +--- + +### Task 6: Wire shutdown overlay into `OnClosing` + +**Files:** +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (the existing `OnClosing` override at ~line 4789) + +- [ ] **Step 1: Show overlay and yield once** + +Open `src/CodeShellManager/MainWindow.xaml.cs`. Find `protected override async void OnClosing(...)` (~line 4789). + +After the existing `_isShuttingDown = true;` line (~line 4804) and **before** `_windowStateTimer.Stop();` (~line 4806), insert: + +```csharp + // Show the shutdown overlay so the user sees progress while sessions tear down. + // The yield lets WPF render the overlay before the synchronous disposal below blocks + // the UI thread; without it, the overlay would only paint after Close() is reached. + ShutdownOverlay.Visibility = Visibility.Visible; + await Dispatcher.InvokeAsync(() => { }, + System.Windows.Threading.DispatcherPriority.Background); +``` + +The yield uses `DispatcherPriority.Background` (lower than `Render`), which guarantees a render pass completes before the continuation runs. + +- [ ] **Step 2: Build** + +```bash +dotnet build src/CodeShellManager/CodeShellManager.csproj +``` + +Expected: build succeeds. + +- [ ] **Step 3: Manual verification** + +```bash +dotnet run --project src/CodeShellManager/CodeShellManager.csproj +``` + +1. Open the app, create at least one session (more sessions = longer shutdown, more visible overlay). +2. If you have Claude installed, open a Claude session too — it has a 10-second-per-session dispose path which makes the overlay shine. +3. Close the window. +4. Expected: the existing UI dims behind the `Shutting down…` overlay, the arc spins, and after sessions finish disposing the window actually closes. + +If the overlay never appears: the yield didn't fire before disposal started. Try increasing the priority to `ContextIdle` (lowest) or verify the XAML naming. +If the overlay flashes too briefly to see: that's actually fine — it means shutdown was fast. Re-test with more sessions or a Claude session. + +- [ ] **Step 4: Commit** + +```bash +git add src/CodeShellManager/MainWindow.xaml.cs +git commit -m "feat(spinner): show ShutdownOverlay during OnClosing teardown" +``` + +--- + +### Task 7: Final smoke test + run full test suite + +**Files:** None modified. + +- [ ] **Step 1: Run the full unit test suite** + +```bash +dotnet test tests/CodeShellManager.Tests/CodeShellManager.Tests.csproj +``` + +Expected: all 206 tests pass (same as before the feature — no test changes were made). + +- [ ] **Step 2: End-to-end visual verification** + +```bash +dotnet run --project src/CodeShellManager/CodeShellManager.csproj +``` + +Checklist: +- [ ] New local session shows "Starting {command}…" spinner that fades out as the prompt arrives +- [ ] New SSH session (if available) shows "Connecting to {host}…" spinner +- [ ] Restored sessions on app launch each show their own spinner briefly +- [ ] Failed launch (e.g. fake a typo'd command) removes the wrapper cleanly — no orphan spinner +- [ ] Closing the app with sessions live shows "Shutting down…" overlay +- [ ] Spinner arc color matches the session's accent stripe + +- [ ] **Step 3: If everything checks out, no commit needed** + +This task is verification only. If any item fails, return to the relevant earlier task and adjust. + +--- + +## Out of scope (per spec) + +- Per-session sleep / wake / close spinners (only app-exit gets a spinner) +- UI tests for the spinner (XAML/WebView2 makes this flaky; spec accepts manual verification) +- Configurable spinner appearance (one accent-colored arc style everywhere) +- Telemetry on how long launches actually take diff --git a/docs/superpowers/specs/2026-05-16-session-spinners-design.md b/docs/superpowers/specs/2026-05-16-session-spinners-design.md new file mode 100644 index 0000000..1bc69ca --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-session-spinners-design.md @@ -0,0 +1,185 @@ +# Session Spinners — Design Spec + +**Date:** 2026-05-16 +**Status:** Approved +**Scope:** Per-session "starting" spinner inside the xterm host, and an app-exit "shutting down" overlay on the main window. No per-session close/sleep spinner. + +--- + +## Context + +When a session launches today, the terminal pane is invisible until the PTY has been spawned and `terminalWrapper.Visibility = Visible` is set at `MainWindow.xaml.cs:1083`. The sidebar shows a "launching…" placeholder (`_launchingSidebarItems`, `BuildLaunchingSidebarItem` at `MainWindow.xaml.cs:4099`), but the main terminal area gives no feedback. WebView2 init, navigation to `Assets/terminal.html`, xterm.js mount, and PTY spawn collectively take long enough — especially for SSH sessions waiting on host handshake — that users wonder whether anything is happening. + +App shutdown has a similar gap. With many live sessions, the WebView2 and PTY disposals serialize and can take a couple of seconds. The window currently freezes during that window with no indication of progress. + +This spec adds two small visual layers to cover both cases. + +--- + +## Goals + +- Show a centered spinner + phase-appropriate text in the terminal pane from the moment a session begins launching until the first byte of PTY output arrives. +- Show a full-window "Shutting down…" overlay during app exit while sessions are being torn down. +- Style: accent-colored rotating arc, matches the per-session accent color from `ColorService`. +- Hide automatically — no manual dismiss. + +## Non-goals + +- Sleep / wake / per-session close spinners. The dispose paths for those are fast enough that adding feedback would be more code than benefit. +- A spinner on the sidebar entry. The existing `_launchingSidebarItems` placeholder already covers that surface and is not changing. +- Progress *quantification* (e.g. "step 2 of 4"). The spinner is qualitative; only the label string changes between phases. +- Surfacing the spinner inside run-command chips. Those have their own status UI. + +--- + +## User flow + +**Launching a session:** + +1. User picks + New Session, fills in the dialog, hits OK. (Or session is restored on startup.) +2. The terminal wrapper becomes visible immediately (no longer waiting until after PTY spawn). +3. The wrapper shows a centered rotating arc in the session's accent color, with a phase label below: + - Local sessions: `Starting {command}…` + - SSH sessions: `Connecting to {host}…` + - During WebView2 init before the bridge has wired up: `Initializing terminal…` (default in HTML) +4. As soon as the first byte of PTY output arrives, the overlay fades out (200ms) and the terminal content takes over. +5. On launch failure, the existing catch block in `LaunchSessionAsync` already removes the terminal wrapper and shows a modal `MessageBox`. The spinner simply disappears with the wrapper — no separate error UI in the overlay. (Trade-off: a hung WebView2 init or SSH connect just spins forever; user must use the toolbar ✕ to bail out. Acceptable for v1; can be revisited if it happens in practice.) + +**App exit:** + +1. User clicks the window close button (or Alt+F4, etc.). +2. A full-window overlay fades in (semi-transparent dark backdrop over the existing UI, centered spinner + `Shutting down…` label). +3. Session disposals run on a background task. +4. Once teardown completes, the window actually closes. + +--- + +## Architecture + +### Per-session launch spinner (HTML + JS) + +The spinner lives inside the xterm host HTML, not as a WPF overlay. This means it disappears the instant `terminal-init.js` sees the first PTY data, with no cross-tier coordination needed. + +**`Assets/terminal.html` and `Assets/terminal-transparent.html`:** + +Both files get a sibling div alongside the existing xterm container: + +```html +
+ + + +
Initializing terminal…
+
+``` + +CSS lives in the same files (small enough not to warrant a separate stylesheet): + +- `.boot-overlay` — absolutely positioned, fills the WebView2 viewport, `background: #1e1e2e`, centered flex column, transitions `opacity 200ms`. +- `.boot-spinner` — 48px square, rotates via `@keyframes spin` (1.2s linear infinite). +- `.boot-arc` — stroked SVG arc (`stroke-dasharray`, `stroke-linecap: round`), default `stroke: #89b4fa`; overridable from JS by setting a CSS variable. +- `.boot-label` — Catppuccin foreground (`#cdd6f4`), muted weight. +- A `.boot-overlay.hidden` modifier sets `opacity: 0; pointer-events: none`, and a `transitionend` handler removes the node from the DOM. +- `.boot-overlay.error` swaps the spinning arc for a static "!" glyph and stops the animation. + +**`Assets/terminal-init.js`:** + +Add three handlers on the existing WebView2 message channel: + +| Message | Payload | Effect | +|---|---|---| +| `setBootState` | `{ label: string, accentHex: string }` | Updates `#boot-label` text and the spinner CSS variable. | +| `bootDone` | (none) | Adds `.hidden`; on `transitionend` removes the node. Idempotent — calling twice is safe. | + +### Per-session launch spinner (C# bridge) + +**`Terminal/TerminalBridge.cs`:** + +- Right after the WebView2 navigates to `terminal.html` and the bridge's `WebMessageReceived` is wired, post a `setBootState` message with the session's accent + phase label. The label is computed from `ShellSession.IsRemote` and the command line. +- On the first invocation of `RawOutputReceived` for that session — track via a `bool _bootDone` field on the bridge — post `bootDone`. +- Also post `bootDone` from `Dispose` defensively so a wrapper that's torn down mid-launch doesn't ship a half-faded overlay if it's somehow revived. +- Ensure the WebView2's default background color is set to `#1e1e2e` before navigation (set on the `CoreWebView2Controller.DefaultBackgroundColor` in the existing WebView2 init code). This hides the gap between WebView2 becoming visible and `terminal.html` rendering. + +**`MainWindow.xaml.cs`:** + +- Move `terminalWrapper.Visibility = Visibility.Visible` from after the PTY-attach (~line 1083) to immediately after the wrapper is built. This makes the spinner visible during the full launch window. The existing catch block continues to remove the wrapper on PTY-start failure — no new error-UI logic needed. + +### App-exit overlay (WPF) + +**`MainWindow.xaml`:** + +A new `Grid x:Name="ShutdownOverlay"` at the end of the root grid (z-order on top), default `Visibility="Collapsed"`. Contents: + +- Full-bleed `Rectangle` with `Fill="#cc1e1e2e"` (80% alpha over existing UI). +- Centered `StackPanel` with: + - A `Path` drawing the same arc shape as the HTML spinner, with a `Storyboard` rotating it 360° linear infinite. The storyboard is started in `Loaded` and stopped on `Unloaded` to avoid CPU when not visible. + - A `TextBlock` with `Shutting down…` in Catppuccin foreground. + +**`MainWindow.xaml.cs`:** + +Override `OnClosing`. WebView2 and PTY disposal must run on the UI thread, so we don't `Task.Run` the teardown — we yield once at `Background` priority to let the overlay paint, then dispose synchronously. The UI is still frozen during disposal, but the user now sees a "Shutting down…" overlay instead of a hung window. + +``` +private bool _shutdownInProgress; + +protected override async void OnClosing(CancelEventArgs e) +{ + if (_shutdownInProgress) return; // second pass: let base.OnClosing fall through naturally + e.Cancel = true; + _shutdownInProgress = true; + + ShutdownOverlay.Visibility = Visibility.Visible; + + // Yield once so the overlay actually paints before disposal blocks the UI thread. + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); + + DisposeAllSessions(); // existing teardown extracted; runs on the UI thread. + Close(); // re-enters OnClosing with _shutdownInProgress=true. +} +``` + +`DisposeAllSessions` is the existing teardown logic extracted from current shutdown paths — no behavior change. The yield-then-dispose pattern is enough to guarantee the overlay paints; we don't need true async disposal. + +--- + +## Edge cases + +- **PTY launch fails:** Existing catch block removes the wrapper (and shows a `MessageBox`); the spinner disappears with the wrapper. No new error UI. +- **WebView2 init flicker:** ~50–200ms before `terminal.html` renders. We set `CoreWebView2Controller.DefaultBackgroundColor` to `#1e1e2e` before navigation so the gap blends with the spinner overlay. +- **Session restored on startup, multiple panes:** Each pane has its own independent overlay since each has its own bridge + WebView2. No coordination needed. +- **SSH connection hangs forever:** Spinner spins forever. Existing close ✕ on the terminal toolbar still works since it's outside the WebView2. +- **First PTY byte is ANSI clear-screen:** Still counts as "first output" — overlay hides. Acceptable; matches the user's mental model of "something happened." +- **Re-entrant `OnClosing`:** Guarded by `_shutdownInProgress` flag; second pass falls through to base. +- **App-exit while a session is mid-launch:** The launching wrapper's spinner becomes irrelevant once the shutdown overlay is on top. Disposal of the half-launched session works the same as today. + +--- + +## Testing + +Most of the new code is XAML / HTML / JS and is not unit-testable headless. We rely on existing UI tests in `tests/CodeShellManager.UITests/` for smoke coverage: + +- A UI test in `SessionTests.cs` can assert the boot overlay element is visible briefly after creating a new session, then disappears once the prompt is on screen. FlaUI cannot directly query the WebView2 DOM, but it can assert the terminal wrapper is visible from t=0 (current behavior would have it invisible). +- Shutdown overlay: add a UI test that closes the window with N sessions live and verifies the overlay element is present during teardown. May be flaky if teardown is faster than the polling interval — keep the assertion loose. + +No new unit tests planned. The C# changes in `TerminalBridge` are too tightly coupled to WebView2 messaging to test headlessly without a refactor we don't otherwise need. + +--- + +## Out of scope (revisit later if asked) + +- A WPF-spinner pre-stage that hands off to the HTML spinner. Would eliminate the WebView2 init flicker, but the matched background color makes the gap invisible and it's not worth the extra coordination code. +- Per-session sleep/close spinners. +- Progress text more granular than the four phases listed above. +- Spinner shape variations (we use one rotating arc everywhere). + +--- + +## File touch list + +- `src/CodeShellManager/Assets/terminal.html` — overlay markup + CSS. +- `src/CodeShellManager/Assets/terminal-transparent.html` — same. +- `src/CodeShellManager/Assets/terminal-init.js` — `setBootState` / `bootDone` / `bootError` handlers. +- `src/CodeShellManager/Terminal/TerminalBridge.cs` — post `setBootState` after navigation, `bootDone` on first PTY byte + defensively from `Dispose`; set WebView2 default background color before navigation. +- `src/CodeShellManager/MainWindow.xaml` — `ShutdownOverlay` grid. +- `src/CodeShellManager/MainWindow.xaml.cs` — `OnClosing` override + `DisposeAllSessions` extraction; move `terminalWrapper.Visibility = Visible` earlier in `LaunchSessionAsync`. +- `tests/CodeShellManager.UITests/SessionTests.cs` — smoke tests for both overlays (best-effort). From abfb23a8c61012b258612a79c036e1c46a23775e Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 17 May 2026 13:55:59 +0200 Subject: [PATCH 2/2] docs: refresh CLAUDE.md and README after the bundle merge CLAUDE.md: - New "Session Spinners" section covering the launch + shutdown overlays, the 8s silent-session fallback, and the OnClosing yield pattern. - New "Visual Studio: Hot Reload disabled" callout under Build & Run explaining the launchSettings.json + MetadataUpdaterSupport workaround for the .NET 10.0.8 + VS 18 ExecutionEngineException crash. - Architecture > Key layers: PseudoTerminal entry now mentions the IPseudoTerminal seam so contributors see how RunInstance/SessionRunner get tested without spawning a real ConPTY. - Testing section: documents IPseudoTerminal injection, SearchService's per-test temp-SQLite + ClearAllPools pattern, and the timestamp-seeding rule (don't rely on Task.Delay for ordering). README.md: - Adds bullets for the visible features that were missing: sidebar groups, recently-closed (Ctrl+Shift+T), per-session run commands (F5), Windows Terminal profile import, and the launching/shutdown spinners. - Keyboard shortcuts table now lists Ctrl+Shift+T, Ctrl+Alt+T, Ctrl+Shift+Tab, F5, and Shift+F5. --- CLAUDE.md | 20 +++++++++++++++++++- README.md | 11 ++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fab37e2..fd488f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,10 @@ dotnet run --project src/CodeShellManager/CodeShellManager.csproj **Requirements:** .NET 10 SDK, Windows 10/11 (uses ConPTY + WebView2) +### Visual Studio: Hot Reload disabled + +`Properties/launchSettings.json` ships with `"hotReloadEnabled": false`, and the csproj sets `false` in Debug. Both are workarounds for a `System.ExecutionEngineException` that crashes the app on F5 under .NET 10.0.8 + VS 18 — `Microsoft.Extensions.DotNetDeltaApplier.dll` faults during its own startup before any managed code runs. Ctrl+F5 (Start Without Debugging) is unaffected either way. **Remove both when the runtime bug is fixed upstream.** + ### Command-line flags | Flag | Effect | @@ -36,7 +40,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) AlertDetector → SessionViewModel.RaiseAlert() ``` -- **PseudoTerminal** (`Terminal/PseudoTerminal.cs`): Windows ConPTY wrapper, P/Invoke only +- **PseudoTerminal** (`Terminal/PseudoTerminal.cs`): Windows ConPTY wrapper, P/Invoke only. Implements `IPseudoTerminal` (`Terminal/IPseudoTerminal.cs`) — the minimum surface needed by `RunInstance` (`DataReceived`, `Exited`, `ExitCode`, `Start`). Tests inject a fake via the `internal RunInstance(item, Func)` constructor. - **TerminalBridge** (`Terminal/TerminalBridge.cs`): Routes bytes between PTY and xterm.js via WebView2 messages. Surfaces accelerator keys (Ctrl-combos, F-keys, Esc) via `_webView.PreviewKeyDown` — the newer WPF WebView2 wrapper forwards accelerators through standard key events rather than a separate `CoreWebView2Controller.AcceleratorKeyPressed`. Bridge re-raises them as `AcceleratorKeyPressed` so `MainWindow.OnBridgeAcceleratorKey` can run global shortcuts even when the terminal has focus. - **OutputIndexer** (`Terminal/OutputIndexer.cs`): Async channels → SQLite, strips ANSI - **AlertDetector** (`Services/AlertDetector.cs`): Regex on raw PTY output, fires after 1.5s idle @@ -249,6 +253,16 @@ Each session gets a collapsible 📝 notepad panel between the terminal toolbar `AlertDetector.NotifyUserInteracted()` clears alert state on user input. +## Session Spinners + +Two overlays cover launch and shutdown so the user sees progress instead of a blank pane. + +**Launch overlay (per session)** lives in `Assets/terminal.html` and `Assets/terminal-transparent.html` as a CSS-animated rotating SVG arc with a phase label. Visible by default; `TerminalBridge` posts `setBootState` after `NavigationCompleted` (label = `Starting {cmd}…` for local, `Connecting to {host}…` for SSH; accent = session color) and `bootDone` on the first PTY byte (via `OnPtyData → PostBootDoneIfNeeded`, race-safe via `Interlocked.CompareExchange`). An 8-second fallback timer scheduled in `NavCompleted` also calls `PostBootDoneIfNeeded` so silent sessions and slow SSH handshakes don't lock the user out of the pane. + +**Shutdown overlay (app-level)** is a `Grid x:Name="ShutdownOverlay"` on `MainWindow.xaml` with a `Storyboard`-rotated `Path`. `OnClosing` shows it then `await Dispatcher.InvokeAsync(() => {}, DispatcherPriority.Background)` to force a render pass before the existing synchronous session-disposal loop blocks the UI thread. + +Full design: `docs/superpowers/specs/2026-05-16-session-spinners-design.md`. + ## Search - All PTY output is stripped of ANSI and indexed to SQLite FTS5 by `OutputIndexer` @@ -295,6 +309,10 @@ Unit tests cover model logic (`ShellSession`, etc.) and run headless. UI tests r `ShellSession.BuildSshArgs()` is `internal` — accessible from tests via `[assembly: InternalsVisibleTo("CodeShellManager.Tests")]` in `AssemblyInfo.cs`. +**`IPseudoTerminal` testability seam.** `PseudoTerminal` implements `IPseudoTerminal` (in `Terminal/IPseudoTerminal.cs`), and `RunInstance` / `SessionRunner` both expose an `internal` constructor that accepts a `Func` factory. Production code uses the parameterless public ctors which default to `() => new PseudoTerminal()`; tests pass a hand-rolled `FakePseudoTerminal` to exercise the run-command lifecycle (Run, Stop, Dismiss, kill-and-restart, 1MB output-buffer cap) without spawning a real ConPTY child. Keep the interface surface minimal — only what `RunInstance` actually calls (`DataReceived`, `Exited`, `ExitCode`, `Start`). + +**SearchService tests** open a fresh file-backed SQLite at `Path.GetTempPath()` per test for isolation. The test class is `IDisposable` and clears the connection pool (`SqliteConnection.ClearAllPools()`) before deleting the file on Windows. Seed `session_history` rows with explicit timestamps rather than `Task.Delay` — Windows' 15.6ms timer granularity makes wall-clock-based ordering flaky on CI. + ## Releases CI/CD is in `.github/workflows/build.yml`. Releases are triggered by pushing a `v*.*.*` tag: diff --git a/README.md b/README.md index ef1eb53..1cef29b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,10 @@ Built with WPF + [xterm.js](https://xtermjs.org/) + Windows ConPTY for full pseu ## Features - **Multi-terminal grid** — run up to 18 sessions simultaneously in configurable layouts (1, 2, 3, 4, 6 columns; 2×2, 6×2, 6×3 grids); the active pane is highlighted with a 2px accent ring so it's easy to spot +- **Sidebar groups** — organise sessions into named groups with their own color and filter strip; bulk actions (sleep / close / re-group) operate on the active group - **Sleep & wake** — 💤 button parks a session: PTY torn down, but the session (and its notes) stays in the sidebar so you can wake it later from where you left off. Great when you have many long-running projects but only need a few live at once. +- **Recently closed** — Ctrl+Shift+T reopens the last-closed session (browser convention); the New Session dialog also lists the last 10 closed sessions for one-click revival +- **Per-session run commands** — define a list of labelled commands per session (Test, Build, Watch…); ▶ runs the default, F5 / Shift+F5 run/stop it, output streams into a side drawer without touching the parent terminal. Optional post-run URL opens in your browser on exit code 0. - **Full-text search** — all terminal output indexed to SQLite FTS5; instant search across every session, ever - **Per-project notepad** — collapsible 📝 notes panel on every terminal, auto-saved and searchable - **Alert detection** — detects when Claude is waiting for input or tool approval; green/orange dot indicators @@ -27,6 +30,8 @@ Built with WPF + [xterm.js](https://xtermjs.org/) + Windows ConPTY for full pseu - **Session rename** — double-click any session name or click ✏ to rename inline - **Auto-resume** — automatically resumes the last Claude Code session when restoring on startup (`--resume `); toggleable in Settings - **SSH remote sessions** — connect to remote hosts using your existing SSH config; sessions persist across restarts +- **Windows Terminal profile import** — opt-in import of profiles from Windows Terminal's `settings.json`; pick a profile in the New Session dialog to stamp its font, color scheme, cursor and padding onto the new terminal +- **Launch & shutdown spinners** — every starting session shows a brief overlay (`Starting …` or `Connecting to …`) until the first PTY byte arrives; closing the window shows a "Shutting down…" overlay during session disposal - **Session history** — clicking a search result from a closed session offers to relaunch it - **Configurable launch commands** — customise the commands available in the New Session dialog - **Claude badge** — sessions running `claude` commands get a visual indicator @@ -86,9 +91,13 @@ dotnet run --project src/CodeShellManager/CodeShellManager.csproj | Key | Action | |-----|--------| | `Ctrl+T` | New session | +| `Ctrl+Shift+T` | Reopen the most-recently-closed session | +| `Ctrl+Alt+T` | Duplicate the active session | | `Ctrl+W` | Close active session | | `Ctrl+F` | Toggle search | -| `Ctrl+Tab` | Cycle sessions | +| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle sessions | +| `F5` | Run the active session's default run-command | +| `Shift+F5` | Stop the active session's running run-command | | `Escape` (in search) | Close search panel | ## Layout Options