Shell Pilot 1.22.0

September 6, 2026 · Feature release

Two things in this release: the sidebar now reflects the colour of the terminal beside it, and ⌘Q asks before it kills a running command.

Ambient reflection

What it replaces

Through 1.21.0 the sidebar had no background of its own. With a non-opaque window and a terminal compositing at 0.85 alpha, terminal colour leaked through it — and people liked the result. But it was an accident, with all the properties of one: it only appeared at certain opacity settings, there was no way to turn it up or down, and it was drawn wherever the compositor happened to put it rather than where it would read as light.

That effect is now a real one, sampled from the terminal and drawn deliberately.

How to use it

Nothing to switch on — it is already there. Open a session, run something colourful, and the sidebar picks up its cast. To change how strongly it reads, go to Settings ▸ Appearance ▸ Reflection: a slider from 0 to 100% in 5% steps, shipping at 75%. Zero is reachable by dragging to the end of the slider — nothing is lost when the wash is off, so "off" did not need a separate toggle of its own.

It ships high on purpose. The failure mode of a conservative default is not "too subtle to notice" but "noticed and read as a rendering artefact" — a hint of colour with no apparent source. The remaining quarter is headroom for anyone who wants it stronger, not a value the default is hedging toward.

Under macOS Reduce Transparency the effect is not dialled back, it is not drawn at all. The slider is disabled and reads "Off — required by Reduce Transparency", the same hard override the rest of the app's chrome already obeys.

Where the colour comes from

Shell Pilot walks the visible viewport of each session's terminal, strided toward a target of roughly 40 rows by 30 columns — about 1200 cells — and resolves each cell's foreground and background through SwiftTerm's own mapColor. That matters: OSC 4 lets a running program redefine any palette entry at runtime, so a copy of the default xterm table baked into app code would confidently report a hue that is nowhere on screen the moment a program recolours itself. Reading through the terminal's live palette means the colour is the one you can actually see.

The terminal's own default foreground and background are skipped. A blank screen is made entirely of them, and counting them would report the theme's background as "the colour on screen" forever.

Averaging in Oklab, not sRGB

Averaging sRGB components is the obvious approach and the wrong one. A screen that is half red-heavy and half green-heavy averages channel-wise toward a desaturated olive; add a third colour and it collapses to mud. In Oklab, the mean of two hues stays a hue, so the dominant colour survives instead of being washed out by its opposite.

Two more rules keep the reading honest:

Hue and strength are separate

The resolved colour is pinned to a fixed lightness, so a make scrolling white-on-black cannot pulse the sidebar every time its output gets denser. Only how much of the viewport carries colour decides how strongly the wash reads — brightness of output does not.

The wash follows the selected session, because it stands in for light spilling across the divider and the terminal on the other side of that divider is the selected one. Every other session gets its own, much fainter glow on its sidebar row, at half the sidebar's ceiling. A background session running a long build glows quietly in the list while you are looking at something else, which is the point.

How it is drawn

A clear-to-tint gradient anchored on the trailing edge: brightest at the divider, still at nearly three quarters of that 9% of the way across, fallen to a quarter by 35%, nothing at the far edge. It is drawn behind the whole sidebar column and behind the selection and hover fills — never over them — so no label loses contrast to it, and a busy session can never dilute the one affordance telling you which row you are on.

That short plateau against the divider is a specular core, and it is what makes the eye place a light source there. A single ramp down from the peak — the obvious construction, and what this had first — reads as a flat tint that happens to be brightest on one side, because there is no point at which the brightness stops changing. A highlight needs something to fall away from.

Peak alpha is capped at 0.40. The wash has to survive being seen through the sidebar's own vibrancy, which eats a low alpha alive: much below 0.25 and the effect only really registers on a saturated full-screen colour, which is the rare case rather than the common one. The limit the other way is that this is still additive tint over vibrancy — past roughly half again, the sidebar stops reading as glass catching light and starts reading as a panel that has been recoloured. And it is additive over the split view's own vibrancy rather than painting over it, which is exactly what the opaque gradient that used to live in that slot got wrong.

What it costs

Close to nothing, by construction:

Why output alone cannot drive it

This is the one thing the first cut of the feature got wrong, and it is worth spelling out. Sampling armed only by output means the last pass of a burst runs 80 ms after the last byte — often before the screen has finished settling, as a pager repaints, a progress bar erases itself, or a program clears on exit. And after that pass, nothing ever samples again, because nothing ever writes again. Whatever it happened to catch was frozen on the sidebar: a hard red wash held over a terminal that had long since gone back to a bare prompt.

So a pass that still has somewhere to converge now arms another one 350 ms later, with no output required — roughly the smoothing constant's own time scale, fast enough that a blank screen fades out in about two seconds, slow enough that idling on a coloured screen costs that 1200-cell walk three times a second rather than twelve.

The part that matters is how it stops. The chain ends the moment both the screen and the published value are exactly neutral, so an idle shell leaves no timer running at all. That is the half a plain repeating timer would get wrong: polling forever to catch a rare change is precisely the cost this sampler is built to avoid.

The same re-arming covers hiding the app with ⌘H in the middle of a build. Nothing is walked while there is no window to light, but a pass stays armed, so the wash resumes converging the moment the app is back rather than staying stuck on the colour it had when you hid it.

⌘Q no longer quits out from under a running command

The problem

A single reflex keystroke could end a long build, an SSH session or a test run with no warning and no way back. Quitting is the one action in the app with no undo.

What happens now

Quit is confirmed only when a session actually has something running. The dialog lists what would be killed:

2 sessions are still running: build — npm, api — ssh

Cancel is the default button, so Return and Escape both cancel; only clicking Quit terminates. An app whose sessions are all sitting idle at a prompt still quits instantly — the prompt appears when there is something to lose, and not otherwise. ⌘Q, File ▸ Quit, the Dock's Quit and logout all go through the same path.

At most five rows are listed; beyond that the dialog says "…and N more".

What "still running" means

It means the PTY's foreground process group differs from the shell's own — the same question a terminal emulator asks to know whether you are sitting at a prompt. The job's name in the dialog is the running executable (npm, ssh, xcodebuild), read from that process group.

The two obvious signals are both wrong, and worth naming so it is clear why they were not used:

A job you backgrounded yourself with & does not hold the terminal, so it does not count and will not stop you quitting.

Why it is an AppKit alert

Deliberately not the SwiftUI alert the session-close path uses. applicationShouldTerminate has to answer synchronously, and the SwiftUI route would hang the app unquittably whenever RootView is not mounted — the window can be closed with ⌘⇧W while the app keeps running, and in that state no SwiftUI alert can appear to answer with.

Tests

371 tests pass across 29 suites, up from 355 in 28. The new QuitConfirmationTests suite covers the no-confirmation case, singular and plural wording, row order, the five-row cap and its "…and N more" tail. AmbientTerminalColorTests covers the Oklab round trip, the chroma floor that discards greys and near-greys, red-and-green not averaging to grey, lightness staying independent of how bright the samples were, the energy floor and saturation, the 2.5× background weighting, and the slider's clamped range. The sampler's stop condition and the gradient's falloff are tested directly — both are pure, static functions precisely so that they can be, since exercising either through the running sampler would need a live terminal, a window and a run loop.

Known limits