diff --git a/CLAUDE.md b/CLAUDE.md
index 100008c..32bbce9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -143,7 +143,7 @@ The accent comes from the **live VM**, not the `Border.Tag` stashed at build tim
Both (2) and (3) **must come from the page**, and this is the part that is easy to get wrong twice:
-- **WebView2 is an `HwndHost`.** Mouse input landing on hosted native content raises **no** WPF routed events, tunnelling `Preview*` ones included. A `PreviewMouseLeftButtonDown` on the host Border only ever fires for the thin ring around the terminal (#108).
+- **WebView2 is an `HwndHost`.** Mouse input landing on hosted native content raises **no** WPF routed events, tunnelling `Preview*` ones included. A `PreviewMouseLeftButtonDown` on the host Border only ever fires for the thin ring around the terminal (#108). The same fact bites on the way out too — WPF content cannot be *drawn* over a pane either, whatever `Panel.ZIndex` says. See "Session Spinners".
- **xterm's `onData` is not "the user typed".** It also carries replies the terminal generates itself — device attributes (`ESC[?1;2c`), cursor-position reports, OSC colour replies, focus in/out (`ESC[I`/`ESC[O`) — plus mouse reports when the app enables tracking. Filtering those by inspecting the bytes cannot work; a device-attribute reply is not distinguishable from typing by shape. xterm knows internally (`triggerDataEvent`'s `wasUserInput`) but does not expose it on `onData`. `onKey` is the only honest source (#106).
The page-side `mousedown` handler also calls `fitAddon.fit()`, and the initial fit is re-run on `document.fonts.ready`. xterm derives its column count from the *measured advance width* of the font, so a fit that runs before the font loads computes the wrong `cols` and tells the PTY a width that doesn't match what is drawn — text then overlaps mid-line. The `ResizeObserver` cannot catch that, because the element size never changed, only the glyph metrics (#113).
@@ -342,9 +342,46 @@ Two overlays cover launch and shutdown so the user sees progress instead of a bl
**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`.
+**You cannot draw WPF content over a terminal pane.** WebView2 is an `HwndHost`, and a
+native child window is composited by the OS *on top of* everything WPF renders —
+`Panel.ZIndex` does not enter into it. This is the same `HwndHost` fact recorded under
+"What makes a session active", but for **output** rather than input, and it is the more
+expensive half to rediscover: the code looks correct, the overlay is genuinely in the tree
+with `Panel.ZIndex="100"`, and it simply does not appear.
+
+The original centred shutdown spinner was invisible for exactly this reason. All the user
+ever saw was scrim leaking through the few-pixel gaps *between* panes — reported, fairly, as
+"more like 1 line, hard to see, no spinner". Anything full-window must therefore either
+collapse `TerminalGrid` first (what `OnClosing` does) or live in the toolbar/sidebar chrome,
+which no `HwndHost` covers.
+
+**Restore rail (startup, app-level)** — `RestoreRail` (a 2px `ProgressBar`, `FlatBar` style)
+docked under the toolbar plus a `RestorePill` counter in the toolbar's right stack, both
+driven by `SetRestoreProgress(done, total)` from the `OnLoaded` restore loop and hidden
+outside it. Determinate on purpose: a 25-session restore runs ~131s with per-session cost
+swinging 12×, so there is no rate to extrapolate and a spinner reads identically at session
+2 and session 22. Placed in the toolbar because that is above the airspace problem.
+
+The counter advances *after* the `try`/`catch` around `LaunchSessionAsync`, so a session that
+fails to restore still moves the rail — otherwise one bad session strands it short of full,
+which reads as a hang.
+
+**Shutdown board (app-level)** — `ShutdownOverlay` is now a card listing every session with a
+per-row glyph (`·` pending → `◐` closing → `✓` clean / `⨯` force-disposed), elapsed time, an
+overall `k / N` bar, and a budget bar running against `ClaudeShutdownBudgetMs`. Built by
+`BuildShutdownBoard`, updated in place by `MarkShutdownRow` / `SetShutdownProgress` /
+`SetShutdownBudget`.
+
+Force-disposed sessions are **marked, not hidden** — that is the case a user most wants to
+see, and it used to happen silently. `ShutdownHint` escalates with elapsed time to explain
+*why* the wait is long; keep it explanatory rather than jokey, since it has to still read
+well on the four-hundredth shutdown. The board is skipped entirely when there are no
+sessions, so `--clean` runs don't get a full-window flash of "0 / 0".
+
+Full design: `docs/superpowers/specs/2026-05-16-session-spinners-design.md`. Option
+comparison behind the current design: the "Waiting States" artifact (Quiet Rail for startup,
+Restore Board for shutdown — the two paths deliberately differ, because restore does not
+block the user and shutdown does).
## Search
diff --git a/src/CodeShellManager/MainWindow.xaml b/src/CodeShellManager/MainWindow.xaml
index e602ed5..23d711d 100644
--- a/src/CodeShellManager/MainWindow.xaml
+++ b/src/CodeShellManager/MainWindow.xaml
@@ -64,6 +64,28 @@
+
+
+
@@ -91,6 +113,16 @@
+
+
+
+
+
@@ -152,6 +184,19 @@
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs
index 19264e2..de82ffc 100644
--- a/src/CodeShellManager/MainWindow.xaml.cs
+++ b/src/CodeShellManager/MainWindow.xaml.cs
@@ -313,6 +313,15 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
// Wall clock for the whole restore, so per-session timings are comparable and
// the total is visible in crash.log (issue #82).
var restoreClock = System.Diagnostics.Stopwatch.StartNew();
+
+ // Determinate restore progress. A 25-session restore runs ~131s with
+ // per-session cost swinging 12x, so there is no rate to extrapolate from and
+ // an indeterminate spinner reads the same at session 2 as at session 22.
+ // The rail and the pill are the only aggregate signal; per-session state stays
+ // on the placeholder sidebar rows.
+ int restoreTotal = saved.Count(x => !x.IsDormant), restoreDone = 0;
+ SetRestoreProgress(restoreDone, restoreTotal);
+
foreach (var s in saved)
{
if (s.IsDormant) continue;
@@ -330,9 +339,10 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
// with more machinery. Predictable beats occasionally-clever on a path
// the user waits through at every launch.
//
- // The gate is still used at SHUTDOWN, where the machine is quiet and it
- // measures a consistent ~304ms against this same flat 1000ms — see
- // OnClosing. Different contention, different answer.
+ // #117 removed it from the shutdown path too. The reasoning for keeping it
+ // there — "the machine is quiet at shutdown, so polling is reliable" — was
+ // falsified by measurement: a real run logged cfgSettle=8731ms against a
+ // 1000ms cap. Same disease, same fix. Both paths now use a flat delay.
long gateStart = restoreClock.ElapsedMilliseconds;
if (isClaude && lastWasClaude && staggerMs > 0)
await Task.Delay(staggerMs);
@@ -359,8 +369,14 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
$"launch={restoreClock.ElapsedMilliseconds - launchStart}ms " +
$"total={restoreClock.ElapsedMilliseconds}ms");
+ // Counted here, not in the try, so a session that failed to restore still
+ // advances the rail — otherwise one bad session strands it short of full
+ // and it reads as a hang.
+ SetRestoreProgress(++restoreDone, restoreTotal);
+
lastWasClaude = isClaude;
}
+ SetRestoreProgress(restoreTotal, restoreTotal, finished: true);
if (webView2AccessDenied.Count > 0)
{
MessageBox.Show(
@@ -381,6 +397,166 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
}
}
+ ///
+ /// Drives the restore rail + toolbar pill. Both are hidden outside a restore, and
+ /// hidden entirely when there is nothing to restore — a rail that flashes full for
+ /// one frame on a single-session start is noise, not feedback.
+ ///
+ private void SetRestoreProgress(int done, int total, bool finished = false)
+ {
+ if (total <= 0 || finished)
+ {
+ RestoreRail.Visibility = Visibility.Collapsed;
+ RestorePill.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ RestoreRail.Maximum = total;
+ RestoreRail.Value = done;
+ RestoreRail.Visibility = Visibility.Visible;
+ RestorePillText.Text = $"{done} / {total} restoring";
+ RestorePill.Visibility = Visibility.Visible;
+ }
+
+ // ── Shutdown board ──────────────────────────────────────────────────────
+ // Row handles, so each session's line can be updated in place as it closes.
+ private readonly Dictionary _shutdownRows = new();
+
+ /// Builds one row per session, all pending, in disposal order.
+ private void BuildShutdownBoard(IReadOnlyList sessions)
+ {
+ ShutdownList.Children.Clear();
+ _shutdownRows.Clear();
+
+ foreach (var vm in sessions)
+ {
+ var grid = new Grid { Margin = new Thickness(0, 1, 0, 1) };
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(18) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+
+ var accent = (Color)ColorConverter.ConvertFromString(vm.AccentColor ?? "#6c7086");
+ var stripe = new Border
+ {
+ Background = new SolidColorBrush(Color.FromArgb(0x99, accent.R, accent.G, accent.B)),
+ CornerRadius = new CornerRadius(2),
+ Height = 14
+ };
+ Grid.SetColumn(stripe, 0);
+
+ var glyph = new TextBlock
+ {
+ Text = "·",
+ Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86)),
+ FontFamily = new FontFamily("Consolas"),
+ FontSize = 11,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ Grid.SetColumn(glyph, 1);
+
+ var name = new TextBlock
+ {
+ Text = string.IsNullOrWhiteSpace(vm.Name) ? vm.Command : vm.Name,
+ Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86)),
+ FontFamily = new FontFamily("Segoe UI"),
+ FontSize = 11.5,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(2, 0, 8, 0)
+ };
+ Grid.SetColumn(name, 2);
+
+ var time = new TextBlock
+ {
+ Text = "",
+ Foreground = new SolidColorBrush(Color.FromRgb(0x58, 0x5b, 0x70)),
+ FontFamily = new FontFamily("Consolas"),
+ FontSize = 10,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ Grid.SetColumn(time, 3);
+
+ grid.Children.Add(stripe);
+ grid.Children.Add(glyph);
+ grid.Children.Add(name);
+ grid.Children.Add(time);
+
+ var row = new Border
+ {
+ Padding = new Thickness(6, 3, 8, 3),
+ CornerRadius = new CornerRadius(4),
+ Background = Brushes.Transparent,
+ Child = grid
+ };
+
+ ShutdownList.Children.Add(row);
+ _shutdownRows[vm.Id] = (row, glyph, time);
+ // Name brush is shared with the glyph lookup below via the row's Tag so
+ // MarkShutdownRow can brighten it without re-walking the visual tree.
+ row.Tag = name;
+ }
+
+ SetShutdownProgress(0, sessions.Count);
+ SetShutdownBudget(0);
+ }
+
+ /// Updates one session's row in place. Unknown ids are ignored.
+ private void MarkShutdownRow(string sessionId, string glyph, string hex, string? time, bool active = false)
+ {
+ if (!_shutdownRows.TryGetValue(sessionId, out var r)) return;
+
+ var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
+ r.Glyph.Text = glyph;
+ r.Glyph.Foreground = brush;
+ if (time != null) r.Time.Text = time;
+
+ r.Row.Background = active
+ ? new SolidColorBrush(Color.FromRgb(0x25, 0x25, 0x39))
+ : Brushes.Transparent;
+
+ if (r.Row.Tag is TextBlock name)
+ name.Foreground = new SolidColorBrush(active
+ ? Color.FromRgb(0xcd, 0xd6, 0xf4)
+ : Color.FromRgb(0xa6, 0xad, 0xc8));
+
+ // Keep the session being waited on visible in a 25-row list.
+ if (active) r.Row.BringIntoView();
+ }
+
+ private void SetShutdownProgress(int done, int total)
+ {
+ ShutdownProgress.Maximum = Math.Max(1, total);
+ ShutdownProgress.Value = done;
+ ShutdownCount.Text = $"{done} / {total}";
+ }
+
+ ///
+ /// Draws the elapsed share of , and escalates the
+ /// hint line with time. The escalation explains *why* the wait is long rather than
+ /// being chatty — it has to still read well on the four-hundredth shutdown.
+ ///
+ private void SetShutdownBudget(long elapsedMs)
+ {
+ double used = Math.Min(1.0, elapsedMs / (double)ClaudeShutdownBudgetMs);
+ ShutdownBudgetBar.Value = used;
+ ShutdownBudgetBar.Foreground = new SolidColorBrush(
+ used >= 1.0 ? Color.FromRgb(0xf3, 0x8b, 0xa8)
+ : used > 0.6 ? Color.FromRgb(0xfa, 0xb3, 0x87)
+ : Color.FromRgb(0xa6, 0xe3, 0xa1));
+
+ double left = (ClaudeShutdownBudgetMs - elapsedMs) / 1000.0;
+ ShutdownBudgetLeft.Text = left <= 0 ? "budget spent" : $"{left:0}s left";
+
+ ShutdownHint.Text = elapsedMs switch
+ {
+ < 8000 => "Letting each session exit cleanly…",
+ < 20000 => "Claude writes its session state on exit — worth the wait.",
+ _ => "Some sessions are slow to finish. Closing them shortly."
+ };
+ }
+
// Detects WebView2 user-data folder access-denied, which surfaces as
// UnauthorizedAccessException from CoreWebView2Environment.CreateAsync /
// CreateCoreWebView2ControllerAsync when another process is holding the
@@ -5273,26 +5449,52 @@ 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);
+ var all = _vm.Sessions.ToList();
+
+ // Collapse the terminal area before showing the overlay. This is load-bearing, not
+ // tidying: WebView2 is an HwndHost, and a native child window composites OVER all
+ // WPF-rendered content no matter what Panel.ZIndex claims. The overlay used to be
+ // drawn *behind* every terminal pane, so the centred spinner was invisible and the
+ // only thing that reached the user was scrim leaking through the few-pixel gaps
+ // between panes — which reads as a stray line, not a shutdown indicator.
+ //
+ // Safe to collapse: every pane here is about to be disposed, and a frozen terminal
+ // is worth nothing during shutdown anyway.
+ //
+ // Skipped when there is nothing to close: with no sessions the teardown is
+ // instant, and the board would be a full-window flash showing "0 / 0".
+ if (all.Count > 0)
+ {
+ TerminalGrid.Visibility = Visibility.Collapsed;
+ BuildShutdownBoard(all);
+ ShutdownOverlay.Visibility = Visibility.Visible;
+ // The yield lets WPF render the overlay before the synchronous disposal below
+ // blocks the UI thread; without it the board would only paint at Close().
+ await Dispatcher.InvokeAsync(() => { },
+ System.Windows.Threading.DispatcherPriority.Background);
+ }
_windowStateTimer.Stop();
if (_windowStateReady)
_vm.UpdateWindowState(WindowState, Left, Top, Width, Height);
await _vm.SaveStateAsync();
- var all = _vm.Sessions.ToList();
-
// Non-Claude sessions don't fight over ~/.claude.json — dispose them in parallel.
+ int boardDone = 0, boardTotal = all.Count;
foreach (var vm in all)
{
if (!ClaudeSessionService.IsClaudeCommand(vm.Command))
+ {
vm.Dispose();
+ MarkShutdownRow(vm.Id, "✓", "#a6e3a1", "closed");
+ SetShutdownProgress(++boardDone, boardTotal);
+ }
}
+ // These are synchronous, so nothing has repainted yet — yield once so the board
+ // shows them as closed rather than jumping when the first Claude session lands.
+ if (boardDone > 0)
+ await Dispatcher.InvokeAsync(() => { },
+ System.Windows.Threading.DispatcherPriority.Background);
// Claude rewrites ~/.claude.json on exit without locking, so two claude.exe
// processes flushing simultaneously can corrupt it. Dispose claude sessions one
@@ -5323,14 +5525,25 @@ await Dispatcher.InvokeAsync(() => { },
$"disposing '{vm.Name}' without waiting");
try { vm.Dispose(); } catch { }
skippedWait++;
+ // Marked, not hidden. Force-disposal is the case where a user most wants
+ // to know which session didn't get to exit cleanly.
+ MarkShutdownRow(vm.Id, "⨯", "#f38ba8", "forced");
+ SetShutdownProgress(++boardDone, boardTotal);
+ SetShutdownBudget(shutdownClock.ElapsedMilliseconds);
continue;
}
+ MarkShutdownRow(vm.Id, "◐", "#fab387", "closing…", active: true);
+ SetShutdownBudget(shutdownClock.ElapsedMilliseconds);
+
long t0 = shutdownClock.ElapsedMilliseconds;
await DisposeAndWaitForExitAsync(vm, timeoutMs: Math.Min(10000, remainingBudget));
long exitMs = shutdownClock.ElapsedMilliseconds - t0;
disposed++;
+ MarkShutdownRow(vm.Id, "✓", "#a6e3a1", $"{exitMs / 1000.0:0.0}s");
+ SetShutdownProgress(++boardDone, boardTotal);
+
// The exit wait above is on the process handle, but Claude's config write can
// still be in flight when the handle closes — hence a flat post-exit pause.
//
@@ -5359,6 +5572,15 @@ await Dispatcher.InvokeAsync(() => { },
Log($"SHUTDOWN complete: {disposed} waited, {skippedWait} force-disposed, " +
$"{shutdownClock.ElapsedMilliseconds}ms total");
+ // Last paint before the DB close and the reclose below. Without it the board's
+ // final row stays mid-flight on screen for the remainder of teardown.
+ SetShutdownBudget(shutdownClock.ElapsedMilliseconds);
+ ShutdownHint.Text = skippedWait > 0
+ ? $"Closed {disposed}, force-closed {skippedWait}. Saving index…"
+ : "All sessions closed. Saving index…";
+ await Dispatcher.InvokeAsync(() => { },
+ System.Windows.Threading.DispatcherPriority.Background);
+
// OutputIndexer.Dispose now drains its worker first, but SqliteConnection.Close
// has been observed to throw NRE internally on shutdown — swallow + log so it
// doesn't escape as an unhandled exception during application exit.