← the Atlas

Distinguish the Active Terminal in a Split

Features·seedling·accepted·

When a tile is split into two stacked terminals, nothing marks which pane your keystrokes go to. Recede the pane that does NOT have focus so the live one stands out — driven by the focus signal the split already tracks, no new state.

Split a tile and you get two stacked terminals — a main pane on top, a sub-panel below. You click into one, you start typing… and nothing on screen tells you which one is listening. The split already knows — it routes your keystrokes to the right pane — but it keeps the secret to itself. This note graduates the Sundry item “Distinguish the active terminal in a split” into a plan: surface that hidden signal as a quiet visual cue.

User-facing description

The pane that does not have focus recedes — it dims so the live pane it’s sitting next to reads as the foreground. A glance answers “where do my keystrokes land?” without reading anything, and the receded terminal’s output stays legible enough to keep half an eye on.

Today vs. proposed — the same split, focus on the sub-panel
argh · claude▢ ✕
$ npm test
PASS · 12 passed
$
serverlogs
$ vite dev
ready in 240 ms
Today — both panes equally bright. Which one is typing into? You can’t tell.
argh · claude▢ ✕
$ npm test
PASS · 12 passed
$
serverlogs
$ vite dev
ready in 240 ms
Proposed — the inactive main pane recedes; the focused sub-panel stays forward.

The recede follows focus. Click the main pane and it brightens while the sub-panel steps back; click into the split and they swap — the same motion you already make, now mirrored on screen.

Whichever pane has focus stays forward; the other recedes
argh · claude▢ ✕
$ npm test
PASS · 12 passed
$
serverlogs
$ vite dev
ready in 240 ms
Main pane focused — sub-panel recedes.
argh · claude▢ ✕
$ npm test
PASS · 12 passed
$
serverlogs
$ vite dev
ready in 240 ms
Sub-panel focused — main pane recedes.

It shows only inside the tile you’re working in and only while the split is open — a collapsed split has just one terminal, so there’s nothing to distinguish. The active sub-tab already has its own highlight in the tab bar; this is the missing, coarser signal: main vs. sub.

Languages considered

Three quiet, static languages were weighed; recede was chosen. All reuse the same idea (mark focus without motion), so prefers-reduced-motion needs no special case beyond making the change instant.

The three languages weighed — main focused in each
argh · claude▢ ✕
$ npm test
PASS · 12 passed
$
serverlogs
$ vite dev
ready in 240 ms
C · Recede the other chosen
Dim the inactive pane. The strongest “which one is live?” read; touches no chrome.
argh · claude▢ ✕
$ npm test
PASS · 12 passed
$
serverlogs
$ vite dev
ready in 240 ms
A · Edge rail
A teal bar on the active pane’s edge. Smallest footprint, but a thin line is easy to miss.
argh · claude▢ ✕
$ npm test
PASS · 12 passed
$
serverlogs
$ vite dev
ready in 240 ms
B · Inset ring
An accent ring around the active pane. Clear, but boxes in the xterm grid.

Architecture-level changes

There is almost nothing structural to change — and that’s the point. The split already tracks which pane has focus: focusTarget: "main" | "sub" lives in the per-tile useSubPanel store and is read on every keystroke to route input. It’s client-only state (only the split layoutcollapsed, panelSize — is server-persisted; on restore focusTarget is reseeded locally, see packages/client/src/terminal/useSessionRestore.ts:282). The work is to render that existing in-session signal, not to invent a new one.

How the cue is driven — one existing signal, no new state focusTarget "main" | "sub" useSubPanel store TerminalContent gate: focused && expanded → pick the active pane data-pane-focus "active" | "inactive" on each pane recede dim the inactive opacity 0.4 Reuses the per-tile focus signal that already routes keystrokes; opacity on the inactive pane — its box never changes size, so xterm never refits.
One existing signal drives the cue. focusTarget already exists; TerminalContent already gates on whether the tile is focused and the split expanded. We add only the last hop: tag each pane with data-pane-focus and let a Tailwind data-variant recede the inactive one.

Implementation details

Small and contained — one component gains a derived flag and a pair of Tailwind data-variant classes, and the Sundry row graduates to this note.

  1. Tag each pane in packages/client/src/terminal/TerminalContent.tsx. It already computes shouldFocusMain() / shouldFocusSub() from props.focused, isExpanded(), and focusTarget() — the cue keys off the same conditions. A paneFocus(pane) helper returns "active" | "inactive" | undefined, written to a data-pane-focus attribute on each of the two Resizable.Panels (plus a stable data-pane="main" | "sub" so the pane is addressable from tests). undefined when collapsed or when the tile isn’t focused, so no background tile lights a pane.
  2. Recede the inactive pane — Tailwind data-variant classes on the same Resizable.Panel (no custom CSS, per the repo’s Tailwind-only styling rule): data-[pane-focus=inactive]:opacity-40 for the recede, plus motion-safe:transition-opacity motion-safe:duration-[120ms] for the cross-fade. motion-safe: is Tailwind’s prefers-reduced-motion: no-preference variant, so reduced-motion gets the change instantly.
  3. Graduate the Sundry item — remove the row from docs/atlas/src/content/atlas/sundry.mdx (per its own “Graduating an item” rule) and leave a one-line pointer here.
  4. Docs + changelog — this note is the design of record; add an Added line to the changelog. No README/marketing surface lists split-pane focus behaviour, so the doc-sync is this note plus the changelog.

Test (feature, so test-first): an e2e scenario in packages/tests/features/sub-terminal.feature that opens a split, checks the sub-panel is active and the main pane receded, then moves focus and checks they swap — asserting on data-pane + data-pane-focus and the rendered opacity it drives (so deleting the recede class would fail the test, not just the marker flip), reusing the existing split/sub-terminal steps (packages/tests/step_definitions/sub_terminal_steps.ts).

Risks, and why they’re small

Scope — focused tile only

The cue shows only in the tile you’re in (props.focused), keeping the canvas calm and removing any “is the dim = a parked tile?” ambiguity for tiles you’re not working in. Revisit if an always-on version (read every split’s pending pane at a glance) proves more useful in practice.


Status: accepted — design of record; the chosen treatment is C · recede the inactive pane. In review in #1509 (status flips to implemented on merge).