2026-09-15 21:10:25 -05:00
|
|
|
|
# Split-Pane Sessions — Design Spec
|
|
|
|
|
|
|
2026-09-19 20:18:36 -05:00
|
|
|
|
**Status**: Implemented (v1)
|
2026-09-15 21:10:25 -05:00
|
|
|
|
**Author**: Claude (session with Tim), 2026-09-15
|
|
|
|
|
|
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
|
|
|
|
|
|
2026-10-06 10:46:33 +02:00
|
|
|
|
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
|
|
|
|
|
|
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
|
|
|
|
|
|
> reconnects after a drop, delivers input exactly once, has clickable paths
|
|
|
|
|
|
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
|
2026-10-06 15:46:49 +02:00
|
|
|
|
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
|
|
|
|
|
|
> closes anything (Close Session has no default key).
|
2026-10-06 10:46:33 +02:00
|
|
|
|
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
|
|
|
|
|
|
|
2026-09-15 21:10:25 -05:00
|
|
|
|
## Problem
|
|
|
|
|
|
|
2026-09-15 21:14:56 -05:00
|
|
|
|
Codeman's terminal area shows exactly one active session (pane) at a time —
|
|
|
|
|
|
switching panes re-binds the single xterm instance and the single WebSocket
|
|
|
|
|
|
to a different session. Multi-monitor spanning (`scripts/span-codeman.sh` /
|
2026-09-15 21:10:25 -05:00
|
|
|
|
`span-codeman.ps1`) turned out to solve a different problem: it makes one
|
|
|
|
|
|
browser window bigger, but that window still shows one session; floating
|
|
|
|
|
|
subagent windows are draggable overlays on top of it, not tiled panes. There
|
|
|
|
|
|
is no way today to see two live sessions (e.g. `w1-codeman` and
|
|
|
|
|
|
`w1-mcp-memory`) side-by-side in one window, even on a monitor wide enough to
|
|
|
|
|
|
fit both.
|
|
|
|
|
|
|
|
|
|
|
|
## Goal (v1)
|
|
|
|
|
|
|
|
|
|
|
|
From the active session, open a **second, independent, fully live session**
|
|
|
|
|
|
in a pane beside it — draggable divider, side-by-side only. Closing the
|
|
|
|
|
|
second pane collapses back to today's normal single-pane view. No
|
|
|
|
|
|
persistence: a page reload always returns to single-pane. Floating
|
|
|
|
|
|
subagent/Ultracode windows keep their current behavior unchanged (global,
|
|
|
|
|
|
unconstrained across the whole viewport, split or not).
|
|
|
|
|
|
|
|
|
|
|
|
Explicitly out of scope for v1 (v2 candidates, not designed here):
|
|
|
|
|
|
- More than 2 panes / grid layouts
|
|
|
|
|
|
- Vertical (stacked) splits
|
|
|
|
|
|
- Drag-a-tab-to-split as a trigger (v1 trigger is an explicit button + picker)
|
|
|
|
|
|
- Persisting the split layout across reload or across devices
|
|
|
|
|
|
- Mobile/tablet layouts (viewport is too narrow for this to make sense; gated
|
|
|
|
|
|
to desktop widths the same way `home-sessions.js`'s rail is)
|
|
|
|
|
|
- Feature parity between the two panes (see "Pane B is deliberately plainer"
|
|
|
|
|
|
below)
|
|
|
|
|
|
|
|
|
|
|
|
## Current architecture (why this isn't a CSS change)
|
|
|
|
|
|
|
|
|
|
|
|
`terminal-ui.js` is built entirely around **singleton** state: `this.terminal`
|
|
|
|
|
|
(one xterm instance), `this._ws`/`this._wsSessionId` (one WebSocket, rebound
|
2026-09-15 21:14:56 -05:00
|
|
|
|
on every pane switch via `_disconnectWs()` + `_connectWs(newId)`), a
|
2026-09-15 21:10:25 -05:00
|
|
|
|
`this._xtermSnapshots` map used only to restore scrollback into that one
|
|
|
|
|
|
terminal when switching back to a session. Roughly 280 references to this
|
|
|
|
|
|
singleton state exist across the file (input handling, resize/fit, sizing-
|
|
|
|
|
|
token claims, mobile touch gestures, CJK IME, local-echo overlay wiring,
|
|
|
|
|
|
keyboard accessory bar, link providers, etc.).
|
|
|
|
|
|
|
|
|
|
|
|
Showing two sessions at once therefore requires a second, independently
|
|
|
|
|
|
alive xterm + WebSocket pair running concurrently — not a layout change to
|
|
|
|
|
|
one shared instance.
|
|
|
|
|
|
|
2026-09-15 21:24:42 -05:00
|
|
|
|
**Related prior art**: `detachSession(id)` (app.js) already opens one session
|
|
|
|
|
|
in a genuinely separate browser window (`isSoloWindow` mode) with its own
|
|
|
|
|
|
independent WebSocket, and two of those can already be snapped side-by-side
|
|
|
|
|
|
today with zero new code. That covers "two sessions visible at once" but not
|
|
|
|
|
|
what this spec is for: one Codeman window with two panes and a divider you
|
|
|
|
|
|
can drag without leaving your seat, each still a full participant in that
|
|
|
|
|
|
window's floating subagent windows, header, and settings. This spec builds
|
|
|
|
|
|
past detach, not a duplicate of it.
|
|
|
|
|
|
|
2026-09-15 21:10:25 -05:00
|
|
|
|
**Server-side check (done, not just assumed)**: `MAX_WS_PER_SESSION = 5`
|
|
|
|
|
|
(`src/web/routes/ws-routes.ts`), scoped by `clientId:tabNonce`
|
|
|
|
|
|
(`ws-connection-registry.ts`). Splitting always opens a *different* session
|
|
|
|
|
|
in the second pane (self-splitting is disallowed, see below), so this is two
|
|
|
|
|
|
sessions each getting their normal one connection — the existing cap is
|
|
|
|
|
|
irrelevant here and needs no server change.
|
|
|
|
|
|
|
|
|
|
|
|
## Key design decision: Pane B is deliberately plainer than Pane A
|
|
|
|
|
|
|
|
|
|
|
|
Porting all ~280 singleton behaviors to a second, symmetric pane is not
|
|
|
|
|
|
worth it for v1 — most of that code is input-quality-of-life for **mobile/
|
|
|
|
|
|
touch** (local-echo overlay, CJK IME textarea, touch gesture handling,
|
|
|
|
|
|
keyboard accessory bar), and this feature is desktop-only by nature (a split
|
|
|
|
|
|
view needs a wide viewport). So:
|
|
|
|
|
|
|
|
|
|
|
|
- **Pane A** (the session that was already active when you opened the split)
|
|
|
|
|
|
stays exactly what it is today — `this.terminal`, `this._ws`, unchanged
|
|
|
|
|
|
code path, zero regression risk.
|
|
|
|
|
|
- **Pane B** is a new, smaller `SplitTerminalPane` object: its own xterm
|
|
|
|
|
|
instance + fit addon, its own WebSocket to `/ws/sessions/:id/terminal`,
|
|
|
|
|
|
resize-on-divider-drag, and plain keyboard input. It does **not** get the
|
|
|
|
|
|
local-echo overlay, CJK IME composition, touch/mobile handlers, or the
|
|
|
|
|
|
keyboard accessory bar. On a desktop, typing directly into an xterm
|
|
|
|
|
|
instance with no overlay is exactly how Codeman behaved before the local-
|
|
|
|
|
|
echo overlay existed for touch devices — normal, not degraded, for a
|
2026-10-09 07:19:04 +02:00
|
|
|
|
keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js,
|
2026-10-09 08:20:02 +02:00
|
|
|
|
which has gained three pieces of the primary pane: hollow-buffer wheel
|
|
|
|
|
|
paging (#555) and the desktop click report for a CLI with
|
|
|
|
|
|
`cliMouseTracking` on, both through the primary pane's gates aimed at the
|
|
|
|
|
|
tile, and its own keyCode-229 soft-keyboard controller
|
|
|
|
|
|
(terminal-keycode229-recovery.js: the #441 next-keydown drain and #541's
|
|
|
|
|
|
edit-based diff, so an Android autocorrect is not sent twice). The 1180px
|
|
|
|
|
|
width gate is all that keeps a phone out, and a wide Android tablet clears
|
|
|
|
|
|
it. See that file's fileoverview.)
|
2026-09-15 21:10:25 -05:00
|
|
|
|
|
|
|
|
|
|
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
|
|
|
|
|
parity is a scoped v2 (extract the shared logic already once you have two
|
|
|
|
|
|
call sites to compare, rather than guessing the right abstraction now).
|
|
|
|
|
|
|
2026-09-19 14:02:28 -05:00
|
|
|
|
One more asymmetry worth naming here rather than discovering by surprise:
|
|
|
|
|
|
while both panes accept keyboard input, the global capture-phase shortcut
|
|
|
|
|
|
handler (`app.js`) always resolves against Pane A — it has no notion of
|
|
|
|
|
|
which pane currently has focus. So Ctrl+L or Ctrl+W typed while Pane B has
|
|
|
|
|
|
focus clears or closes Pane A, not the session you were actually typing
|
|
|
|
|
|
into. Not fixed for v1, same reasoning as the rest of this section.
|
|
|
|
|
|
|
2026-09-15 21:10:25 -05:00
|
|
|
|
## Components
|
|
|
|
|
|
|
|
|
|
|
|
### 1. `SplitTerminalPane` (new, `terminal-split.js`)
|
|
|
|
|
|
|
|
|
|
|
|
A small class, one instance per secondary pane:
|
|
|
|
|
|
- `constructor(sessionId, mountEl)`
|
|
|
|
|
|
- `connect()` — creates the xterm instance (same theme/font config as the
|
|
|
|
|
|
primary, read from the same settings so it doesn't visually clash), opens
|
|
|
|
|
|
`/ws/sessions/:id/terminal`, wires input → WS, WS → terminal write
|
|
|
|
|
|
- `fit()` — calls the fit addon; called on divider drag (rAF-throttled) and
|
|
|
|
|
|
on window resize
|
|
|
|
|
|
- `destroy()` — disposes the xterm instance, closes the WS cleanly
|
|
|
|
|
|
|
|
|
|
|
|
No snapshot/scrollback-restore map is needed the way `_xtermSnapshots` exists
|
|
|
|
|
|
for Pane A — Pane B is destroyed on close, not hidden-and-restored, since
|
|
|
|
|
|
there's no persistence requirement.
|
|
|
|
|
|
|
|
|
|
|
|
### 2. Split container (layout)
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
.terminal-split-container (flex row, only rendered when split is active)
|
|
|
|
|
|
├── .terminal-wrap (existing element, Pane A — untouched)
|
|
|
|
|
|
├── .split-divider (new, draggable seam)
|
|
|
|
|
|
└── .terminal-pane-b (new, hosts SplitTerminalPane's xterm + a
|
|
|
|
|
|
small header: session name + × close button)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
When not split, `.terminal-wrap` renders exactly as it does today (no
|
|
|
|
|
|
wrapping container at all, to keep the no-split path byte-identical to
|
|
|
|
|
|
current behavior). Splitting inserts the container and reparents
|
|
|
|
|
|
`.terminal-wrap` into it as the first child — same reparenting pattern
|
|
|
|
|
|
already used by `applySessionListLayout()` for `#sessionTabs`, so this isn't
|
|
|
|
|
|
a new pattern for the codebase.
|
|
|
|
|
|
|
|
|
|
|
|
Default split is 50/50 (`flex-basis: 50%` each). Divider drag updates both
|
|
|
|
|
|
panes' `flex-basis` live (rAF-throttled) and calls `fit()` on **both**
|
|
|
|
|
|
terminals per tick, clamped to 20%/80% so neither pane can be dragged into an
|
|
|
|
|
|
unusably thin sliver.
|
|
|
|
|
|
|
|
|
|
|
|
### 3. Trigger UI
|
|
|
|
|
|
|
|
|
|
|
|
A **"Split"** button (header, opt-in like the other header buttons —
|
|
|
|
|
|
`showSplitButton`, default off, same pattern as `showMultiMonitorButton`)
|
|
|
|
|
|
opens a small picker listing your other open sessions (reuses
|
|
|
|
|
|
`this.sessions`/`sessionOrder`, filtered to exclude the currently active
|
|
|
|
|
|
session — you cannot split a session against itself). Picking one:
|
|
|
|
|
|
1. Creates the split container, reparents `.terminal-wrap`
|
|
|
|
|
|
2. Instantiates `SplitTerminalPane` for the chosen session in `.terminal-pane-b`
|
|
|
|
|
|
3. Button state flips to "close split" (or Pane B's own header × does it)
|
|
|
|
|
|
|
|
|
|
|
|
Closing (via Pane B's × or the header button toggling off):
|
|
|
|
|
|
1. `SplitTerminalPane.destroy()`
|
|
|
|
|
|
2. Removes `.terminal-split-container`, reparents `.terminal-wrap` back to
|
|
|
|
|
|
its original location at 100% width
|
|
|
|
|
|
3. Fires a resize/fit on Pane A (same `ResizeObserver`-driven fit already in
|
|
|
|
|
|
place today — no new code needed here, it fires naturally once the
|
|
|
|
|
|
container's size changes)
|
|
|
|
|
|
|
|
|
|
|
|
v2 note (not designed): dragging a session tab onto the active pane as an
|
|
|
|
|
|
alternate trigger. You confirmed right-click doesn't work today (Codeman
|
|
|
|
|
|
doesn't intercept it) and declined a keybind, so v1 is button+picker only.
|
|
|
|
|
|
|
|
|
|
|
|
### 4. Failure / edge cases
|
|
|
|
|
|
|
|
|
|
|
|
- **The Pane B session ends or is deleted while split is active** → treat
|
|
|
|
|
|
identically to the user closing Pane B manually: destroy the pane, collapse
|
|
|
|
|
|
to Pane A at full width.
|
|
|
|
|
|
- **The Pane A session ends while split is active** → Pane B is promoted:
|
|
|
|
|
|
it becomes the new single full-width pane (reusing today's normal
|
|
|
|
|
|
single-pane code path means Pane B's `SplitTerminalPane` must hand off to
|
|
|
|
|
|
a real `this.terminal`/`this._ws` binding — simplest correct approach is
|
|
|
|
|
|
to just collapse the split and let normal session-select logic reopen
|
|
|
|
|
|
Pane B's session as the new primary, rather than trying to promote the
|
|
|
|
|
|
lightweight pane object in place).
|
|
|
|
|
|
- **Both end** → falls through to today's normal "no active session" /
|
|
|
|
|
|
welcome-screen state.
|
|
|
|
|
|
- **Subagent/Ultracode floating windows** → no design work needed; they're
|
|
|
|
|
|
already positioned independent of `.terminal-wrap`'s layout, so they
|
|
|
|
|
|
continue to float over whichever pane(s) are on screen, unconstrained,
|
|
|
|
|
|
exactly as today.
|
|
|
|
|
|
|
|
|
|
|
|
## Testing
|
|
|
|
|
|
|
|
|
|
|
|
- Unit: `SplitTerminalPane` connect/fit/destroy lifecycle (mock WS, like
|
|
|
|
|
|
existing terminal tests use `TEST_PTY_SCRIPT`).
|
|
|
|
|
|
- Route/integration: opening two WS connections to two different sessions
|
|
|
|
|
|
from one simulated client concurrently — confirms the existing per-session
|
|
|
|
|
|
cap and connection registry need no changes.
|
|
|
|
|
|
- Browser (Playwright, `test/browser` since this is desktop-viewport-gated
|
|
|
|
|
|
UI): open split via button+picker, verify both panes render live output
|
|
|
|
|
|
independently, drag divider and confirm both refit, close Pane B and
|
|
|
|
|
|
confirm Pane A returns to full width, kill the Pane B session externally
|
|
|
|
|
|
and confirm auto-collapse.
|
|
|
|
|
|
|
|
|
|
|
|
## Open questions for review
|
|
|
|
|
|
|
|
|
|
|
|
None blocking — the scope-narrowing decisions above (Pane B feature parity,
|
|
|
|
|
|
no persistence, side-by-side only, button+picker trigger) came directly from
|
|
|
|
|
|
your answers during brainstorming. Flag anything here you want reconsidered.
|