Distinguish the Active Terminal in a Split
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.
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.
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.
Dim the inactive pane. The strongest “which one is live?” read; touches no chrome.
A teal bar on the active pane’s edge. Smallest footprint, but a thin line is easy to miss.
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 layout — collapsed, 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.
- Reuse the source of truth, don’t fork it. The cue reads
focusTarget— the same field that routes keystrokes (packages/client/src/terminal/useSubPanel.ts). A parallel “which pane looks active” flag, or a CSS:focus-withinshortcut that re-derives the same fact a second way, would be two sources that can disagree. One signal, two consumers (input routing + the cue). - A presentational leaf, not electricity. This hides no hard volatility
(transport, reconnect, GPU-context loss) — it’s a bounded bit of styling inside
the terminal module. It stays in
TerminalContent(Tailwind classes on the panel, no separate stylesheet rule); it earns no@kolu/*package. - No knob, no fallback. The cue is always on whenever a split is open and
focused — no preference toggle (an override is a defect, not a feature) and no
degraded path. It either reflects
focusTargetor, if that signal were ever absent, the split itself would already be broken upstream. - Dim, never reflow. The cue is
opacityon the inactive pane — a compositor property that changes no box size. That matters: any size change to a pane forces xterm to refit its grid and resize the PTY. Opacity costs zero layout (the canvas tile-aura paints without reflow for the same reason).
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.
- Tag each pane in packages/client/src/terminal/TerminalContent.tsx.
It already computes
shouldFocusMain()/shouldFocusSub()fromprops.focused,isExpanded(), andfocusTarget()— the cue keys off the same conditions. ApaneFocus(pane)helper returns"active" | "inactive" | undefined, written to adata-pane-focusattribute on each of the twoResizable.Panels (plus a stabledata-pane="main" | "sub"so the pane is addressable from tests).undefinedwhen collapsed or when the tile isn’t focused, so no background tile lights a pane. - 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-40for the recede, plusmotion-safe:transition-opacity motion-safe:duration-[120ms]for the cross-fade.motion-safe:is Tailwind’sprefers-reduced-motion: no-preferencevariant, so reduced-motion gets the change instantly. - 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.
- Docs + changelog — this note is the design of record; add an
Addedline 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
- xterm refit on a layout change — avoided by construction: the cue is
opacity, not a border or size change, so no pane box changes dimensions. - Compounding with the canvas parked-tile dim — can’t happen: the recede only fires on the focused tile, which is never parked, so opacity is always ×0.4 of a full-strength tile.
- Mobile —
TerminalContentis shared by the canvas and the mobile tile view, so the cue appears in both with no extra work; evidence covers the mobile layout.
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).