Skip to content

Multiplex inside a session: split trees and tabs on desktop, tabs on mobile, and a double-Ctrl scratch terminal - #82

Merged
karngyan merged 7 commits into
mainfrom
worktree-issue-73-multiplex
Aug 17, 2026
Merged

Multiplex inside a session: split trees and tabs on desktop, tabs on mobile, and a double-Ctrl scratch terminal#82
karngyan merged 7 commits into
mainfrom
worktree-issue-73-multiplex

Conversation

@karngyan

Copy link
Copy Markdown
Contributor

Closes #73.

What this is

A session stays one terminal, full bleed — until you ask for more. Then:

  • Splits, tmux-shaped. ⌘D splits the focused pane to the right, ⇧⌘D stacks inside it — axes mix freely, so ⇧⌘D over the right column of an A|B split stacks that column and touches nothing else. Dividers drag (DOM-direct during the drag, one state commit per drag). Off a Mac: Ctrl+Shift+D / Ctrl+Alt+Shift+D — plain Ctrl+D is the shell's EOF and is never taken.
  • Tabs, composing with splits. ⌥⌘T (Ctrl+Alt+T) opens a tab beside your split arrangement; each tab holds its own split tree. The strip appears only past one tab; a group of one renders exactly as today.
  • Mobile flattens to tabs. A phone never renders splits — every member is a tab, one pane on screen, with the visual-viewport pinning taught about the strip above the pane so the prompt stays above the keyboard.
  • A scratch terminal on double-Ctrl. Two bare Ctrl taps (chords never count — the detector is a tested state machine) open a switcher-styled modal with a shell in the current session's directory. Esc dismisses but only detaches: the shell keeps running — a dev server started there keeps serving — and the next double-Ctrl reattaches. It dies when its parent session ends, and "Keep" promotes it to a real member of the group.
  • One + menu. The control strip stays four chips; New session / Split right / Split down / New tab / Scratch live in one menu with their chords beside them, worn by the surface's top-right pane only.

Server design (issue's sketch, followed)

Panes are sessions — one PTY, one ring, one revival each. The daemon learns two optional spawn fields (group, ephemeral) and one update field (ephemeral: false, the Keep edit), all additive:

  • A running ephemeral session is closed by the reap sweep once its group parent exits; an exited one is reaped in ~seconds instead of ten minutes; one with no group is left alone.
  • Grouped sessions snapshot and revive with their link intact; ephemeral ones are never snapshotted.
  • The welcome carries a multiplex cap for feature detection.

Backward compatibility

  • A plain session is the degenerate case: no group, one pane, today's rendering, URL, attach flow and revival, byte-identical wire shape.
  • Old daemon + new web: no cap on the welcome, so every affordance hides.
  • New daemon + old web: the extra fields are ignored; grouped/ephemeral sessions are ordinary rows.
  • CLI and GET /api/sessions unchanged; folding and hiding are client decisions.
  • The sessions list folds members under their anchor with an "N panes" badge (a live member never folds under an exited anchor — nothing may vanish from every surface at once); scratches hide from list, switcher and recents.

Testing

  • go test ./... and go vet green: lifecycle tests for group/ephemeral spawn, parent-death sweep, promotion, snapshot rules, wire round-trips, and daemon-level spawn/update/welcome tests.
  • cd web && pnpm vitest run: 1442 tests green, including the shared golden fixture (extended with the new messages on both sides), the chord state machines, the pane tree (split/prune/reconcile/tabs), the group component across breakpoints, and the Tailwind prose-scanner build guard.
  • Smoke-tested live against a dev daemon: chord splits and nesting, tab/split coexistence, layout persistence across reload, scratch open → Esc → reattach, single chip strip.
  • A second-pass adversarial React review ran over the diff; its confirmed findings (sibling viewport-pinning theft, group collapse on socket blips, Radix autofocus landing on "Keep", exited-anchor folding, focus-mode chord fan-out) are all fixed in this branch.

Known limits (deliberate, per the issue)

  • Same-session mirror panes stay out of scope (FlueClient.wanted/refOwner are keyed by session id).
  • Layout persists in localStorage per device; the same group is splits on a desk and tabs on a phone by design.
  • Esc always dismisses the scratch modal, so Esc-driven TUIs inside a scratch lose that key — double-Ctrl also toggles, and this matches the requested behavior.

🤖 Generated with Claude Code

karngyan and others added 3 commits August 17, 2026 17:01
…mobile, and a double-Ctrl scratch terminal

Panes are sessions, not a new server concept. The daemon gains two optional,
additive fields — spawn.group links a session under an anchor, and
spawn.ephemeral marks a scratch terminal — plus update.ephemeral to promote a
scratch into a real member ("Keep"). A running ephemeral session lives exactly
as long as its group parent: the registry's reap sweep closes it once the
parent has exited, and an exited scratch is reaped in seconds rather than ten
minutes. Grouped sessions snapshot and revive with their link intact;
ephemeral ones are never snapshotted. The welcome announces a "multiplex"
capability so the web client can feature-detect before drawing any of this;
a plain session keeps exactly today's wire shape, rendering, and revival.

On the web, the terminal route becomes a group host. Desktop renders tabs of
split trees — ⌘D splits the focused pane to the right, ⇧⌘D stacks inside it,
⌥⌘T opens a tab beside the splits (Ctrl+Shift+D / Ctrl+Alt+Shift+D /
Ctrl+Alt+T off a Mac), with draggable dividers whose drags write the DOM
directly and commit once. The arrangement persists per group and device in
localStorage. Mobile flattens every member to a finger-sized tab strip, one
pane on screen, with the visual-viewport pinning taught about in-flow chrome
above the pane. The control strip collapses to one "+" menu carrying all four
verbs and their chords, worn by the surface's top-right pane only.

Double-Ctrl — two bare taps, chords never count — opens the scratch terminal:
an ephemeral session in the current session's directory, in a switcher-styled
modal. Escape dismisses but only detaches; the shell (a dev server, a watch)
keeps running and the next double-Ctrl reattaches to it, until the parent
session ends. The sessions list folds members under their anchor with a pane
count and hides scratches everywhere; the switcher still offers members
individually.

Closes #73.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and the switcher knows the group

A theme choice now reaches every terminal in the document, not only the pane
whose menu took the click: the storage event only ever fires in other tabs,
which was sufficient exactly as long as one terminal per document held — the
preference module gains a same-document announcement and every terminal
subscribes.

The exit overlay is gone. A shell that ends hands the exit to the view above
and that view closes it: a split pane folds away, the scratch modal dismisses
(typing exit in a scratch now puts it away), a lone session leaves for the
dashboard. The one case that stays on screen is a session opened after it had
already exited — reading those is what the daemon's retention window is for.

⌥⌘←/→ (Ctrl+Alt+←/→) walk the tabs of the group on screen — trees on a
desktop, panes on a phone — in the same modifier family as ⌥⌘T, since every
conventional spelling belongs to the browser. The ⌘K switcher leads with a
"This session" section naming the group's other terminals, which is the one
place they are offered by name now that the sessions list folds them away;
pinned siblings keep their Pinned rows so the ⌃⇧1..9 badges never renumber.
The list's fold badge says "N terminals" — panes and tabs are per-device
renderings of the same group, and the badge should not pick a side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karngyan

Copy link
Copy Markdown
Contributor Author

Second round, from live feedback:

  • Theme fix: choosing a theme now repaints every pane and the scratch modal, not just the pane whose menu took the click — the storage event only reaches other tabs, so the preference module gained a same-document announcement.
  • Exit overlay removed: a shell that ends just closes — a split pane folds away, the scratch modal dismisses (typing exit in a scratch puts it away), a lone session returns to the dashboard. A session opened after it exited still stays readable, which is what the retention window is for.
  • Tab cycling: ⌥⌘←/→ (Ctrl+Alt+←/→) walk the group's tabs — trees on desktop, panes on mobile.
  • Switcher: a "This session" section leads the ⌘K palette with the group's other terminals; pinned siblings keep their Pinned rows so ⌃⇧1..9 badges never renumber.
  • List badge reads "N terminals" — panes vs tabs is a per-device rendering, the badge should not pick a side.

Suite: 1450 web tests + lint green, Go untouched this round.

Every chord in one place — switcher, splits, tabs, scratch, focus mode —
spelled for the keyboard at hand, because the pills that used to teach them
are gone in a hundred milliseconds on a local daemon and each tooltip
teaches one chord. ⌘/ is the palette-help spelling everywhere else; off a
Mac it is Ctrl+Shift+/, in the reserved Ctrl+Shift namespace, since plain
Ctrl+/ is readline's undo and may not be taken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karngyan

Copy link
Copy Markdown
Contributor Author

Added a keyboard-shortcuts card: a keyboard chip in the control strip, and ⌘/ (Ctrl+Shift+/ off a Mac) toggles it. Lists every chord — switcher, splits, tabs, scratch, focus mode, copy — spelled for the platform. 1456 web tests + lint green.

karngyan and others added 3 commits August 17, 2026 17:34
A card of keyboard chords is furniture on a phone, so a coarse pointer gets
no chip. The ⌘/ listener stays mounted either way: an iPad grows a hardware
keyboard without changing its pointer, and the chord should answer it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tabs were shrinkable flex children, so too many of them absorbed the
overflow by squishing each other into slivers — a strip that never
overflowed had nothing to pan. shrink-0 keeps every tab its natural width
and lets the overflow engage, and the selected tab now keeps itself in view,
so the cycle chord landing on a scrolled-past tab reads as a move rather
than as nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… join

Fixes from a second review pass over the multiplex branch, plus the CI
flake that predates it.

- viewport.ts kept a captured-previous chain per tracker, which breaks
  the moment trackers dispose out of install order: the last disposer
  restores a dead tracker's handler, leaving a stale closure pinning a
  detached pane while the live one goes deaf to the keyboard. The stack
  is now explicit and shared per viewport; disposal removes exactly its
  own entry and the slot always belongs to the newest survivor.

- The scratch resolution now checks the route at every landing: an
  answer that arrives after navigating away no longer pops the modal
  over a different session, and a chord whose parent is not a running
  session declines to spawn (the daemon's sweep would close the newborn
  within seconds, in the wrong directory no less).

- The split chord's focus ref is cleared when the pane it names closes,
  so a chord in the gap before the next focusin cannot aim at the dead
  pane.

- Registry.Reap re-reads the ephemeral flag before closing an orphan:
  a Keep landing between collection and the close no longer loses to
  the sweep.

- splitInTabs returns the same array when it declines, as its contract
  already promised, and withRatio clamps once at the target split.

- The relay transport joins its serve and pairing goroutines before Run
  returns. Those goroutines write into the device registry (last-seen
  stamps, pairing entries), and a Run that returned while one was still
  going left the write racing the caller's teardown - in CI, the test
  harness deleting the registry directory out from under it, which is
  the TempDir flake that failed the previous run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karngyan
karngyan merged commit c56ab73 into main Aug 17, 2026
1 check passed
@karngyan
karngyan deleted the worktree-issue-73-multiplex branch August 18, 2026 21:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multiplex inside a session: splits on desktop, tabs on mobile, and a double-Ctrl scratch terminal

1 participant