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). diff --git a/installer/CodeShellManager.wxs b/installer/CodeShellManager.wxs index 84d87b0..b664156 100644 --- a/installer/CodeShellManager.wxs +++ b/installer/CodeShellManager.wxs @@ -53,6 +53,9 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 610eef2..12ab981 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -886,6 +886,7 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal var terminalWrapper = BuildTerminalWrapper(vm, webView); terminalWrapper.Visibility = Visibility.Collapsed; TerminalGrid.Children.Add(terminalWrapper); // in tree → WebView2 can init + terminalWrapper.Visibility = Visibility.Visible; // show spinner immediately // Create bridge and initialize var bridge = new TerminalBridge(webView); @@ -918,6 +919,10 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal string htmlFile = wantTransparent ? "terminal-transparent.html" : "terminal.html"; string htmlPath = new Uri(Path.Combine(assetsDir, htmlFile)).AbsoluteUri; + string bootLabel = session.IsRemote + ? $"Connecting to {session.SshHost}…" + : $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…"; + bridge.SetBootContext(bootLabel, GetAccentForSession(session)); await bridge.InitializeAsync(htmlPath); bridge.ApplyFontSettings(_vm.Settings); bridge.ApplyProfileOverrides(session); @@ -1022,7 +1027,6 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal } Log($"terminalWrapper visible, TerminalGrid children={TerminalGrid.Children.Count}"); - terminalWrapper.Visibility = Visibility.Visible; // Build sidebar entry var sidebarItem = BuildSidebarItem(vm); @@ -4727,6 +4731,13 @@ protected override async void OnClosing(System.ComponentModel.CancelEventArgs e) if (_isShuttingDown) return; _isShuttingDown = true; + // 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); + _windowStateTimer.Stop(); if (_windowStateReady) _vm.UpdateWindowState(WindowState, Left, Top, Width, Height); diff --git a/src/CodeShellManager/Terminal/TerminalBridge.cs b/src/CodeShellManager/Terminal/TerminalBridge.cs index 6f9e050..518e8b7 100644 --- a/src/CodeShellManager/Terminal/TerminalBridge.cs +++ b/src/CodeShellManager/Terminal/TerminalBridge.cs @@ -24,6 +24,15 @@ public sealed class TerminalBridge : IDisposable // so the PTY starts at the right dimensions even if resize fired before AttachPty. private (int cols, int rows) _lastSize = (80, 24); + // Boot overlay — set by MainWindow before InitializeAsync; posted as setBootState after + // navigation completes, and hidden via bootDone on the first PTY byte (see OnPtyData). + // Fallback hides the overlay after BootDoneFallbackMs so silent sessions (e.g. a child + // that prints nothing, an SSH connect that's mid-handshake) aren't locked out. + private const int BootDoneFallbackMs = 8000; + private string? _bootLabel; + private string? _bootAccentHex; + private int _bootDoneFlag; // 0 = overlay still visible, 1 = bootDone already posted + // Output that arrived before the page finished loading is buffered here private readonly System.Text.StringBuilder _outputBuffer = new(); @@ -73,11 +82,33 @@ private void Trace(string msg) catch { } } + // 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 { } + }); + } + public TerminalBridge(WebView2 webView) { _webView = webView; } + /// + /// 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; + } + /// /// Initializes WebView2, navigates to terminal.html and AWAITS full page load /// before returning. This ensures PTY output is never dropped. @@ -96,6 +127,11 @@ public async Task InitializeAsync(string htmlPath) await _webView.EnsureCoreWebView2Async(env); Log("EnsureCoreWebView2Async done"); + // 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 { } + var settings = _webView.CoreWebView2.Settings; settings.AreDevToolsEnabled = true; // enable for debugging settings.AreDefaultContextMenusEnabled = false; @@ -137,6 +173,26 @@ void NavCompleted(object? s, CoreWebView2NavigationCompletedEventArgs e) Log($"NavigationCompleted: success={e.IsSuccess} httpStatus={e.HttpStatusCode} webErrorStatus={e.WebErrorStatus}"); _ready = true; + // 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 { } + } + + // Silent-session fallback: if the child writes nothing (or exits without + // any output) the overlay would otherwise block the terminal indefinitely. + // PostBootDoneIfNeeded is idempotent via Interlocked, so this is a no-op + // when the first PTY byte beat the timer. + _ = Task.Delay(BootDoneFallbackMs).ContinueWith( + _ => PostBootDoneIfNeeded(), TaskScheduler.Default); + // Flush any PTY output that arrived during page load string buffered; lock (_outputBuffer) @@ -188,6 +244,7 @@ private void OnPtyData(string rawData) } RawOutputReceived?.Invoke(rawData); + PostBootDoneIfNeeded(); if (!_ready) { @@ -403,6 +460,7 @@ public void FocusTerminal() public void Dispose() { + PostBootDoneIfNeeded(); if (_pty != null) _pty.DataReceived -= OnPtyData; if (_webView.CoreWebView2 != null) {