Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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

Expand Down
156 changes: 131 additions & 25 deletions src/CodeShellManager/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@
</Setter.Value>
</Setter>
</Style>

<!-- Flat determinate bar. The stock ProgressBar template draws a track border and
a gradient decorator that read as chrome at 2-3px; this is just the fill. -->
<Style x:Key="FlatBar" TargetType="ProgressBar">
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Background" Value="#313244"/>
<Setter Property="Foreground" Value="#89b4fa"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid>
<Rectangle Fill="{TemplateBinding Background}"
RadiusX="1" RadiusY="1"/>
<Rectangle x:Name="PART_Track" Fill="Transparent"/>
<Rectangle x:Name="PART_Indicator" HorizontalAlignment="Left"
Fill="{TemplateBinding Foreground}"
RadiusX="1" RadiusY="1"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>

<Grid>
Expand Down Expand Up @@ -91,6 +113,16 @@
</StackPanel>

<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Restore counter — paired with RestoreRail; both live only for the
duration of the restore loop in OnLoaded. -->
<Border x:Name="RestorePill" Background="#1c2438" CornerRadius="10"
BorderBrush="#2f4368" BorderThickness="1"
Padding="8,2" Margin="0,0,8,0" VerticalAlignment="Center"
Visibility="Collapsed">
<TextBlock x:Name="RestorePillText" Text="restoring…" Foreground="#89b4fa"
FontSize="11" FontWeight="SemiBold"/>
</Border>

<!-- Update-available badge (shown when a new GitHub release exists) -->
<Border x:Name="UpdateBadge" Background="#a6e3a1" CornerRadius="10"
Margin="0,0,8,0" VerticalAlignment="Center" Visibility="Collapsed">
Expand Down Expand Up @@ -152,6 +184,19 @@
</DockPanel>
</Border>

<!-- ── Restore rail ─────────────────────────────────────────────────
Determinate k-of-N progress for session restore, which takes ~131s for a
25-session setup with per-session cost swinging 12x (issue #82). A spinner
can't express that; a filling bar can.

Deliberately docked here rather than drawn as an overlay: everything below
TerminalGrid is WebView2, an HwndHost, whose native window composites OVER
all WPF content regardless of Panel.ZIndex. The toolbar strip is one of the
few places an indicator is guaranteed to be visible. -->
<ProgressBar x:Name="RestoreRail" DockPanel.Dock="Top" Height="2"
Style="{StaticResource FlatBar}" Background="Transparent"
Minimum="0" Maximum="1" Value="0" Visibility="Collapsed"/>

<!-- ── Command helper panel ─────────────────────────────────────── -->
<Border x:Name="CommandHelperPanel" DockPanel.Dock="Top" Background="#11111b"
BorderThickness="0,0,0,1" BorderBrush="#313244"
Expand Down Expand Up @@ -292,31 +337,92 @@
</Grid>
</DockPanel>

<Grid x:Name="ShutdownOverlay" Visibility="Collapsed" Background="#cc1e1e2e" Panel.ZIndex="100">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical">
<Grid Width="48" Height="48">
<Path Stroke="#89b4fa" StrokeThickness="4" StrokeStartLineCap="Round" StrokeEndLineCap="Round"
Data="M 24 4 A 20 20 0 1 1 4 24"
RenderTransformOrigin="0.5,0.5">
<Path.RenderTransform>
<RotateTransform x:Name="ShutdownSpinnerRotate" Angle="0"/>
</Path.RenderTransform>
<Path.Triggers>
<EventTrigger RoutedEvent="Path.Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<DoubleAnimation Storyboard.TargetName="ShutdownSpinnerRotate"
Storyboard.TargetProperty="Angle"
From="0" To="360" Duration="0:0:1.2"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Path.Triggers>
</Path>
</Grid>
<TextBlock Text="Shutting down…" Foreground="#cdd6f4" FontFamily="Segoe UI" FontSize="13"
Margin="0,14,0,0" HorizontalAlignment="Center"/>
</StackPanel>
<!-- ── Shutdown board ───────────────────────────────────────────────
Names every session and ticks it off, because shutdown genuinely blocks,
has a hard 30s budget (ClaudeShutdownBudgetMs), and force-disposes whatever
the budget doesn't cover — which used to happen with no indication at all.

This is only visible because OnClosing collapses TerminalGrid before showing
it. WebView2 is an HwndHost; its native window composites over WPF content
whatever Panel.ZIndex says, so the previous centred spinner was drawn behind
the terminal panes and only leaked through the gaps between them. -->
<Grid x:Name="ShutdownOverlay" Visibility="Collapsed" Background="#e61e1e2e" Panel.ZIndex="100">
<Border Width="420" MaxHeight="440" Background="#1e1e2e" CornerRadius="8"
BorderBrush="#313244" BorderThickness="1"
HorizontalAlignment="Center" VerticalAlignment="Center">
<DockPanel>
<!-- header: title + k/N + overall progress -->
<StackPanel DockPanel.Dock="Top" Margin="16,13,16,11">
<DockPanel Margin="0,0,0,8">
<TextBlock x:Name="ShutdownCount" DockPanel.Dock="Right"
Text="" Foreground="#6c7086" FontFamily="Consolas" FontSize="11"
VerticalAlignment="Center"/>
<TextBlock Text="Closing sessions" Foreground="#cdd6f4"
FontFamily="Segoe UI" FontSize="13" FontWeight="SemiBold"/>
</DockPanel>
<ProgressBar x:Name="ShutdownProgress" Height="3"
Style="{StaticResource FlatBar}" Minimum="0" Maximum="1" Value="0"/>
</StackPanel>

<!-- footer: budget bar + why-it's-taking-a-while line -->
<StackPanel DockPanel.Dock="Bottom" Margin="16,10,16,13">
<ProgressBar x:Name="ShutdownBudgetBar" Height="3" Margin="0,0,0,6"
Style="{StaticResource FlatBar}" Foreground="#a6e3a1"
Minimum="0" Maximum="1" Value="0"/>
<DockPanel>
<TextBlock x:Name="ShutdownBudgetLeft" DockPanel.Dock="Right" Text=""
Foreground="#6c7086" FontFamily="Consolas" FontSize="10"
VerticalAlignment="Center"/>
<TextBlock x:Name="ShutdownHint" Text="Letting each session exit cleanly…"
Foreground="#6c7086" FontFamily="Segoe UI" FontSize="11"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
</StackPanel>

<ScrollViewer x:Name="ShutdownListScroll" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" Margin="8,0"
Background="Transparent">
<!-- Implicit style, scoped to this ScrollViewer: the stock WPF
scrollbar is system-coloured and reads as a light bar dropped
into the panel. Any setup past ~14 sessions scrolls, so this
is on screen for most shutdowns, not an edge case. -->
<ScrollViewer.Resources>
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="6"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border Background="#45475a" CornerRadius="3"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageDownCommand"
Opacity="0" Focusable="False"/>
</Track.IncreaseRepeatButton>
<Track.DecreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageUpCommand"
Opacity="0" Focusable="False"/>
</Track.DecreaseRepeatButton>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ScrollViewer.Resources>
<StackPanel x:Name="ShutdownList"/>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</Grid>
</Window>
Loading