Compare commits

..
Author SHA1 Message Date
Codeman maintainer bcdccd14c4 fix(shortcuts): Ctrl+W no longer closes a session
Close Session was bound to Ctrl+W by default. Ctrl+W is delete-word in
every shell, readline prompt and agent CLI, so muscle memory killed the
session (its tmux pane and CLI, with no confirm) mid-sentence, and with
the split pane open it was not even the pane being typed in.

Close Session now has no default key: the capture-phase handler lets
Ctrl+W through and xterm sends ^W to whichever pane is focused. The
action stays in the registry and can be bound in App Settings ->
Shortcuts; the shortcut overlay shows it as not bound. The Help modal,
CLAUDE.md, the split and tile-grid specs and three wiki pages stop
advertising Ctrl+W as kill. Owner decision (tile-grid decision 5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 15:46:49 +02:00
Codeman maintainer 9e032bdc3e feat(split): Pane B reconnects as soon as the server is back
When SSE comes back after a server restart, handleInit's reconnect
branch already re-opens the primary pane's socket; it now also calls
the split pane tile's reconnectNow(), so Pane B no longer waits out its
backoff (up to 10 s between tries) after every deploy. Live: Pane B was
back 4.6 s after the server process respawned, i.e. as soon as it
listened.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 11:03:09 +02:00
Codeman maintainer 497711a05e docs: Pane B is a TerminalTile (reconnect, exactly-once input, focus)
CLAUDE.md, architecture-invariants#split-pane-sessions and the split-pane
spec described Pane B as having no reconnect, seq-less input and xterm's
own Ctrl+V. Updated for TerminalTile (terminal-tile.js, load order 7.4):
reconnect and stop codes, the input-socket map and which input is kept
out of the persisted queue, image paste, the geometry rules, and
_focusedPane() with its Ctrl+W exception. The tile-grid spec now records
PR 1 as built (no key handler factory; scheduleLoad and scrollback move
to PR 2 with their first user).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:46:33 +02:00
Codeman maintainer fe9b209f67 feat(split): terminal shortcuts, voice and paste follow the focused pane
With the split open, every app-level terminal action resolved against
Pane A: Ctrl+L typed into Pane B cleared Pane A's display while xterm
sent the ^L into Pane B's PTY, and Ctrl+Shift+R restored Pane A's size.

_focusedPane() now answers with the terminal focused LAST (a mic or
header click moves DOM focus but not the user's pane): Pane B claims it
from its own textarea's focus, the primary terminal's focus gives it
back, and a destroyed pane never holds it. Ctrl+L, Ctrl+Shift+R, voice
dictation and image paste act on the focused pane. Ctrl+W deliberately
still closes the active session: it kills with no confirm, so moving it
is an owner decision (docs/tile-grid-plan.md, decision 5).

Also destroys every tile after each terminal-tile-input test: a real
reconnect timer from one test opened a socket in a later one and flaked
under full-suite load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:45:16 +02:00
Codeman maintainer fbd69e62aa feat(split): clickable file paths and image paste in Pane B
- Pane B registers the primary pane's file-path/URL link provider on
  its own terminal, so a path an agent prints there opens the file
  preview or log viewer for Pane B's session (it was plain text).
- Ctrl+V/Cmd+V in Pane B goes through the primary pane's paste trap,
  aimed at Pane B: a pasted image uploads to Pane B's session and its
  path is typed there; text keeps its bracketed-paste markers. xterm's
  default handled text only.

Tests pin both the tile wiring and the targeted link provider itself
(registered on the target terminal, opening against the target's
session at click time, the primary's tap-path provider untouched).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:20:25 +02:00
Codeman maintainer a54ad81684 fix(split): Pane B and its PTY never disagree about size (#464)
- Font size, family and weight changes refit Pane B AND tell its PTY.
  They used to reflow the xterm only, leaving the CLI wrapping at the old
  column count, the garbled-redraw class #464 fixed for the primary pane.
- The resize frame reports the size the xterm actually holds, with no
  40x10 floor (the divider's 20% clamp leaves about 28 columns), skips
  an unchanged size, and is always re-sent on a fresh socket so it
  re-registers as a desktop viewer.
- The server's {t:'zc'} geometry report is handled: a different column
  count is adopted, rows stay local, using the primary pane's own
  reconcilePtyGeometry verdict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:07:40 +02:00
Codeman maintainer 0ec17633ba feat(split): Pane B reconnects after a drop instead of staying dead
A Codeman restart (every deploy) or a network blip used to leave Pane B
dead, with a marker asking the user to close and reopen the split.
TerminalTile now reconnects:

- A transient close reconnects on the primary pane's backoff ladder
  (CodemanWsReconnect) plus jitter; the attempt count resets only on a
  successful open. The redelivery sweep's forced close (1005) counts as
  transient.
- On reopen the closed state is cleared before the buffer refresh that
  closes the output gap, so no stale marker lands under a healthy pane.
- 4003/4004/4009/4010 stop the pane for good and report once through a
  new onExit(code) callback; the marker says why.
- Sockets are replaced race-free: the old one is detached before a new
  one opens, and every handler ignores events from a socket that is no
  longer current. destroy() cancels a pending reconnect.
- reconnectNow() lets an owner skip the backoff.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:54:57 +02:00
Codeman maintainer 5e3dbf2057 feat(split): Pane B input goes through the exactly-once queue
TerminalTile used to send every xterm onData chunk as a bare {t:'i'}
frame: no seq, no ACK, silently dropped while its socket was down, and
typing never acknowledged the session's idle alert. Keystrokes and
pastes now go through app._sendInputAsync over the tile's own socket,
registered in the input-socket map while open (HTTP fallback while
not), so they are ACKed, persisted until delivered, redelivered after a
drop, and the ACK clears the idle alert.

What xterm generates on its own stays out of that persisted queue: a
query reply (DA/CPR/OSC) is dropped, as the primary pane drops it, and a
focus or mouse report goes out once via _sendInputEphemeral. The tile's
socket carries the tab identity with a :tile suffix so it can never
evict the primary pane's socket.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:41:10 +02:00
Codeman maintainer d1bbb4cc26 refactor(split): move the pane class into terminal-tile.js as TerminalTile
Pure move and rename, no behavior change. The split pane's second
terminal (SplitTerminalPane) moves out of terminal-split.js into its own
terminal-tile.js (load order 7.4) as TerminalTile, so the tile grid can
reuse it. terminal-split.js keeps the split orchestration (picker,
divider, auto-collapse) and constructs a TerminalTile for Pane B.

Tests follow the class: split-pane-terminal-unit becomes
terminal-tile-unit, and the Shift+Enter guard and the two browser suites
read terminal-tile.js / window.TerminalTile. The browser suites match
master (one pre-existing environmental failure in both).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:28:08 +02:00
Codeman maintainer e2f56dc077 fix(input): image paste and dictation land in the session they started in
Both read activeSessionId at the END of an async gap, so switching tabs
in between sent the input to the wrong session:

- An image upload inserted its paths with sendInput(), which re-reads
  activeSessionId after the uploads finish. It now inserts into the
  session the batch was uploaded to, through the same durable queue.
- Voice dictation read the target when the transcript arrived and again
  when the send button or the compose overlay's Send was pressed. The
  target is now captured in start() (via _focusedPane()), the local-echo
  overlay is only used when that target is the active session, and a
  target that closed meanwhile gets a toast instead of a 404.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:09:51 +02:00
Codeman maintainer ead3d34411 refactor(terminal): seams for a second terminal pane (input socket map, targeted links, copy, paste)
No behavior change. Prepares the split pane's second terminal (and later
grid tiles) to share what today only the primary terminal has:

- _inputSocketFor/_registerInputSocket/_unregisterInputSocket: the
  exactly-once input queue, its ACK handling and the redelivery sweep now
  deliver over any registered socket bound to a session, not only
  this._ws. ACKs are routed by the receiving socket's session; silence is
  judged per socket; a stale handle cannot unregister its replacement.
- registerFilePathLinkProvider, cleanedTerminalSelection,
  copyTerminalSelection and _handleImagePaste take an optional target
  terminal and session (defaults: the primary pane).
- _focusedPane() is the one place to ask which pane the keyboard is in
  (primary only, for now); _forEachTile() replaces the _splitPane special
  cases in the font, family, weight, skin and resize paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:55:05 +02:00
Codeman maintainer f1537a7887 docs: tile grid design spec (two-PR plan: TerminalTile foundation, then the grid)
Plans a grid of up to nine live sessions side by side. All tiles are
equal TerminalTiles, the main terminal is parked while the grid is
open, and activeSessionId follows the focused tile. The split pane stays
and shares the tile class. PR 1 builds the seams and TerminalTile, so
the split's second pane gains reconnect, exactly-once input, links,
image paste and focus-following shortcuts. PR 2 adds the grid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:35:14 +02:00
38 changed files with 4303 additions and 1940 deletions
+5 -5
View File
@@ -250,7 +250,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned) **Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) **Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
@@ -274,9 +274,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit) **Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The planned tile grid reuses `TerminalTile` (`docs/tile-grid-plan.md`). → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
@@ -325,7 +325,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend ### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
@@ -389,7 +389,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. **Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
### Security ### Security
File diff suppressed because one or more lines are too long
+8
View File
@@ -4,6 +4,14 @@
**Author**: Claude (session with Tim), 2026-09-15 **Author**: Claude (session with Tim), 2026-09-15
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed. **Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
> **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
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
> closes anything (Close Session has no default key).
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
## Problem ## Problem
Codeman's terminal area shows exactly one active session (pane) at a time — Codeman's terminal area shows exactly one active session (pane) at a time —
+860
View File
@@ -0,0 +1,860 @@
# Tile Grid: Design Spec
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`, local only; PR 2 (the grid) proposed. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Author**: Claude (planning session with the maintainer), 2026-10-06
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktree `claudeman-tiles`)
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
## Problem
Codeman's terminal area shows exactly one session at a time. The split pane
(`showSplitButton`, `src/web/public/terminal-split.js`) added a second live
session beside the active one, but stops at two, gives the second pane
("Pane B") a deliberately reduced feature set, and has no reconnect: every
Codeman restart (every release deploy) leaves Pane B dead until the split is
closed and reopened.
The goal is a dashboard of agents: four, six or nine live Claude sessions on
one monitor, each readable and typeable, with its state visible at a glance.
The target picture is a 3x2 grid of tiles, each tile a full terminal with a
small header: status dot, session name, a `⋯` menu, maximize, `+` and `×`.
## Goal (v1)
- A **tile grid** of 1 to 9 live sessions in one Codeman window, laid out
automatically by count, with draggable column and row dividers.
- Every tile is equal: same terminal class, same features, same header. One
tile is **focused** and receives the keyboard.
- The rest of the app follows the focused tile: files panel, git status,
respawn and Ralph panels, subagent windows, voice, image paste.
- Tiles survive a Codeman restart (reconnect) and a page reload (per-device
persistence).
- The split pane stays as it is for now (decided). The grid is a separate
mode that shares the same tile class with it; the two are never open at the
same time (see "Coexistence with the split pane").
## Non-goals (v1)
- Phones and tablets. The grid is desktop-only, gated at 1180 px like the
split (`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`).
- More than 9 tiles.
- WebGL rendering inside tiles (see "Rendering" below).
- Full parity with the main terminal's touch and IME features: local-echo
overlay, the CJK input textarea, the keyboard accessory bar, touch gestures,
mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full
history" banner. These exist for touch devices or rare cases; a desktop
keyboard user types straight into xterm, which is how Codeman behaved before
those features existed.
- Server-side persistence of grids (named presets per owner).
- Pop-out windows (`/session/:id`, solo mode) showing a grid.
## Current architecture (why this is not a CSS change)
`terminal-ui.js` is built around ONE terminal: `this.terminal` (one xterm),
`this._ws` / `this._wsSessionId` (one WebSocket, rebound on every tab switch
via `_disconnectWs()` + `_connectWs(id)`), `this._xtermSnapshots` (scrollback
snapshots restored into that one terminal), and about 280 references to that
singleton across input handling, sizing, link providers, local echo, IME,
touch handling and the keyboard accessory bar.
The split pane works around it with a second, independent object,
`SplitTerminalPane`: its own xterm, its own fit addon, its own
`/ws/sessions/:id/terminal` socket. That class is the seed of this feature.
It already handles, and this design keeps:
- single-flight buffer loads (`_loadBuffer` / `_refreshBuffer` /
`_endBufferLoad`), so two replays never interleave;
- the "disconnected" marker that must be the LAST thing on screen
(`_markerOwed`, `_stampMarkerIfOwed`);
- the bounded scroll-to-top history pull for shell sessions (`_pullHistory`);
- its own key handler gating app chords (palette, Alt nav, Ctrl+Z, Shift/Ctrl+Enter
via `send-key`, smart copy);
- divider drag with pointer capture, rAF-coalesced local fit, one PTY resize at
pointer-up, and teardown when the split collapses mid-drag.
## Key decision: equal tiles, main terminal parked
Three ways to put N sessions on screen were evaluated:
| Approach | How | Verdict |
|---|---|---|
| A. Anchor plus light tiles | The main terminal stays as tile 1; other tiles are Pane-B style | Rejected. Tile 1 is privileged. The panels follow tile 1, not the tile you are typing in. Making another tile the "main" one costs two buffer reloads per click. |
| B. Equal tiles, main parked | Every tile is a `TerminalTile`. The main terminal is hidden and disconnected while the grid is open. `activeSessionId` always equals the focused tile's session. | **Chosen** |
| C. iframes of solo windows | Each tile is `/session/:id` in an iframe | Rejected. All frames share `codeman:clientId` (localStorage), and `SseStreamManager.addClient` evicts the previous stream with the same clientId, so frames knock each other's SSE offline about every 45 s (the staleness watchdog reconnects, evicting the next one). They also share `codeman:pendingInput`, load N full copies of the app, and play N notification sounds. |
Why B:
- **Focus changes are instant.** Moving focus is `xterm.focus()` plus an
`activeSessionId` update. No fetch, no replay, no flicker.
- **Panels follow focus for free.** Everything keyed on `activeSessionId`
(files panel, git status poll, respawn and Ralph panels, subagent window
visibility, voice, image upload, kill/close, tab highlight) follows the
focused tile once the tile branch of `selectSession` runs the same panel
refresh as a normal switch.
- **Hiding the main terminal is already proven safe.** Web tabs set
`display:none` on `.terminal-wrap` today (`.main.webview-active`). With the
container hidden, FitAddon's `proposeDimensions()` reads `auto` widths and
returns NaN, `clampTerminalDimensions` returns null, and no resize is sent.
- **The end state is clean.** Long term, the main terminal can itself become a
1x1 grid of the same class, which deletes the singleton. B moves toward that;
A entrenches it.
The cost of B is that tiles must reach desktop parity with the main terminal
on the features that matter at a desk: reliable input, reconnect, file-path
links, copy, image paste, voice. Those are listed under "Seams". Most of that
work also fixes gaps the split pane has today.
## User-facing behavior
### Entry points
- **Header Tiles button** (its own button, beside Split). Opens a picker with
checkboxes over open sessions, ordered like the tab strip. When the grid is
open, the button toggles it closed.
- **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if
closed).
- **Drag a tab** from the strip onto a tile to replace it, or onto an empty
slot to add it.
- **"Open group as tiles"** in the tab-group menu (vertical rail, where group
menus exist).
- **New sessions started from this browser tab's Run button** while the grid is
open join the next free slot and take focus. Sessions created elsewhere
(agents, other devices, cron) do not join.
- **Toggle shortcut** (registry action `toggleTileGrid`).
### Layout
Automatic by tile count, computed by a pure helper:
| Tiles | Layout |
|---|---|
| 1 | 1x1 |
| 2 | 2x1 |
| 3 | 3x1 if the grid area is at least ~1800 px wide, else 2x2 with one empty slot |
| 4 | 2x2 |
| 5-6 | 3x2 |
| 7-9 | 3x3 |
Hard cap 9. Capacity is also bounded by a minimum tile size (about 480x240 px,
roughly 60 columns at the default tile font), so the picker disables additions
the window cannot fit.
Column and row dividers are draggable (generalizing the split divider): the
grid stores track fractions (`grid-template-columns: <a>fr <b>fr …`), each
drag clamps both neighbors to the minimum tile size, reflows locally per
animation frame, and sends one resize per affected tile at pointer-up.
### Tile header
`● name ……… ⋯ ⤢ + ×`
- **●** status dot from the existing six-state classifier
(`app._sidebarRichRow(id, session)`, built on `_mobileOverviewState`):
`needs`, `error`, `waiting`, `working`, `idle`, `done`, styled with the
existing unscoped `.home-sessions-dot--*` classes. A `needs` tile also gets a
pulsing red border so a permission prompt is visible across the room. The
label ("working 3m") shows on hover via `_mobileOverviewSince` /
`_mobileOverviewStampText`.
- **name** via `textContent` with `data-i18n-skip` (a session literally named
"Sessions" must not be translated). Double-click renames through
`_queueInlineSessionName(id, name)`.
- **⋯** reuses `openTabRailActionMenu(event, id)` (tab-rail-resize.js): Session
options, Open in a new window, Close session.
- **⤢** zooms the tile to fill the grid, like tmux zoom. The other tiles stay
connected but hidden; hidden tiles measure NaN and send no resizes. Pressing
it again (or the shortcut) restores the grid.
- **+** adds a session: a picker of open sessions not yet tiled, plus "New
session in this case", which runs the normal quick-start for the tile's case
and drops the result into the next slot.
- **×** removes the tile ONLY. The session keeps running. Killing stays behind
`⋯ → Close session` and its existing confirm modal (`requestCloseSession`).
### Focus and keyboard
- The focused tile gets an accent border; its session is `activeSessionId`.
- Tabs of tiled sessions carry an `.in-tiles` marker; the focused one is
`.active` as usual.
- Clicking a tile focuses it (a human selection, see "Focus and alert rules").
- New registry actions (all rebindable, all swallowed in every xterm key
handler so the chord never reaches a PTY):
- `toggleTileGrid` (proposed Ctrl+Shift+G)
- `focusTileLeft/Right/Up/Down` (proposed Alt+Shift+Arrows)
- `zoomTile` (proposed Alt+Shift+Enter)
- `removeTile` (unbound by default)
The defaults must be checked against xterm passthrough, Claude Code's own
bindings and browser chords before they are fixed.
- While the grid is open, Ctrl+Tab and Alt+[ / Alt+] cycle through tiles.
- A USER-initiated selection of a session that is NOT tiled (clicking its tab,
Alt+1-9 onto it, the command palette) leaves the grid and shows that session
in the normal single view; the grid is remembered and one click on Tiles
brings it back (decision 1). An app-driven selection (`auto: true`)
never collapses the grid; see "Selections while the grid is open".
- Ctrl+L clears the focused tile; Ctrl+W is delete-word in the focused tile
(it is not an app shortcut, decision 5); Ctrl +/-
changes the tile font size; Ctrl+Shift+R restores the focused tile's size.
### Persistence
Decided: per device, restored on reload. Stored in localStorage key
`codeman:tile-grid`:
```json
{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
"colFr": [1, 1, 1], "rowFr": [1, 1] }
```
Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
duplicate ids on load. Never restored in a solo window.
The restore runs INSIDE `handleInit`, in place of its initial
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
after it. Otherwise the page first selects the active session in the main
terminal, whose first non-shell select per page pulls an unbounded `?full=1`
capture, only to park that terminal a moment later. With a stored open grid,
the main terminal never loads on that page load. A later `handleInit` (SSE
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
against the live list without rebuilding tiles that are still alive.
### Gating
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
settings PUT, NOT in `SettingsUpdateSchema`), default OFF. Independent of
`showSplitButton`, which is unchanged; a desk can show both buttons.
- Hidden below 1180 px by both a JS width check with a `matchMedia` listener and
a CSS `@media (max-width: 1179px)` backstop, exactly like the split button.
Narrowing the window while the grid is open returns to the single view and
keeps the stored grid.
- Hidden in solo windows (`body.solo-mode`).
- `test/mobile-header-buttons-policy.test.ts` keeps it off phones.
## Components
### 1. `TerminalTile` (`terminal-tile.js`, load order 7.4, PR 1)
The `SplitTerminalPane` class moves out of `terminal-split.js` into a new
`src/web/public/terminal-tile.js` and is renamed `TerminalTile`, keeping every
behavior listed under "Current architecture". The split orchestration stays in
`terminal-split.js` and constructs a `TerminalTile` for Pane B; the grid (PR 2)
constructs one per tile. New in the class:
**Reconnect.** The primary pane's backoff ladder (`CodemanWsReconnect`,
constants.js: 0, 250 ms, 500 ms, ... capped at 10 s) plus up to 250 ms of
jitter, the attempt count reset only by a successful open. A reconnect is also kicked when SSE `handleInit` reports the
server is back. After every reopen the tile runs a bounded refresh (the same
in-stream `\x1bc` clear plus replay as `_refreshBuffer`), because output
frames carry no sequence number and a gap cannot be replayed otherwise. That
refresh goes through the grid's load queue like every other load (see "Load
cost"): after a deploy restart all N tiles reopen within the same second, and
N unqueued refreshes are exactly the capture storm the queue exists to
prevent. Close codes that must NOT reconnect:
| Code | Meaning | Tile does | Owner decides (via `onExit`) |
|---|---|---|---|
| 4009 | Session exited | Stops reconnecting, reports the code | PR 1 split: an "exited" marker in Pane B (the split's existing delete path collapses it if the session is removed). PR 2 grid: the Attach overlay |
| 4003 / 4004 | Forbidden / session gone | Stops reconnecting, reports the code (4003 is stopped by the tile itself: `CodemanWsReconnect` classes it as transient) | PR 1 split: a marker saying why. PR 2 grid: removes the tile |
| 4010 | Superseded by a socket with the same cid | Stops, but only for the CURRENT socket (see below) | Same marker as 4003 |
The class never decides what happens to its container; it reports the close
code through the `onExit` callback and the owner (split or grid) acts.
**Replacing a socket is race-free.** A reconnect can be kicked (by
`handleInit`) while the old socket still looks open on the client: a half-open
connection whose `onclose` has not fired yet. The new socket carries the same
cid, so the server supersedes the old one with a 4010, and that late `onclose`
would run the "4010: stop" branch on a perfectly healthy tile. So before
opening a replacement the tile detaches the old socket's handlers (as
`destroy()` already does: null `onopen`/`onmessage`/`onclose`/`onerror`, then
`close()`), and every handler checks `event.target === this.ws` and ignores
events from any socket that is no longer current.
The marker text becomes `[disconnected, reconnecting…]` (a stop writes
`[disconnected: <why>]`, `TerminalTile.STOP_MARKERS`) and keeps its "last
thing on screen" rule. On reopen the closed state is cleared BEFORE the gap
refresh, or the refresh re-owes the marker and stamps it under a healthy
pane. `reconnectNow()` skips the backoff and never replaces an open socket.
**Client id on the upgrade URL.** `cid=${clientId}:${tabNonce}:tile`. The
connection registry supersedes by cid PER SESSION, so a distinct suffix means a
tile can never evict the main terminal's socket in a 4010 loop even if the same
session were ever on both. Input frames keep the BARE `clientId` (that is what
the server dedups on).
**Reliable input.** `terminal.onData` calls
`app._sendInputAsync(this.sessionId, data)`, which rides the tile's own socket
through the input-socket map (see "Seams"). This gives the tile the same
exactly-once delivery as the main terminal (per-session `seq`, ACKed, persisted
until ACK), and through `_ackDelivery` it clears the session's idle alert when
you type, which Pane B never did.
**Geometry** (rules from #464, see `docs/architecture-invariants.md`):
- One method, `syncGeometry()`, measures (`proposeDimensions()`), resizes the
xterm and sends `{t:'z', c, r, v:'desktop'}` with THE SAME raw dimensions.
The xterm and the PTY never disagree. There is deliberately NO 40x10 floor
(unlike the main terminal's `clampTerminalDimensions`): commit `57406f6c`
removed it from Pane B because the split's 20% divider clamp can leave Pane
B at about 240 px, roughly 28 columns, and a floored xterm is wider than its
container and clips columns. The server's own range (columns 1-500, rows
1-200) is the only bound. Grid tiles never get that narrow anyway (the
minimum tile size keeps them near 60 columns).
- Unchanged dimensions are not resent (Pane B had no such dedupe and fanned out
a `tmux resize-window` per animation frame during drags before the rAF fix).
- The `{t:'zc'}` reply is handled: adopt the PTY's COLUMNS only, keep local
rows, via the pure `reconcilePtyGeometry` (constants.js). Pane B ignores
`zc` today.
- A hidden tile (zoomed out, web tab active) measures NaN and does nothing; it
syncs when shown again.
- Detached sessions are never tiled, so the split's `detachedSessions` yield in
`_sendResize` becomes a removal (see "Edge cases").
**Rendering.** DOM renderer, no WebGL addon, as Pane B does today. Chrome
allows roughly 16 live WebGL contexts per page and the main terminal keeps
one. Measure nine busy tiles on the DOM renderer before considering WebGL
(follow-up).
**Scrollback.** Tiles use their own cap, `TILE_SCROLLBACK` (proposed 10,000
lines), not `DEFAULT_SCROLLBACK` (50,000). Nine DOM-rendered xterms at 50k
lines each is a real memory cost, and a tile's initial load is already bounded
to a 1 MiB window, so a larger buffer only fills with live output over time.
The shell history pull's "pane full" check reads `term.options.scrollback`, so
it adapts to the lower cap unchanged.
**Key handler.** Pane B's `attachCustomKeyEventHandler` stays in
`TerminalTile` (every tile IS a `TerminalTile`, so no separate factory is
needed), plus:
- Ctrl+V routes into the image-paste trap with this tile's terminal and session;
- the new tile chords are swallowed (return false) so they never reach the PTY.
**Links and copy.** The tile registers the file-path link provider and uses the
shared copy helper (see "Seams"), so clicking a path printed in a tile opens the
file preview for THAT session.
**Disposal.** `destroy()` closes the socket (handlers nulled first, as today),
unregisters from the input-socket map, removes listeners and disposes the
xterm. Nothing may outlive a removed tile (24-hour sessions rule).
### 2. `TileGrid` controller and layout helper
- `computeTileLayout({ count, width, height, minTileW, minTileH })` and
`tileGridCapacity(...)`: pure, in `constants.js`, exported on
`window.CodemanTileGrid` beside the existing helper namespaces.
`sanitizeTileGridState(raw, liveSessions, detachedIds)`: pure, same place.
- The controller (in a new `src/web/public/tile-grid.js`, load order 7.6, as
`CodemanApp.prototype` methods like the split code) owns: the ordered tile list, `focusedId`,
`zoomedId`, track fractions, the `<section class="tile-grid">` element, one
`ResizeObserver` on that section (the main terminal's observer watches a
hidden node and stops firing), the divider drags and the load queue.
- DOM: the grid is a NEW sibling of `.terminal-wrap` inside `.main`, toggled by
`.main.tiles-active`. Unlike the split there is no reparenting of
`.terminal-wrap`. CSS adds `.main.webview-active .tile-grid { display: none }`
beside the existing `.terminal-split-container` rule.
### 3. Parking the main terminal
**Enter:**
1. `_cleanupPreviousSession()` once. Its snapshot of the current session is
correct at that moment, and it disconnects the main socket and flushes local
echo.
2. Add `.main.tiles-active`: `.terminal-wrap` hidden, `.tile-grid` shown.
3. `hideWelcome()`.
**Guards.** With the main socket closed, `_wsReady` is false and the SSE
terminal fallback would start writing the focused session's output into the
hidden xterm (every handler keys on `activeSessionId`). One predicate,
`_tilesOwnTerminal()`, turns these into no-ops while the grid is open:
- `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh`
(it would fetch `?full=1` for nothing), `_scheduleDroppedOutputRecovery`;
- the `terminal.writeln` calls in `_onSessionCompletion` and `_onSessionError`;
- `retryConnection` and the `keepTerminal` branch of `handleInit`, which would
reconnect the main socket;
- as a backstop, beside the existing `detachedSessions` checks in
`sendResize`, `throttledResize`, `_maybeRefetchFullHistory` and
`restoreTerminalSize`;
- the WebGL long-task guard (`_installWebGLLongTaskGuard`). Its
`PerformanceObserver` watches the WHOLE page and counts every long task while
the main terminal's WebGL addon exists, so long tasks caused by tile rendering
or tile replays would trip it and write the sticky `codeman-webgl-disabled`
marker (7 days), silently moving the main terminal to the DOM renderer for
reasons that have nothing to do with WebGL. While tiles own the terminal the
observer callback must not count entries.
A missed guard is mostly harmless (exit replays from scratch) but costs fetches
and CPU, so the guard test enumerates them.
`_computeConnectionDescriptor` must derive the header connection state from the
tile sockets while the grid is open (all open: connected; any reconnecting:
degraded), or the header shows "Connecting" forever.
The SSE subscription stays `[activeSessionId]` as in single view. Those frames
are dropped by the guards. (An empty list means "all sessions", so there is no
"none" to subscribe to; this matches today's single-view duplication anyway.)
**Exit:**
1. Destroy every tile, remove `.main.tiles-active`.
2. `this._lastResizeDims = null` (as `_redock` does).
3. Invalidate the main terminal's cached content for EVERY tiled id, not just
the focused one: the `_xtermSnapshots` entry, the `codeman-xs-<id>`
localStorage key and the buffer-cache entry. Those were written before the
grid opened, possibly hours earlier, and `selectSession` paints a snapshot as
a first frame before its fetch replaces it, so the next switch to a formerly
tiled session would flash content from before the grid.
4. `selectSession(focusedId, { forceReload: true, auto: true })`. For the
already-active id that path drops the stale snapshot and nulls
`activeSessionId` BEFORE `_cleanupPreviousSession`, so nothing wrong is
saved, then reconnects and replays normally.
### 4. The tile branch of `selectSession`
Placed in `app.js` directly after the "already active" early return (~7876),
so tapping the focused tab still acknowledges its alert and the detached-window
check (~7855) still runs first:
```js
if (this._tileGrid?.open) {
if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options);
// Decision 1: only a USER-initiated pick (or an explicit leaveTiles) of a
// non-tiled session leaves the grid. An app-driven one never collapses it.
if (options.auto === true && !options.leaveTiles) return;
this.closeTileGrid({ keepStored: true });
}
```
`_selectTiledSession` keeps:
- `++this._selectGeneration` (aborts any in-flight normal select at its next
`_isStaleSelect` check);
- `_hideWebviewLayer()`; `activeSessionId = id`; `_activateFileBrowserSession`;
the `codeman-active-session` key; `hideWelcome()`;
- `markIdleAlertSeen(id)` only when user-initiated (`options.auto !== true`);
- `_updateActiveTabImmediate`, tab glow, `renderSessionTabs`,
`closeSessionSidebarOnHandheld`;
- `updateAttachmentHistoryBadge`, `KeyboardAccessoryBar.refreshForActiveSession`,
`refreshHostWakeBanner`, `currentSessionWorkingDir`;
- the deferred panel block (respawn banner and countdown, action log, task
panel, Ralph state, CLI info, project insights, subagent window visibility,
file browser). That block (~8452-8523) is first extracted into
`_refreshSessionPanels(id, generation)` so both paths share one copy;
- focusing the tile's xterm.
It skips everything bound to the main terminal: `terminal.focus()`,
`_cleanupPreviousSession`, the truncation banner, `playTerminalEntrance`,
local-echo state, `_beginBufferLoad`, `syncTerminalGeometry`, both
`sendResize` calls, snapshot/cache/fetch replay, `_fullHistoryLoaded`,
`_markTerminalBufferReconciled`, `_connectWs`, scroll-to-bottom, the resize
retry, and the `pid === null` attach POST (the tile's Attach overlay owns it).
The split's own `selectSession` and `_onSessionDeleted` wrappers stay. They
act only while `this._splitPane` is set, and the grid never opens alongside a
split (see "Coexistence with the split pane"), so the two never compete.
#### Selections while the grid is open
Several app-driven paths call `selectSession` on their own, and without the
`auto` rule above each of them would land on a non-tiled session and collapse
the grid. Each one gets an explicit grid-aware behavior:
| Path | Today | With the grid open |
|---|---|---|
| Close session on the focused tile (`closeSession`, from its menu or a user-bound key) | Reads `wasActive` before its `await`, adds the id to `_closingSessions`, then selects the first remaining `sessionOrder` entry with `auto: true`, which is often NOT tiled | A grid-aware fallback picker: remove the tile, then focus the neighboring tile (next in grid order, else previous). Only with no tiles left does it fall back to the `sessionOrder` pick, which closes the grid. Note the split's `_onSessionDeleted` wrapper deliberately skips selection for ids in `_closingSessions`, so the fallback MUST live in `closeSession` itself, not in the delete wrapper. |
| Session deleted elsewhere (`_onSessionDeleted`) | The handoff selects the first remaining `sessionOrder` entry | If it was tiled: remove the tile and focus a neighbor with `auto: true`. If it was not tiled it was not active, so there is no handoff. |
| Boot restore (`handleInit`) | `selectSession(restoreId, { auto: true })` | Replaced by the grid restore when a stored grid is open (see "Persistence") |
| URL `#session=<id>` link | `selectSession(id, { auto: true })` | Following a link is navigation, so this path passes `leaveTiles: true`: a tiled id focuses its tile, a non-tiled id opens the single view (grid kept in storage) |
| Pane promotion after a delete (split) | `selectSession(promoted, { auto: true })` | Unchanged for the split; unreachable while the grid is open, since no split can be open then |
| Auto-join of a session created by this tab's Run | Run selects the new session | The session joins the grid first, then is selected through the tile branch |
### 5. Focus and alert rules
These follow the Approvals Inbox acknowledgement rule
(`docs/architecture-invariants.md#approvals-inbox`):
| Event | Acknowledges the idle alert? |
|---|---|
| Pointerdown on a tile, a tile-nav chord, a click on its tab | Yes (human selection) |
| Typing into a tile | Yes, via `_ackDelivery` on the input ACK |
| Opening the grid, restoring it, promoting a neighbor after a delete, auto-join of a new session | No (`auto: true`) |
| A tile merely being visible | No |
| Anything | Never clears permission/question (action) alerts; those clear only on resolution |
Notifications are unchanged: a visible but unfocused tile still raises its
sound/title/desktop notification, so nothing is silently swallowed.
### 6. Seams (small refactors, no behavior change on their own)
| Feature | Today | Change |
|---|---|---|
| Input socket | `_drainSession`, `_sendInputEphemeral`, `_redeliverSweep` and `_onWsInputAck` assume the single `_ws` / `_wsSessionId` / `_wsLastRecvAt` | An `_inputSocketFor(sessionId)` map that the main terminal and each tile register into (`{ ws, ready(), lastRecvAt }`). `{t:'ia'}` acks carry no session id, so each socket's `onmessage` passes its own: `_onWsInputAck(seq, msg, sessionId)`. Without the map, tile input still works through the HTTP POST fallback (durable, but one awaited POST per record). |
| File-path links | `registerFilePathLinkProvider()` reads `self.terminal` and opens with `self.activeSessionId` | `registerFilePathLinkProvider(terminal = this.terminal, getSessionId = () => this.activeSessionId)` returning the provider; about five references change |
| Copy | `copyTerminalSelection` / `cleanedTerminalSelection` read `this.terminal`; Pane B re-implements them | Both take `(terminal, sessionId)`; Pane B's copy is deleted |
| Image paste | `_handleImagePaste()` uses the main terminal; `_uploadAndInsertImages` inserts with `sendInput()`, which re-reads `activeSessionId` AFTER the upload (an existing bug: switch tabs mid-upload and the paths land in the wrong session) | `_handleImagePaste({ terminal, sessionId })`; insert with `_sendInputAsync(sessionId, paths, { useMux: true })` |
| Voice | `_insertText` re-reads `app.activeSessionId` at insert time and appends to the main local-echo overlay | Capture the target in `start()`; send with `_sendInputAsync(target, …)`; skip the overlay when the target is not the main terminal |
| Shortcuts | Ctrl+L (`clearTerminal`) and Ctrl+Shift+R (`restoreTerminalSize`) act on `this.terminal` | Resolve through `_focusedPane()` returning `{ terminal, sessionId, isPrimary }`; Close Session (no default key) already takes an id |
| Font, family, weight, skin | `setFontSize` / `setFontFamily` / `setFontWeight` / `applyTerminalSkin` special-case `this._splitPane` | Loop over all tiles |
### 7. Fonts
Tiles get their own per-device font size, `codeman-tile-font-size` (default
13), because a tile is a fraction of the screen. While the grid is open,
Ctrl +/- changes the tile font for all tiles. A font change is a geometry
change (#464): every tile re-runs `syncGeometry()` afterwards.
### 8. Load cost
`GET /api/sessions/:id/terminal` runs three SYNCHRONOUS tmux calls
(`list-panes`, `capture-pane`, `display-message` via `execSync`, each with a
5 s timeout). Nine `full=1` loads at once would not run in parallel; they would
run back to back on the event loop and stall every WS and SSE stream on the
server for seconds.
So EVERY tile load goes through ONE grid-level client queue (PR 2, plugged in
through a `scheduleLoad` option PR 2 adds to `TerminalTile`): the initial load,
the refresh after a reconnect, a server `{t:'r'}` refresh, and the shell
history pull. No tile calls `fetch('/terminal…')` on its own. The tile's
single-flight flag stays (it is what keeps one tile's replays from
interleaving); the queue sits in front of it and bounds the whole grid.
- concurrency 1, focused tile first, then reading order (a user-triggered
history pull jumps ahead of background refreshes);
- after a deploy restart, the N reconnect refreshes drain one at a time
instead of hitting the server together;
- each load uses the BOUNDED window `?full=1&tail=TERMINAL_TAIL_SIZE` (1 MiB)
for TUI sessions and `?tail=…` for shells, never an unbounded `full=1`;
- a tile shows a quiet "loading" state until its turn;
- full history is one action away: zoom then exit tiles, or exit tiles.
### 9. Coexistence with the split pane
Decided: the split pane stays for now. The two modes are mutually exclusive
and share one tile class:
- Opening the grid while a split is open closes the split first and seeds the
grid with both of its sessions (Pane A's focused, Pane B's beside it), so
"split, then want more" is one click.
- While the grid is open, `openSplitPicker` and `openSplitPane` refuse and the
Split button shows as disabled (`aria-disabled`), the same refusal pattern
`openSplitPane` already uses for web tabs and the welcome screen.
- Closing the grid never reopens a split.
- The split's wrappers (`selectSession`, `_onSessionDeleted`) key on
`this._splitPane`, which is null whenever the grid is open, so they stay
inert there without changes.
- Shared code lives in `TerminalTile` and the seams from PR 1, so a fix to tile
behavior reaches both modes. Whether to retire the split later (a 2-tile grid
covers it) is left for after the grid has been used for a while.
## Edge cases
| Situation | Behavior |
|---|---|
| A tiled session is deleted (here or elsewhere) | Tile removed; a neighbor gets focus with `auto: true`; the last tile gone falls through to the normal handoff |
| Closing the focused tile's session | The grid stays open and the neighboring tile takes focus (grid-aware fallback in `closeSession`, see "Selections while the grid is open") |
| A tiled session is popped out to its own window | Tile removed: that window now owns the PTY size |
| Session exited or not attached (`pid === null`, `paneExit`) | The tile body shows "Not attached" with an Attach button: `POST /interactive` (or `/shell` for shell mode) with NO body, at most one in flight per session (the route has no in-flight guard of its own). A tripped PTY-exit breaker goes through the existing confirm before `clearBreaker: true`; no automatic path ever sends it |
| A web tab is opened | Grid hidden by CSS; sockets stay up; hidden tiles send no resizes. Selecting a tiled session's tab brings the grid back |
| Window narrower than 1180 px | Back to single view of the focused session; stored grid kept |
| Window shrinks below the tiles' minimum size | The focused tile zooms with a short hint; widening restores the grid |
| Codeman restarts (deploy) | Tiles reconnect; their refreshes drain through the load queue one at a time; `handleInit` reconciles ids without rebuilding live tiles |
| A half-open tile socket when a reconnect is kicked | The old socket's handlers are detached before the replacement opens, so its late 4010 close is ignored |
| A phone opens a tiled session | Its resize is declined while the desktop holds a sizing claim and was active in the last 90 s (existing `Session.resize` arbitration) |
| A second desktop browser shows a tiled session full-size | Last resize wins and only the resizing socket hears `zc` (existing behavior, see follow-up 2) |
| Split collapses or a tile is removed mid-divider-drag | Drag teardown first (carried over from the split's mid-drag fix) |
| Remote (SSH) and Docker sessions | Work unchanged: their pane is a local tmux pane like any other |
| Multi-user mode | The picker lists only visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side |
| Solo window | Tiles unavailable |
## Server
**No server change is required.**
- N sockets to N different sessions each use one slot of that session's
`MAX_WS_PER_SESSION = 5` (`ws-routes.ts`). There is no per-client or global WS
cap.
- One `/api/events` SSE stream already carries every session's lifecycle and
hook events, so tiles need no extra stream for their status dots.
- `v:'desktop'` resizes already register a sizing claim, so a phone cannot
shrink a tiled session while the desktop is active.
- The WS sends nothing on connect, which is why each tile loads its buffer
first (already true for Pane B).
Separate follow-up PRs worth doing (see "Follow-ups").
## Invariants this feature must keep
- **#464 geometry**: a tile's xterm and its PTY never disagree; one function
sizes both; `zc` adoption is columns only; withhold the fit wherever the
resize is withheld.
- **Grid and split are never open together**: opening the grid closes a split;
the split refuses to open while the grid is open.
- **One place per session in this browser tab**: the main terminal is parked
while the grid is open, a session is in at most one tile, detached sessions
are never tiled.
- **App-driven selections never collapse the grid**: only a user-initiated pick
(or an explicit `leaveTiles`) of a non-tiled session leaves it; every
app-driven fallback (close, delete, restore) picks a tile.
- **One load queue**: no tile fetches `/terminal` outside the grid's queue
(initial, reconnect, `{t:'r'}`, history pull), because each capture blocks
the server's event loop.
- **Only the current socket counts**: a tile ignores events from any socket
that is no longer `this.ws`, and detaches handlers before replacing one.
- **Tiles never touch main-terminal state**: no long tasks counted against the
main terminal's WebGL, and its snapshots for tiled ids are invalidated on
exit.
- **Alerts**: visible is not acknowledged; only a human selection or delivered
input acknowledges idle; action alerts are never cleared by view or input;
app-driven selections pass `auto: true`.
- **PTY-exit breaker**: never `clearBreaker` from an automatic path.
- **Replay clears are in-stream** (`\x1bc` queued in the write stream), never
`reset()`.
- **Capture fetches carry deadlines that cover the body** (reuse
`CodemanFetchDeadline`, as `_pullHistory` does).
- **Per-device setting**: in `displayKeys`, stripped from the PUT, not in the
`.strict()` schema; the `--hidden` marker class has a `display: none` rule.
- **Palette chords** are swallowed in every xterm key handler.
- **Escape**: the picker's close method returns early when the picker is not
open (the global Escape handler calls every close method).
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
- **No secrets in localStorage**: the stored grid holds ids only.
- **Memory**: everything a tile creates is released in `destroy()`.
## Delivery: two PRs
Decided: the work ships as two PRs. PR 1 stands on its own: it builds the tile
class and the seams, and the existing split pane runs on them, so users get a
better split before the grid exists. PR 2 adds the grid on top.
### PR 1: tile foundation (the split pane gets better)
Commits:
1. **Seams.** The input-socket map; the parameterized link provider, copy,
image paste and voice target; `_focusedPane()`; a `_forEachTile()` helper
that replaces the `this._splitPane` special cases in the font, family,
weight and skin setters. No behavior change on its own. Unit tests for each.
2. **`TerminalTile`.** The class moves out of `terminal-split.js` into a new
`src/web/public/terminal-tile.js` (load order 7.4, before
`terminal-split.js` at 7.5) and is renamed from `SplitTerminalPane`. The
split's orchestration (`openSplitPane`, `closeSplitPane`, the picker, the
divider drag, the wrappers) stays in `terminal-split.js` and constructs a
`TerminalTile` for Pane B. New in the class: reconnect with race-free socket
replacement and the close-code table, the `:tile` cid suffix, reliable input
through the socket map, `zc` handling with one geometry method, the
file-path link provider and image paste, and an `onExit` callback. The
`scrollback` and `scheduleLoad` options were deferred to PR 2, which
introduces them together with the grid's load queue, their first user.
3. **Split pane follows focus.** `_focusedPane()` returns Pane B while its
xterm has focus, so Ctrl+L, Ctrl+Shift+R, voice and image paste act on the
pane you are typing in. This removes most of the asymmetry the split-pane
spec documented and accepted for its v1 ("Ctrl+L or Ctrl+W typed while Pane
B has focus clears or closes Pane A"). Typing into Pane B now clears its
idle alert through `_ackDelivery`, which it never did.
**Ctrl+W no longer closes anything** (decision 5): Close Session has no
default key, so Ctrl+W reaches the focused pane as delete-word.
4. **Docs.** Update the split-pane paragraph in CLAUDE.md and
`docs/architecture-invariants.md#split-pane-sessions` (Pane B now
reconnects, delivers input exactly once, has links and image paste, and
follows focus; the "deliberately plainer" list shrinks accordingly), add
`terminal-tile.js`(7.4) to the frontend load order, add a note at the top of
`docs/split-pane-sessions-plan.md` pointing here.
What users get from PR 1 alone: a split pane that survives deploys, never
loses or doubles a keystroke across a reconnect, has clickable file paths and
image paste, and whose shortcuts act on the pane that has focus.
PR 1 tests (gate):
- `test/terminal-tile-unit.test.ts`, moved from
`split-pane-terminal-unit.test.ts` with every existing case kept
(single-flight, marker-last including the async-parse fake, history pull),
plus: reconnect backoff and each close code, a late `onclose` (4010) from a
replaced socket is ignored and the tile keeps running, `zc` columns-only
adoption, the cid suffix, input routed through `_sendInputAsync` (as built:
`test/terminal-tile-input.test.ts`, which runs `connect()` for real).
- `test/input-socket-map.test.ts`: acks routed to the right session's queue,
redelivery per socket, POST fallback when no socket is registered.
- `test/focused-pane-shortcuts.test.ts`: with Pane B focused, Ctrl+L clears
Pane B, Ctrl+Shift+R restores Pane B's size, voice and image paste target
Pane B's session, and a user-bound Close Session still targets the active
session; `test/ctrl-w-never-closes.test.ts` pins that no default shortcut
answers Ctrl+W;
with Pane A focused nothing changes.
- Geometry: with the split divider at its 20% clamp, Pane B's xterm and the
size it sends are both under 40 columns and equal (no floor regression).
- `onExit`: each close code is reported once to the owner and stops
reconnecting; the split shows the matching marker.
- The image-paste wrong-session fix: an upload that finishes after a tab switch
still inserts into the session it started in.
- Every existing `split-pane-*` test keeps passing (class name updated where it
is referenced).
PR 1 browser tests: the existing `split-pane-*.browser.test.ts` files (they
match master, one pre-existing environmental failure in both). Pane B
reconnecting after a server restart and a click on a printed path opening
Pane B's file preview are covered by unit tests and checked live on the beta
instance rather than as browser tests (the harness cannot restart its own
server).
PR 1 verification: a split with two real Claude sessions on the beta instance;
restart the server mid-typing in Pane B (reconnect, refresh, no lost or doubled
input); type into Pane B while it has an idle alert (the alert clears); Ctrl+L
in Pane B; image paste into Pane B.
### PR 2: tile grid
Commits:
1. **Grid core.** `_refreshSessionPanels()` extraction from `selectSession`
(no behavior change, its own commit first), layout helpers, the grid section
and CSS, parking with guards (SSE handlers, reconnect paths, WebGL long-task
observer), the `selectSession` tile branch with the `auto` rule, the
grid-aware `closeSession` fallback, focus rules, tile chords in the shortcut
registry, the single grid-level load queue that every tile load goes
through (a new `scheduleLoad` option on `TerminalTile`, plus a `scrollback`
option for `TILE_SCROLLBACK`), coexistence with the split.
2. **Tile chrome and entry points.** Header (dot, name, menu, zoom, add,
remove), the Attach overlay, picker, dividers, drag-a-tab, Ctrl/Cmd+click,
"Open group as tiles".
3. **Persistence.** `codeman:tile-grid` restore inside `handleInit` (in place
of the initial select), snapshot invalidation on exit, auto-join of new
sessions from this tab, the `showTileGridButton` setting.
4. **Docs.** A tile-grid paragraph in CLAUDE.md beside the split-pane one,
`tile-grid.js`(7.6) in the load order, header-button and z-index notes,
`docs/architecture-invariants.md#tile-grid`, a wiki page under
`docs/wiki/`.
## Testing (PR 2)
**Gate (`npm test`):**
- `test/tile-grid-layout.test.ts`: `computeTileLayout`, `tileGridCapacity`,
`sanitizeTileGridState` (unknown, deleted, detached, duplicate ids).
- `test/tile-grid-select-branch.test.ts` and an extension of
`test/session-select-ack-gate.test.ts`: the branch never calls `_connectWs` or
`_cleanupPreviousSession`; acknowledgement only when user-initiated; an
`auto: true` selection of a non-tiled session leaves the grid open; a
user-initiated one closes it; `leaveTiles: true` closes it.
- `test/tile-grid-close-fallback.test.ts`: closing (`closeSession`) the
focused tile keeps the grid open and focuses the neighboring tile, even when
the first `sessionOrder` entry is not tiled; closing the last tile falls back
to the normal pick.
- `test/tile-grid-park-guards.test.ts`: every guarded SSE handler is a no-op
while tiles own the terminal, and the WebGL long-task observer counts nothing
while tiles own the terminal.
- `test/tile-grid-load-queue.test.ts`: N tiles reconnecting together produce at
most one in-flight `/terminal` fetch at a time; `{t:'r'}` and history pulls go
through the same queue; a destroyed tile's queued load is dropped.
- `test/tile-grid-restore.test.ts`: with a stored open grid, `handleInit`
restores the grid and never calls the main terminal's buffer load; on exit,
snapshot and cache entries for every tiled id are invalidated.
- `test/tile-grid-split-coexistence.test.ts`: opening the grid closes an open
split and seeds the grid with both of its sessions; the split cannot open
while the grid is open; the split's wrappers do nothing while the grid is
open.
- `test/tile-grid-per-device-setting.test.ts` and a hidden-button CSS case, in
the shape of the split-pane ones: `showTileGridButton` is in `displayKeys`,
stripped from the PUT, not in the schema, and `.btn-tile-grid--hidden` has a
`display: none` rule.
- Shortcut tests: the new chords are swallowed in the main and tile key
handlers; `mobile-header-buttons-policy` keeps the button off phones.
**Browser (`npm run test:browser -- <file>`, NOT in the gate):**
- Open 4 tiles; all render live output independently.
- Click-to-focus routes keystrokes to the right PTY (assert with
`tmux capture-pane`, never on HTTP 200).
- Ctrl+L clears only the focused tile; Shift+Enter inserts a newline in a tile.
- Zoom and unzoom refit; a divider drag sends exactly one resize per affected
tile at pointer-up.
- Deleting a tiled session removes its tile and moves focus.
- Reload restores the grid; a server restart reconnects every tile.
- Narrowing below 1180 px returns to the single view.
Reminder: `npm test -- <browser file>` matches nothing, runs zero tests and
exits green. Use the browser runner for those files and read the file count.
## Verification before merging PR 2
- Build in the worktree and run an isolated beta instance (its own
`CODEMAN_INSTANCE`, so its own data dir and tmux socket), reached through
`tailscale serve` on a never-used port.
- Six throwaway Claude sessions, as in the target picture; Playwright captures
at `deviceScaleFactor: 1`, unique filenames.
- A Chrome performance trace with all six tiles working at once for 60 s,
recorded in the PR, with pass/fail bars:
- p95 frame time at or below 25 ms (40 fps or better);
- no long task of 200 ms or more after the initial load settles (three of
those in 30 s is what trips the WebGL fallback, so the bar matches the
codebase's own threshold);
- JS heap within ±10% between minute 1 and minute 10 of a ten-minute run
(no growth from tile churn: add and remove tiles and zoom a few times in
between);
- the initial load of six tiles completes with the server's event loop never
blocked for more than one capture at a time (check `Server-Timing` on each
`/terminal` response).
Missing a bar blocks the merge or lowers the tile cap, not the bar.
- A server restart while typing into a tile: reconnect, refresh, no lost or
doubled input.
- A phone opened on one tiled session: the desktop tile keeps its size while
active.
## Follow-ups (not in these PRs)
1. Make `captureActivePaneBuffer` async (`execFile`) and add a server-side limit
on concurrent captures, so no client can stall the event loop with captures.
2. When a session's PTY size changes, send `zc` to EVERY socket on that
session. Today only the resizing socket hears back, so a second desktop
viewer keeps a stale width and renders garbled output (#464).
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
on drain) for grids over slow links.
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
a "Load full history" action inside a tile.
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
6. Named grid presets, possibly per owner on the server.
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
which removes the singleton from `terminal-ui.js`.
## Decisions
1. **Clicking a tab that is not tiled.** Decided: a user-initiated pick
leaves the grid and shows that session in the single
view; the grid is kept for one-click return. App-driven selections never
leave it either way. Alternative: swap that session into the focused tile.
2. **The split pane.** Decided: keep it alongside the grid for now; the two
share `TerminalTile` and never open together.
3. **Persistence.** Decided: per device, restored on reload.
4. **PR shape.** Decided: two PRs. PR 1 is the tile foundation (the split
improves on its own), PR 2 is the grid.
5. **Ctrl+W.** Decided: it never closes a session. Close Session has no
default key (Ctrl+W is delete-word in every shell and agent CLI, and it
killed sessions with no confirm); it stays bindable in App Settings →
Shortcuts.
## Code anchors
Line numbers are approximate (as of 1.35.0) and drift; the names are stable.
| Area | Where |
|---|---|
| Split pane class and orchestration | `src/web/public/terminal-split.js` (`SplitTerminalPane`, `openSplitPane`, `closeSplitPane`, `_installSplitDividerDrag`, the `selectSession` and `_onSessionDeleted` wrappers) |
| Split helpers | `src/web/public/constants.js` ~1592-1634 (`SPLIT_PANE_MIN_WIDTH`, `clampDividerPercent`, `buildSplitPickerSessions`), exported as `window.CodemanSplitPane` |
| `selectSession` | `src/web/public/app.js` ~7844-8644; early return ~7868-7876; deferred panels ~8452-8523 |
| `_cleanupPreviousSession` | `app.js` ~7377 |
| Reliable input | `app.js` ~3558-3920 (`_sendInputAsync`, `_reliableSend`, `_nextSeq`, `_drainSession`, `_ackDelivery`, `_onWsInputAck`, `_redeliverSweep`); main socket URL ~3341 |
| SSE terminal fallback | `app.js` `_onSessionTerminal` ~2192, `_onSessionNeedsRefresh` ~2916, `_onSessionClearTerminal` ~3020 |
| Geometry | `terminal-ui.js` `syncTerminalGeometry` ~5894, `sendResize` ~5981, `_onPtyGeometryReport` ~6072, `throttledResize` ~1292-1429; `constants.js` `reconcilePtyGeometry` ~1850 |
| Link provider | `terminal-ui.js` `registerFilePathLinkProvider` ~1831 |
| Main key handler | `terminal-ui.js` ~552-718 |
| Shortcut registry | `app.js` `DEFAULT_SHORTCUTS` ~406-557, `SHORTCUT_ACTIONS` ~1248, capture handler ~1263-1363 |
| Status classifier | `app.js` `_sidebarRichRow` ~5058; `mobile-overview.js` `_mobileOverviewState` ~130; dot CSS `.home-sessions-dot--*` in styles.css |
| Session action menu | `tab-rail-resize.js` `openTabRailActionMenu` ~318 |
| Tab drag | `app.js` `setupTabDragHandlers` ~7137 |
| Tab-group menu | `app.js` `openTabGroupMenu` ~6737 |
| Image paste | `image-input.js` `_handleImagePaste` ~53, `_uploadAndInsertImages` ~130 |
| Voice target | `voice-input.js` `start` ~666, `_insertText` ~962 |
| WS route and caps | `src/web/routes/ws-routes.ts` (`MAX_WS_PER_SESSION`, frame handling), `src/web/ws-connection-registry.ts` |
| Resize arbitration | `src/session.ts` `resize` / `claimDesktopSizing` ~4339-4426 |
| Terminal capture | `src/web/routes/session-routes.ts` `GET /api/sessions/:id/terminal` ~2986; `src/tmux-manager.ts` `captureActivePaneBuffer` ~3825 |
| Attach | `session-routes.ts` `POST /api/sessions/:id/interactive` ~1566 |
+1 -1
View File
@@ -189,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
## Lineage ## Lineage
A create request can name the session that spawned it, through a body field or a header, and A create request can name the session that spawned it, through a body field or a header, and
the dashboard then draws a lineage line from parent to child. The skill sets it automatically. the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
failing the spawn, because a cosmetic field must never break a worker. failing the spawn, because a cosmetic field must never break a worker.
+4 -1
View File
@@ -9,13 +9,16 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
| Shortcut | Action | | Shortcut | Action |
| ------------------------------- | --------------------------------------------------------------- | | ------------------------------- | --------------------------------------------------------------- |
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. | | `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
| `Ctrl+W` | Kill the active session. |
| `Ctrl+Tab` | Next session. | | `Ctrl+Tab` | Next session. |
| `Alt+[` / `Alt+]` | Previous / next tab. | | `Alt+[` / `Alt+]` | Previous / next tab. |
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. | | `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. | | `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. | | `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
use it to delete the previous word. **Close Session** has no key by default; close a session
from its tab, or bind a key to it in App Settings → Shortcuts.
## Terminal ## Terminal
| Shortcut | Action | | Shortcut | Action |
+1 -1
View File
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
enforces it. enforces it.
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead. - The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
- The desktop home tab rail, which needs a wide window. - The desktop home tab rail, which needs a wide window.
- Lineage lines, which are a desktop overlay. - Lineage arcs, which are a desktop overlay.
## Gotchas ## Gotchas
+1 -1
View File
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
| To do this | Do that | | To do this | Do that |
| ------------------------- | ------------------------------------------------------------------- | | ------------------------- | ------------------------------------------------------------------- |
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. | | Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
| Close one session | `Ctrl+W`, or the tab's close control. | | Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. | | Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
| Stop everything | `tmux -L codeman kill-server`. | | Stop everything | `tmux -L codeman kill-server`. |
+1 -1
View File
@@ -89,7 +89,7 @@ every session or only the active tab.
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. | | Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
| Tall Tabs | Taller tab strip. | | Tall Tabs | Taller tab strip. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. | | Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. | | Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). | | Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Overview Home Screen | The phone home screen. On by default. | | Overview Home Screen | The phone home screen. On by default. |
+5 -10
View File
@@ -64,7 +64,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) | | Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` | | Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` | | Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
| Close | `Ctrl+W` | | Close | The tab's close control (no key by default) |
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) | | Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
Tabs can also be dragged to reorder. Tabs can also be dragged to reorder.
@@ -84,18 +84,13 @@ title is derived locally from the prompt's first sentence; no text leaves the ma
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable. scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
### Lineage lines ### Lineage arcs
When one session spawns another (an agent starting a worker through the API), Codeman draws When one session spawns another (an agent starting a worker through the API), Codeman draws
lines from the parent to each child, in the parent's colour, routed through the gaps between a coloured arc under the strip connecting parent to child, with one colour per child. It is
tab rows so they never cover a tab or the terminal. Every family is always shown; selecting how a fan-out of eight workers stays readable.
a tab draws its own family thicker and brighter. A dashed branch means that child is
working. It is how a fan-out of eight workers stays readable.
While any tab has spawned another, the strip keeps a little extra room between rows for the Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Arcs are
lines, so switching tabs never changes the header height.
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Lines are
skipped for tabs scrolled out of the strip. skipped for tabs scrolled out of the strip.
## Header controls ## Header controls
+5 -10
View File
@@ -22,18 +22,13 @@ transcript: what it was asked to do, what it is doing, and what it returned.
This is the feature that makes a fan-out legible. Without it, a lead session that spawned This is the feature that makes a fan-out legible. Without it, a lead session that spawned
eight workers looks like a stalled terminal for several minutes. eight workers looks like a stalled terminal for several minutes.
## Session lineage lines ## Session lineage arcs
The tab strip draws lines from every tab to the tabs it spawned. That covers the other The tab strip draws a coloured arc from a parent tab to any tab it spawned, one colour per
direction of fan-out: not subagents inside one session, but whole sessions started by an child. That covers the other direction of fan-out: not subagents inside one session, but
agent through the API. whole sessions started by an agent through the API.
The lines form one tree per spawning tab, in that tab's colour, and run only through the Desktop only, on by default, and toggled in **App Settings → Appearance**. Arcs are skipped
gaps between tab rows, so they never cover a tab name or the terminal. Select a tab and its
family (the tabs it spawned, or its parent and siblings) is drawn thicker and brighter. A
dashed branch means that child is working.
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
for tabs scrolled out of view. for tabs scrolled out of view.
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
+88 -30
View File
@@ -419,7 +419,14 @@ const DEFAULT_SHORTCUTS = [
id: 'close-session', id: 'close-session',
group: 'Session', group: 'Session',
label: 'Close Session', label: 'Close Session',
bindings: [{ modifiers: ['ctrl'], key: 'w' }], // ⚠️ No default key. This used to be Ctrl+W, which is "delete the previous
// word" in every shell, readline prompt and agent CLI, so muscle memory
// killed the session (tmux and the CLI, with no confirm) mid-sentence, and
// with the split open it was not even the pane being typed in. Ctrl+W now
// reaches the terminal like any other key. Closing stays on the tab's close
// control and menu (with their confirm), and anyone who wants a key binds
// one in App Settings → Shortcuts.
bindings: [],
action: 'killActiveSession', action: 'killActiveSession',
}, },
{ {
@@ -867,6 +874,11 @@ class CodemanApp {
// writes when the freshly computed descriptor is identical (COD-136). // writes when the freshly computed descriptor is identical (COD-136).
this._lastIndicatorDescriptor = null; this._lastIndicatorDescriptor = null;
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
// session they are bound to: the split pane's second terminal registers its
// socket here so its input rides the same exactly-once queue. Values are
// `{ ws, lastRecvAt }` handles owned by that terminal (see _inputSocketFor).
this._extraInputSockets = new Map();
this._persistReliableTimer = null; this._persistReliableTimer = null;
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
@@ -3397,7 +3409,7 @@ class CodemanApp {
} else if (msg.t === 'ia') { } else if (msg.t === 'ia') {
// Input ACK — the server applied (or deduped) this seq; drop it from // Input ACK — the server applied (or deduped) this seq; drop it from
// the durable queue so it can never be re-delivered/lost. // the durable queue so it can never be re-delivered/lost.
this._onWsInputAck(msg.seq, msg); this._onWsInputAck(msg.seq, msg, sessionId);
} else if (msg.t === 'zc') { } else if (msg.t === 'zc') {
// Resize confirm — the geometry the PTY actually holds, which is not // Resize confirm — the geometry the PTY actually holds, which is not
// always the one this client asked for (issue #464). // always the one this client asked for (issue #464).
@@ -3579,6 +3591,49 @@ class CodemanApp {
this._reliableSend(sessionId, input, useMux); this._reliableSend(sessionId, input, useMux);
} }
/**
* The OPEN terminal socket bound to `sessionId`, or null: the primary socket
* first, then one registered by a second terminal (the split pane). The
* delivery layer below asks this instead of reading `this._ws` directly, so a
* second terminal's input gets the same exactly-once queue, ACKs and
* half-open detection as the primary's.
*
* Returns `{ ws, lastRecvAt }`: `lastRecvAt` is the socket's last received
* frame, which `_redeliverSweep` reads to tell a dead socket from a slow ACK.
*/
_inputSocketFor(sessionId) {
if (!sessionId) return null;
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
return { ws: this._ws, lastRecvAt: this._wsLastRecvAt };
}
const handle = this._extraInputSockets?.get(sessionId);
if (handle && handle.ws && handle.ws.readyState === WebSocket.OPEN) return handle;
return null;
}
/**
* Register a second terminal's socket for `sessionId` (call from its onopen).
* `handle` is `{ ws, lastRecvAt }`, owned by the caller, which must bump
* `handle.lastRecvAt` on every received frame. Stamped here so a socket that
* just opened does not look silent to the redelivery sweep, which would
* otherwise force-close it as half-open on the first stale record.
*/
_registerInputSocket(sessionId, handle) {
if (!sessionId || !handle) return;
handle.lastRecvAt = Date.now();
if (!this._extraInputSockets) this._extraInputSockets = new Map();
this._extraInputSockets.set(sessionId, handle);
}
/**
* Drop a registration, but only the one `handle` made: a replacement socket
* registers its own handle before the old one's close lands, and that late
* close must not unregister the socket that replaced it.
*/
_unregisterInputSocket(sessionId, handle) {
if (this._extraInputSockets?.get(sessionId) === handle) this._extraInputSockets.delete(sessionId);
}
/** /**
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll * Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue, * reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
@@ -3590,9 +3645,10 @@ class CodemanApp {
*/ */
_sendInputEphemeral(sessionId, input) { _sendInputEphemeral(sessionId, input) {
if (!sessionId || !input) return; if (!sessionId || !input) return;
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) { const sock = this._inputSocketFor(sessionId);
if (sock) {
try { try {
this._ws.send(JSON.stringify({ t: 'i', d: input })); sock.ws.send(JSON.stringify({ t: 'i', d: input }));
return; return;
} catch { } catch {
// socket died mid-send — fall through to a best-effort POST // socket died mid-send — fall through to a best-effort POST
@@ -3646,11 +3702,12 @@ class CodemanApp {
// over the single ordered stream. They stay pending until the server ACKs // over the single ordered stream. They stay pending until the server ACKs
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after // them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady). // the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) { const sock = this._inputSocketFor(sessionId);
if (sock) {
for (const rec of list) { for (const rec of list) {
if (rec.sentAt !== 0) continue; if (rec.sentAt !== 0) continue;
try { try {
this._ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId })); sock.ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
rec.sentAt = Date.now(); rec.sentAt = Date.now();
rec.tries++; rec.tries++;
} catch { } catch {
@@ -3671,7 +3728,7 @@ class CodemanApp {
if (!cur || cur.length === 0) break; if (!cur || cur.length === 0) break;
// If the WebSocket came back mid-drain, yield to it (the acked stream) // If the WebSocket came back mid-drain, yield to it (the acked stream)
// so we don't redundantly re-POST what onopen is already re-sending. // so we don't redundantly re-POST what onopen is already re-sending.
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) { if (this._inputSocketFor(sessionId)) {
break; break;
} }
const rec = cur[0]; const rec = cur[0];
@@ -3759,8 +3816,10 @@ class CodemanApp {
* called a duplicate is the mechanism working as designed — the original did * called a duplicate is the mechanism working as designed — the original did
* land — and re-sending it would type the same thing twice. * land — and re-sending it would type the same thing twice.
*/ */
_onWsInputAck(seq, msg) { _onWsInputAck(seq, msg, sessionId = this._wsSessionId) {
const sessionId = this._wsSessionId; // `sessionId` is the session of the socket the ACK arrived on: `{t:'ia'}`
// frames carry none, and with a second terminal's socket in play
// `this._wsSessionId` is no longer the only candidate.
if (!sessionId || !Number.isInteger(seq)) return; if (!sessionId || !Number.isInteger(seq)) return;
if (msg && msg.err) { if (msg && msg.err) {
// Refused for good (e.g. over the size limit): retrying cannot help. // Refused for good (e.g. over the size limit): retrying cannot help.
@@ -3805,19 +3864,20 @@ class CodemanApp {
for (const sessionId of [...this._pendingDeliveries.keys()]) { for (const sessionId of [...this._pendingDeliveries.keys()]) {
const list = this._pendingDeliveries.get(sessionId); const list = this._pendingDeliveries.get(sessionId);
if (!list || list.length === 0) continue; if (!list || list.length === 0) continue;
const isActiveWs = const sock = this._inputSocketFor(sessionId);
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId; if (sock) {
if (isActiveWs) {
const oldest = list[0]; const oldest = list[0];
// Only tear the socket down when the oldest unacked frame is stale AND the // Only tear the socket down when the oldest unacked frame is stale AND the
// socket has been silent for the timeout: a connection still delivering // socket has been silent for the timeout: a connection still delivering
// output/ACKs is alive (the ACK is just behind), so force-closing it would // output/ACKs is alive (the ACK is just behind), so force-closing it would
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet. // cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
// Silence is measured on THIS socket: the primary's last frame says
// nothing about a second terminal's connection, and the reverse.
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs; const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
const silent = Date.now() - this._wsLastRecvAt > this._reliableAckTimeoutMs; const silent = Date.now() - (sock.lastRecvAt || 0) > this._reliableAckTimeoutMs;
if (stale && silent) { if (stale && silent) {
try { try {
this._ws.close(); // half-open: never recovers on its own — force reconnect sock.ws.close(); // half-open: never recovers on its own — force reconnect
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -4528,6 +4588,10 @@ class CodemanApp {
// a give-up) nothing else would re-establish it from here. // a give-up) nothing else would re-establish it from here.
if (this._wsSessionId !== restoreId) this._connectWs(restoreId); if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
void this._onSessionNeedsRefresh({ id: restoreId }); void this._onSessionNeedsRefresh({ id: restoreId });
// The split pane's second terminal reconnects on its own backoff (up to
// 10 s between tries); the server is back now, so skip the wait. A no-op
// while its socket is open, stopped for good, or destroyed.
this._splitPane?.reconnectNow?.();
} else { } else {
this.activeSessionId = null; this.activeSessionId = null;
// `auto`: the app is restoring a session on load, not a human opening // `auto`: the app is restoring a session on load, not a human opening
@@ -5241,9 +5305,6 @@ class CodemanApp {
// strip scrolls horizontally, so a tab selected from the palette, a swipe, // strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen. // Alt+N or a push notification could stay parked off-screen.
this._scrollActiveTabIntoView(sessionId); this._scrollActiveTabIntoView(sessionId);
// Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a
// selection change is a redraw whenever any lineage exists at all.
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
} }
/** /**
@@ -5636,15 +5697,13 @@ class CodemanApp {
this._refreshHomeSessionsIfVisible?.(); this._refreshHomeSessionsIfVisible?.();
// The full-render path already redraws the connection SVG; this incremental // The full-render path already redraws the connection SVG; this incremental
// one does not, and a badge appearing widens a tab and shifts every tab after // one does not, and a badge appearing widens a tab and shifts every tab after
// it, sliding the lineage lines off their anchors. Only pay for it when there // it, sliding the lineage arcs off their anchors. Only pay for it when there
// is something anchored to tab rects: any lineage at all (not just what is // is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
// drawn now: a parentSessionId or a child's working state can arrive on this // (sidebar, where lineage is skipped and the edge count stays 0, or the
// path and change the selected family's tree), or, in a VERTICAL list (the // rail, which can show connectors with zero lineage edges too) — the
// sidebar, where lineage is skipped, or the rail, which can show connectors // subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
// with zero lineage edges too), the subagent/ultracode connectors, whose // Same widening as the strip-scroll listener in session-lineage.js.
// rows a badge changes the HEIGHT of. updateTabOverflowMode() above has just if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
// refreshed _lineageTotalEdges.
if (this._lineageTotalEdges > 0 || this._isVerticalTabList()) this.updateConnectionLines();
this.applySidebarFilter(this._sidebarFilter); this.applySidebarFilter(this._sidebarFilter);
} }
@@ -5656,10 +5715,6 @@ class CodemanApp {
const container = this.$('sessionTabs'); const container = this.$('sessionTabs');
if (!container) return; if (!container) return;
// Lineage routing room (session-lineage.js) changes the strip's padding and
// row gap, so it is decided before the wrap is measured below.
this._syncLineageGutter?.();
// The sidebar list is a single vertical column with its own scroller — // The sidebar list is a single vertical column with its own scroller —
// there is no row to overflow, and measuring it would fight the CSS. // there is no row to overflow, and measuring it would fight the CSS.
if (this.isSessionSidebarActive()) { if (this.isSessionSidebarActive()) {
@@ -9086,6 +9141,9 @@ class CodemanApp {
const fmtBindings = (s) => { const fmtBindings = (s) => {
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / '); if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
if (!s.bindings) return ''; if (!s.bindings) return '';
// An action with no key (Close Session by default) is still listed, so the
// overlay says so instead of showing an empty key column.
if (s.bindings.length === 0) return '<span class="shortcut-overlay-unbound">not bound</span>';
return s.bindings.map((b) => { return s.bindings.map((b) => {
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || '']; const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`; return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
+118 -231
View File
@@ -296,251 +296,139 @@ function computeTabScrollLeft(input) {
return Math.min(Math.max(Math.round(target), 0), maxScroll); return Math.min(Math.max(Math.round(target), 0), maxScroll);
} }
// Session lineage lines: geometry for the lines joining a tab to the tabs it // Session lineage lines — geometry for the arc drawn between a tab and a tab it
// spawned (a worker started through the codeman agent skill, which passes its own // spawned (a worker started through the codeman agent skill, which passes its own
// id as parentSessionId). Pure: the caller measures and appends, this decides. // id as parentSessionId). Pure: the caller measures and appends, this decides.
// //
// ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized // ONE shape, because both endpoints live in the same horizontal strip and the subagent
// (session-lineage.js). Every route starts at the PARENT and ends at one child, so // shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
// a parent's routes share their first stretch exactly: overlaid, they read as one // strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
// trunk with a branch per child, and a dashed (working) route stays in phase with // bracket joining two tabs rather than as a line crossing them. The dip grows with
// its siblings along the shared part. // horizontal distance and with `depth` (the child's index among its siblings), so
// several children of one parent nest instead of overprinting.
// //
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per // ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
// child hanging below the strip, which in a wrapped strip crossed every lower // desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on // child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
// row 3 with ten children, "too confusing"). A route now moves horizontally only // had a 4px span to work with and drew a flat horizontal line inside the row gap
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to // (reported as "they connect already, but the lines are straight and not easy visible"),
// the gap right under it) or along the SPINE, a channel left of every row that // and three siblings drew three of them on top of each other. Aiming BOTH ends at the
// joins the gaps of different rows. styles.css reserves that room // tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last // the same bracket as the flat case: it leaves the parent downward, crosses the lower
// row's gap, and the spine channel on the left of a wrapped strip). // row once, and comes back up under the child. Same formula, no branch.
// //
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the // Returns null when the edge must not be drawn: a missing/degenerate rect, or an
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px // endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
// taller), or siblings in one row would hang their bus at different heights. // scrolled-out tab still HAS a rect — one lying over the logo or the header
// // buttons. Skipping is honest; clamping would point at a tab that isn't there.
// computeLineageTree() returns null when the parent cannot be drawn (missing or // ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is // directions, so treat these numbers as a corridor rather than a dial to crank:
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a // - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
// rect, lying over the logo or the header buttons. Skipping is honest; clamping // the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
// would point at a tab that is not there. // a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
const LINEAGE_CORNER_RADIUS_PX = 10; // - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
// Families drawn together take separate lanes: gap lines this far apart, spines // the FULL row offset stacked, bowing the bracket ~106px into the terminal text
// LINEAGE_SPINE_STEP_PX apart. // (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
const LINEAGE_LANE_STEP_PX = 3.5; // The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
// Every family is drawn at once, and a 12px row gap only fits this many lanes at // bottom when the strip rect is missing or shorter than its tabs), which buys two
// LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them. // things at once: the bow needs no per-row offsets stacked on top, and a same-row
const LINEAGE_MAX_LANES = 3; // arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
const LINEAGE_SPINE_INSET_PX = 6; // drawn through them (the retune's own first draft had exactly that regression).
const LINEAGE_SPINE_STEP_PX = 4; const LINEAGE_DIP_BASE_PX = 14;
// The last row has no row below it; with no strip rect to measure, its gap is const LINEAGE_DIP_PER_PX = 0.06;
// taken to be this deep. const LINEAGE_DIP_MIN_PX = 22;
const LINEAGE_LAST_GAP_PX = 12; const LINEAGE_DIP_MAX_PX = 64;
const LINEAGE_ROW_TOLERANCE_PX = 6; // Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
// apart bled into one thick band instead of reading as three separate lines.
const LINEAGE_SIBLING_STEP_PX = 8;
const LINEAGE_STRIP_TOLERANCE_PX = 4; const LINEAGE_STRIP_TOLERANCE_PX = 4;
// How far the vertical rail's track sits in from the rail's left edge. It has to // How far the vertical bracket sits in from the rail's left edge. It has to
// clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned // clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
// to the window frame. The rail reserves the channel itself // glow, so a track at 6px had half of that glow clipped away and the arc read
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is // as a thin thread pinned to the window frame. The rail reserves the channel
// still clamped to stay left of every endpoint. // itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
// computeLineagePath still clamps the track to stay left of both tabs.
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10; const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
const LINEAGE_VERTICAL_LANE_STEP_PX = 4; const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4; const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled // Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
// (session-lineage.js). Every line leaving one tab shares its colour however many // (session-lineage.js). Every arc leaving one tab shares its colour however many
// workers it spawns; a child that spawns in turn gets its own for the lines below it. // workers it spawns; a child that spawns in turn gets its own for the arcs below it.
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue, // The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
// which every skin block tunes for its own background, so a lone family keeps the // which every skin block tunes for its own background, so a lone arc keeps the
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid // skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on"). // (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
// they ride the same double glow as the blue, which is what keeps them legible over
// terminal text on every skin.
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940']; const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
/** A rect normalized to numbers with its edges and center, or null if unusable. */ function computeLineagePath(input) {
function lineageRect(rect) { const parent = input?.parent;
if (!rect) return null; const child = input?.child;
const left = Number(rect.left); if (!parent || !child) return null;
const top = Number(rect.top);
const width = Number(rect.width);
const height = Number(rect.height);
if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height,
cx: left + width / 2,
cy: top + height / 2,
};
}
/** const pw = Number(parent.width) || 0;
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its const ph = Number(parent.height) || 0;
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's const cw = Number(child.width) || 0;
* gap for everyone in it. const ch = Number(child.height) || 0;
*/ if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
function computeLineageRows(rects) {
const rows = [];
for (const raw of rects || []) {
const r = lineageRect(raw);
if (!r) continue;
const row = rows.find((candidate) => Math.abs(candidate.top - r.top) <= LINEAGE_ROW_TOLERANCE_PX);
if (row) {
row.top = Math.min(row.top, r.top);
row.bottom = Math.max(row.bottom, r.bottom);
} else {
rows.push({ top: r.top, bottom: r.bottom });
}
}
return rows.sort((a, b) => a.top - b.top);
}
/**
* An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
* (never more than half of either segment, so short stems stay short). Repeated and
* collinear points are dropped first, so a degenerate corner draws nothing odd.
*/
function lineagePolylinePath(points, radius) {
const pts = [];
for (const p of points) {
const prev = pts[pts.length - 1];
if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
const before = pts[pts.length - 2];
if (before && prev) {
const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
if (Math.abs(cross) < 0.01) pts.pop();
}
pts.push(p);
}
if (pts.length < 2) return null;
let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
for (let i = 1; i < pts.length - 1; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const [x2, y2] = pts[i + 1];
const lenIn = Math.hypot(x1 - x0, y1 - y0);
const lenOut = Math.hypot(x2 - x1, y2 - y1);
const r = Math.min(radius, lenIn / 2, lenOut / 2);
if (!(r > 0.5)) {
d += ` L ${r1(x1)} ${r1(y1)}`;
continue;
}
const ax = x1 - ((x1 - x0) / lenIn) * r;
const ay = y1 - ((y1 - y0) / lenIn) * r;
const bx = x1 + ((x2 - x1) / lenOut) * r;
const by = y1 + ((y2 - y1) / lenOut) * r;
d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
}
const last = pts[pts.length - 1];
return d + ` L ${r1(last[0])} ${r1(last[1])}`;
}
/**
* Routes from one parent tab to each of its children.
*
* input: { parent, children: [{ id, rect }], strip?, tabs?, orientation?, lane?,
* laneCount?, radius? }. `tabs` is every tab rect in the strip (rows are
* derived from it); `lane`/`laneCount` separate families drawn together.
* Returns { routes: [{ id, points, d, endX, endY }] } or null.
*/
function computeLineageTree(input) {
const parent = lineageRect(input?.parent);
if (!parent) return null;
const strip = lineageRect(input?.strip);
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal'; const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1); const strip = input?.strip;
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0)); const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX; const pLeft = Number(parent.left);
const children = []; const cLeft = Number(child.left);
for (const child of input?.children || []) { const pTop = Number(parent.top);
const rect = lineageRect(child?.rect); const cTop = Number(child.top);
if (rect) children.push({ id: child.id, rect }); if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
}
const tol = LINEAGE_STRIP_TOLERANCE_PX;
const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
const routes = [];
if (orientation === 'vertical') { if (orientation === 'vertical') {
// The rail: one track down the empty left gutter, shared by every sibling. const py = pTop + ph / 2;
if (!inStripY(parent)) return null; const cy = cTop + ch / 2;
const visible = children.filter((c) => inStripY(c.rect)); if (strip && Number(strip.height) > 0) {
if (visible.length === 0) return { routes }; const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left)); const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2; if (py < min || py > max || cy < min || cy > max) return null;
const trackX = Math.min(
base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
);
for (const { id, rect } of visible) {
const points = [
[parent.left, parent.cy],
[trackX, parent.cy],
[trackX, rect.cy],
[rect.left, rect.cy],
];
const d = lineagePolylinePath(points, radius);
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
}
return { routes };
} }
if (!inStripX(parent) || !inStripY(parent)) return null; const stripLeft =
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect)); strip && Number.isFinite(Number(strip.left))
if (visible.length === 0) return { routes }; ? Number(strip.left)
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]); const requestedTrack =
const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol); stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX; const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
// Y of the gap under row i, this family's lane. The offset is clamped so a busy const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
// gap never pushes a lane into the tabs on either side of it. return { d, endX: cLeft, endY: cy, sameRow: false };
const gapUnder = (i) => {
const row = rows[i];
const next = rows[i + 1];
let bottom;
if (next) bottom = next.top;
else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
};
const pRow = rowOf(parent);
const gp = gapUnder(pRow);
const tabLefts = (input?.tabs || []).map(lineageRect).filter(Boolean).map((r) => r.left);
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left), ...tabLefts);
const spineBase = strip ? strip.left : minLeft - LINEAGE_SPINE_INSET_PX * 2;
const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
for (const { id, rect } of visible) {
const cRow = rowOf(rect);
const gc = gapUnder(cRow);
const points =
cRow === pRow
? [
[parent.cx, parent.bottom],
[parent.cx, gp],
[rect.cx, gp],
[rect.cx, rect.bottom],
]
: [
[parent.cx, parent.bottom],
[parent.cx, gp],
[spineX, gp],
[spineX, gc],
[rect.cx, gc],
[rect.cx, rect.bottom],
];
const d = lineagePolylinePath(points, radius);
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
} }
return { routes };
}
function roundPoints(points) { const px = pLeft + pw / 2;
return points.map(([x, y]) => [r1(x), r1(y)]); const cx = cLeft + cw / 2;
if (strip && Number(strip.width) > 0) {
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
if (px < min || px > max || cx < min || cx > max) return null;
}
const pBottom = pTop + ph;
const cBottom = cTop + ch;
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
// sitting above further rows (see the corridor note above the constants).
const span = Math.abs(cx - px);
const stripBottom =
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
? Number(strip.top) + Number(strip.height)
: Number.NEGATIVE_INFINITY;
const baseline = Math.max(pBottom, cBottom, stripBottom);
const dip =
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
depth * LINEAGE_SIBLING_STEP_PX;
const yc = baseline + dip;
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
return { d, endX: cx, endY: cBottom, sameRow };
} }
// One decimal is plenty for a screen-space path and keeps the `d` string short. // One decimal is plenty for a screen-space path and keeps the `d` string short.
@@ -1073,13 +961,12 @@ if (typeof window !== 'undefined') {
plan: planWsReconnect, plan: planWsReconnect,
}; };
window.CodemanLineage = { window.CodemanLineage = {
computeTree: computeLineageTree, computePath: computeLineagePath,
computeRows: computeLineageRows, DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX, DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
LANE_STEP_PX: LINEAGE_LANE_STEP_PX, SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
MAX_LANES: LINEAGE_MAX_LANES,
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX, VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
COLORS: LINEAGE_COLORS, COLORS: LINEAGE_COLORS,
}; };
window.CodemanConnectionLoss = { window.CodemanConnectionLoss = {
@@ -1727,7 +1614,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
if (id === excludeId) continue; if (id === excludeId) continue;
// A detached (popped-out) session's own window already yields its PTY // A detached (popped-out) session's own window already yields its PTY
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) — // size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting // Pane B's TerminalTile._sendResize() has no such check, so letting
// one into the picker put its detached window and Pane B in a fight over // one into the picker put its detached window and Pane B in a fight over
// the same PTY's dimensions. // the same PTY's dimensions.
if (detachedIds?.has?.(id)) continue; if (detachedIds?.has?.(id)) continue;
@@ -1735,7 +1622,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
if (!session) continue; if (!session) continue;
// A session with no PTY attached (exited CLI, a crash-looped session // A session with no PTY attached (exited CLI, a crash-looped session
// whose breaker tripped, a restore that failed to re-attach) has nothing // whose breaker tripped, a restore that failed to re-attach) has nothing
// reading its tmux pane. SplitTerminalPane never does selectSession()'s // reading its tmux pane. TerminalTile never does selectSession()'s
// re-attach POST, so its socket would open onto a pane nothing feeds: // re-attach POST, so its socket would open onto a pane nothing feeds:
// no terminal events, and Session.write() silently drops every keystroke // no terminal events, and Session.write() silently drops every keystroke
// with no ack either way (Pane B sends no `seq`), so the loss is // with no ack either way (Pane B sends no `seq`), so the loss is
+18 -8
View File
@@ -50,8 +50,13 @@ Object.assign(CodemanApp.prototype, {
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown. // Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
// Creates a hidden paste trap, lets the browser paste into it, then inspects // Creates a hidden paste trap, lets the browser paste into it, then inspects
// the result for images. Works on plain HTTP (no Clipboard API needed). // the result for images. Works on plain HTTP (no Clipboard API needed).
_handleImagePaste() { // `target` names the terminal the Ctrl+V came from and its session; both
// default to the primary pane. A second terminal (the split pane) passes its
// own, so text pastes into THAT xterm and images upload to THAT session.
_handleImagePaste(target = {}) {
const self = this; const self = this;
const terminal = target.terminal || this.terminal;
const sessionId = target.sessionId || this.activeSessionId;
// Create a hidden contenteditable div to receive the paste // Create a hidden contenteditable div to receive the paste
const trap = document.createElement('div'); const trap = document.createElement('div');
@@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, {
setTimeout(function() { setTimeout(function() {
if (trap.parentNode) trap.parentNode.removeChild(trap); if (trap.parentNode) trap.parentNode.removeChild(trap);
// Refocus the terminal // Refocus the terminal
if (self.terminal) self.terminal.focus(); if (terminal) terminal.focus();
}, 0); }, 0);
if (imageFiles.length > 0) { if (imageFiles.length > 0) {
self._uploadAndInsertImages(imageFiles); self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
} else { } else {
// No image -- route text through xterm's paste() so bracketed-paste // No image -- route text through xterm's paste() so bracketed-paste
// markers (CSI 200~ ... CSI 201~) survive when the inner application // markers (CSI 200~ ... CSI 201~) survive when the inner application
@@ -106,7 +111,7 @@ Object.assign(CodemanApp.prototype, {
// indistinguishable from typed input, weakening the CLI's // indistinguishable from typed input, weakening the CLI's
// prompt-injection defenses. // prompt-injection defenses.
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : ''; var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
if (text && self.terminal) self.terminal.paste(text); if (text && terminal) terminal.paste(text);
} }
}); });
@@ -126,9 +131,10 @@ Object.assign(CodemanApp.prototype, {
/** Upload a batch and normally insert its paths into the active terminal. /** Upload a batch and normally insert its paths into the active terminal.
* The prompt composer passes `{ insert: false }` so it can put those paths * The prompt composer passes `{ insert: false }` so it can put those paths
* into its textarea instead. Returns successful paths in selection order. */ * into its textarea instead. `options.sessionId` names the session to upload
* to (default: the active one). Returns successful paths in selection order. */
async _uploadAndInsertImages(fileList, options = {}) { async _uploadAndInsertImages(fileList, options = {}) {
const sessionId = this.activeSessionId; const sessionId = options.sessionId || this.activeSessionId;
if (!sessionId) return []; if (!sessionId) return [];
let files = Array.from(fileList || []); let files = Array.from(fileList || []);
@@ -179,8 +185,12 @@ Object.assign(CodemanApp.prototype, {
const paths = results.filter(Boolean); const paths = results.filter(Boolean);
if (paths.length > 0 && options.insert !== false) { if (paths.length > 0 && options.insert !== false) {
// Insert all paths in one shot, space-separated, in selection order. // Insert all paths in one shot, space-separated, in selection order, into
await this.sendInput(paths.join(' ')); // the session the batch was uploaded TO. Not sendInput(): it re-reads
// activeSessionId, and after the awaits above that is whatever tab the
// user switched to mid-upload, so the paths landed in the wrong session.
// Same delivery sendInput() uses (durable queue, useMux for the POST path).
this._sendInputAsync(sessionId, paths.join(' '), { useMux: true });
} }
// Final status: successes, plus any failures / cap so nothing is silent. // Final status: successes, plus any failures / cap so nothing is silent.
+2 -2
View File
@@ -807,7 +807,6 @@
<section class="shortcut-section"> <section class="shortcut-section">
<h4>Session</h4> <h4>Session</h4>
<div class="shortcuts-grid"> <div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div> <div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div> <div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div> <div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
@@ -2153,7 +2152,7 @@
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection"> <div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span> <span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
<span class="set-row-desc">Draw lines from each session to the sessions it spawned. The selected tab's family is drawn thicker.</span> <span class="set-row-desc">Draw a line under the tab strip from a session to the sessions it spawned.</span>
</div> </div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label> <label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
</div> </div>
@@ -3919,6 +3918,7 @@
<script defer src="app.js"></script> <script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script> <script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script> <script defer src="terminal-ui.js"></script>
<script defer src="terminal-tile.js"></script>
<script defer src="terminal-split.js"></script> <script defer src="terminal-split.js"></script>
<script defer src="respawn-ui.js"></script> <script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script> <script defer src="ralph-panel.js"></script>
+80 -210
View File
@@ -1,41 +1,29 @@
/** /**
* @fileoverview Session lineage lines: the tree joining a tab to the tabs it spawned. * @fileoverview Session lineage lines — the arcs joining a tab to the tabs it spawned.
* *
* A session that starts another session (the `codeman` agent skill spawning a worker, * A session that starts another session (the `codeman` agent skill spawning a worker,
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its * which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
* state server-side. This module turns that field into a quiet orthogonal tree, one per * state server-side. This module turns that field into the same kind of glowing
* spawning tab, routed through the gaps between tab rows so it never crosses a label or * connection line the subagent windows use, but tab → tab, so the strip shows at a
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js). * glance which tab spawned which.
*
* EVERY FAMILY IS ALWAYS DRAWN, AND THE SELECTED TAB'S IS EMPHASIZED: the family the
* active tab spawned, and the family it belongs to as a child, get the
* `lineage-family--focus` group (thicker, full opacity, drawn last so nothing covers
* it). Drawing ONLY the selected family was tried first and rejected by the owner
* (2026-10-07: "I wanna see all the connections always"). Selection still has to
* redraw to move the emphasis, which `_updateActiveTabImmediate()` does whenever any
* lineage exists (`_lineageTotalEdges`).
* *
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core * It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls * `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does, * `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
* so every layer shares ONE batched read → write reflow and one tab-rect cache. * so every layer shares ONE batched read → write reflow and one tab-rect cache.
* *
* Constraints that are not obvious from the code: * Two constraints that are not obvious from the code:
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint * - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css * over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone * makes the header `position: fixed; z-index: 1200` and would bury them. The phone
* strip is also a scroller where both endpoints are rarely on screen at once. * strip is also a scroller where both endpoints are rarely on screen at once.
* - The routing room is RESERVED in CSS (`.session-tabs.lineage-tree`), toggled by
* `_syncLineageGutter()` from `updateTabOverflowMode()`. It keys on whether ANY
* family exists, never on the selection, so switching tabs never resizes the header
* (and with it the terminal and the PTY).
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute * - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
* `_applyLineEntrances()` queries, so the draw-in animation and its * `_applyLineEntrances()` queries, so the draw-in animation and its
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free. * negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
* *
* @mixin Extends CodemanApp.prototype via Object.assign * @mixin Extends CodemanApp.prototype via Object.assign
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines) * @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS) * @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings) * @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass) * @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
*/ */
@@ -74,99 +62,49 @@ Object.assign(CodemanApp.prototype, {
applyLineageLineSettings() { applyLineageLineSettings() {
const prev = this._lineageLinesOn; const prev = this._lineageLinesOn;
const next = this._syncLineageLinesEnabled(); const next = this._syncLineageLinesEnabled();
if (prev !== next) { if (prev !== next) this.updateConnectionLines();
// The reserved routing room follows the setting; updateTabOverflowMode()
// re-syncs it and re-measures the wrap with the new padding.
this.updateTabOverflowMode?.();
this.updateConnectionLines();
}
}, },
/** /**
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs * Every parent → child pair worth drawing, with the child's index among its siblings
* widens the row gap, pads the bottom for the last row's gap, and opens the spine * (that index is what nests sibling arcs instead of overprinting them).
* channel on the left of a wrapped strip (styles.css). Called at the top of
* `updateTabOverflowMode()`, so it runs on every tab render, BEFORE the wrap is
* measured.
* *
* Keyed on whether any family exists at all, never on which one is selected: a * Walks `sessionOrder` rather than the sessions Map so sibling depth follows the
* class that followed the selection would grow and shrink the header on every tab * strip's own left-to-right order, which is what the user sees.
* switch, and the header's height is the terminal's height.
*
* Only the header strip routes through reserved gaps. The vertical rail keeps its
* own `--lineage-vertical-gutter`, and the sidebar draws no lineage.
*/
_syncLineageGutter() {
const edges = this._lineageLinesEnabled() ? this._collectLineageEdges() : [];
this._lineageTotalEdges = edges.length;
const strip = document.getElementById('sessionTabs');
if (!strip) return;
const want = edges.length > 0 && !this._isVerticalTabList?.();
if (strip.classList.contains('lineage-tree') !== want) strip.classList.toggle('lineage-tree', want);
},
/**
* Every parent → child pair, in strip order. Walks `sessionOrder` rather than the
* sessions Map so sibling order follows the strip's own left-to-right order, which
* is what the user sees.
*/ */
_collectLineageEdges() { _collectLineageEdges() {
const edges = []; const edges = [];
if (!this.sessions || this.sessions.size < 2) return edges; if (!this.sessions || this.sessions.size < 2) return edges;
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()]; const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
const seenPerParent = new Map();
for (const id of order) { for (const id of order) {
const session = this.sessions.get(id); const session = this.sessions.get(id);
const parentId = session && session.parentSessionId; const parentId = session && session.parentSessionId;
// A parent that is gone (closed, or never came back after a restart) draws // A parent that is gone (closed, or never came back after a restart) draws
// nothing: the field is decoration, so a dangling one is simply not rendered. // nothing: the field is decoration, so a dangling one is simply not rendered.
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue; if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
edges.push({ parentId, childId: id, status: session.status || 'idle' }); const depth = seenPerParent.get(parentId) || 0;
seenPerParent.set(parentId, depth + 1);
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
} }
return edges; return edges;
}, },
/** Every family as `{ parentId, edges }`, in strip order of first appearance. */
_lineageFamilies(edges) {
const families = new Map();
for (const edge of edges) {
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
families.get(edge.parentId).push(edge);
}
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
},
/** /**
* Parent ids of the families the selection emphasizes: the family the selected tab * Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
* spawned, and the family it was spawned into (its parent plus its siblings). Empty
* when a web tab holds the stage, or the selected tab has no lineage at all.
*/
_lineageFocusParents(edges) {
const parents = new Set();
const focus = this.activeWebviewId ? null : this.activeSessionId;
if (!focus) return parents;
for (const edge of edges) {
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
}
return parents;
},
/**
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
* *
* ⚠ Per PARENT, not per child: every line leaving one tab is the same colour, no * ⚠️ Per PARENT, not per child: every arc leaving one tab is the same colour, no
* matter how many workers it spawns, so the strip reads as "these five came from * matter how many workers it spawns, so the strip reads as "these five came from
* w1, those two came from w2". Keying it per child instead gave one tab's own * w1, those two came from w2". Keying it per child instead gave one tab's own
* children a different colour each, which is the thing the colours exist to tell * children a different colour each, which is the thing the colours exist to tell
* apart. A child that goes on to spawn its own workers is a parent in its turn and * apart. A child that goes on to spawn its own workers is a parent in its turn and
* gets its own colour for the lines BELOW it, so a chain changes colour at each * gets its own colour for the arcs BELOW it, so a chain changes colour at each
* generation while each generation's fan-out stays uniform. * generation while each generation's fan-out stays uniform.
* *
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than * Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
* draw-index keeps a colour stable across re-renders, tab reorders and sibling * draw-index keeps a colour stable across re-renders, tab reorders and sibling
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would * closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
* flicker). The draw pass claims a colour for EVERY family in strip order before it * flicker). An empty string means "no override": the CSS falls back to
* draws anything, so neither the draw order (the selected family goes last) nor
* which family was selected first ever decides who gets which colour. An empty string means "no override": the CSS falls back to
* --session-blue, so the first spawning tab keeps the skin-aware blue. * --session-blue, so the first spawning tab keeps the skin-aware blue.
*/ */
_lineageColorFor(parentId) { _lineageColorFor(parentId) {
@@ -202,20 +140,19 @@ Object.assign(CodemanApp.prototype, {
_appendLineageConnectionLines(svg, rects) { _appendLineageConnectionLines(svg, rects) {
this._lineageEdgeCount = 0; this._lineageEdgeCount = 0;
if (!svg || !this._lineageLinesEnabled()) return; if (!svg || !this._lineageLinesEnabled()) return;
// Sidebar layout: the tree is routed for a horizontal strip or the vertical // Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
// rail. The sidebar is a vertical list with its own scroller and no reserved // from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
// channel; parent/child adjacency reads fine there without lines. // tab row. Against a vertical list the "strip bottom" is the bottom of the
// sidebar, so every arc would draw a giant loop to the foot of the list.
// Parent/child adjacency reads fine in a vertical list without arcs; a
// sideways lineage shape is a follow-up with its own visual tuning, not a
// by-product of a layout port.
if (this.isSessionSidebarActive?.()) return; if (this.isSessionSidebarActive?.()) return;
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree; const compute = window.CodemanLineage && window.CodemanLineage.computePath;
if (!computeTree) return; if (!compute) return;
const edges = this._collectLineageEdges(); const edges = this._collectLineageEdges();
this._lineageTotalEdges = edges.length;
if (edges.length === 0) return; if (edges.length === 0) return;
// Claim colours in strip order for every family before drawing (_lineageColorFor).
for (const edge of edges) this._lineageColorFor(edge.parentId);
const families = this._lineageFamilies(edges);
const focusParents = this._lineageFocusParents(edges);
if (!rects) rects = new Map(); if (!rects) rects = new Map();
// PHASE 1 — reads. // PHASE 1 — reads.
@@ -225,9 +162,9 @@ Object.assign(CodemanApp.prototype, {
const orientation = const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal'; document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
// A session hidden inside a collapsed group of the grouped rail has no row // A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the line moves to that group's header (a // to anchor to, so its end of the arc moves to that group's header (a
// "proxied" endpoint, drawn quieter). A child proxied to the same header as // "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
// its parent would be a line from a row to itself: skipped. // header would be an arc from a row to itself: skipped.
const resolveEndpoint = (id) => { const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`); const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false }; if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
@@ -236,125 +173,71 @@ Object.assign(CodemanApp.prototype, {
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`); const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header }; return { key: 'group:' + groupId, element: header, proxied: !!header };
}; };
const measure = (endpoint) => { const resolvedEdges = [];
if (!rects.has(endpoint.key)) { for (const edge of edges) {
const parentEndpoint = resolveEndpoint(edge.parentId);
const childEndpoint = resolveEndpoint(edge.childId);
if (parentEndpoint.key === childEndpoint.key) continue;
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
for (const endpoint of [parentEndpoint, childEndpoint]) {
if (rects.has(endpoint.key)) continue;
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null); rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
} }
return rects.get(endpoint.key);
};
const resolvedFamilies = [];
for (const family of families) {
const parentEndpoint = resolveEndpoint(family.parentId);
const parentRect = measure(parentEndpoint);
if (!parentRect) continue;
const children = [];
for (const edge of family.edges) {
const childEndpoint = resolveEndpoint(edge.childId);
if (childEndpoint.key === parentEndpoint.key) continue;
const rect = measure(childEndpoint);
if (rect) children.push({ edge, endpoint: childEndpoint, rect });
}
if (children.length > 0) resolvedFamilies.push({ family, parentEndpoint, parentRect, children });
}
if (resolvedFamilies.length === 0) return;
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
// gap under its tallest tab), web tabs included. The rail needs none.
const tabRects = [];
if (orientation === 'horizontal') {
for (const tab of strip.querySelectorAll('.session-tab')) {
const id = tab.getAttribute('data-id');
const key = id ? 'tab:' + id : null;
if (key && rects.has(key)) tabRects.push(rects.get(key));
else {
const rect = tab.getBoundingClientRect();
if (key) rects.set(key, rect);
tabRects.push(rect);
}
}
} }
this._lineageEdgeCount = resolvedEdges.length;
// PHASE 2 — writes, from the cache only. // PHASE 2 — writes, from the cache only.
// Lanes follow STRIP order, so a family keeps its lane when the selection moves; for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
// only the DRAW order changes (the emphasized families last, on top). A row gap const parentRect = rects.get(parentEndpoint.key);
// fits a few lanes, so they cycle: families that share one are told apart by colour. const childRect = rects.get(childEndpoint.key);
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3); if (!parentRect || !childRect) continue;
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
const drawOrder = resolvedFamilies const geom = compute({
.map((resolved, index) => ({
...resolved,
lane: index % laneCount,
focus: focusParents.has(resolved.family.parentId),
}))
.sort((a, b) => a.focus - b.focus);
for (const { family, parentEndpoint, parentRect, children, lane, focus } of drawOrder) {
const geom = computeTree({
parent: parentRect, parent: parentRect,
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })), child: childRect,
strip: stripRect, strip: stripRect,
tabs: tabRects, depth: edge.depth,
orientation, orientation,
lane,
laneCount,
}); });
if (!geom || geom.routes.length === 0) continue; if (!geom) continue; // scrolled out of the strip, or a degenerate rect
const byId = new Map(children.map((c) => [c.edge.childId, c]));
const color = this._lineageColorFor(family.parentId);
// One group per family: it carries the translucency, so the stretches its
// routes share (the trunk) do not stack into a brighter line than the branches,
// and the emphasis for the selected tab's families (styles.css).
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
group.setAttribute('data-parent-tab', family.parentId);
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
// shared trunk and only the working child's own branch shows its dashes.
const routes = geom.routes
.map((route) => ({ route, child: byId.get(route.id) }))
.filter((r) => r.child)
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
for (const { route, child } of routes) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', route.d); line.setAttribute('d', geom.d);
// `status` is the CHILD's, which is the interesting end: a working child's // The working class marches the dashes, so an active worker is visible along
// route is dashed (and marches, motion permitting). // the line itself. `status` is the CHILD's, which is the interesting end.
const working = child.edge.status === 'working' ? ' lineage-line--working' : ''; const working = edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied; const proxied = parentEndpoint.proxied || childEndpoint.proxied;
line.setAttribute( line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
'class', // The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '') // opacity, glow and dash; an empty colour leaves the --session-blue fallback.
); // Every arc out of one tab shares it — see _lineageColorFor().
// The PARENT's colour rides a CSS custom property so the stylesheet keeps const color = this._lineageColorFor(edge.parentId);
// owning weight and dash; an empty colour leaves the --session-blue fallback.
if (color) line.style.setProperty('--lineage-color', color); if (color) line.style.setProperty('--lineage-color', color);
// `data-agent-id` is what _applyLineEntrances() queries — see the file header. // `data-agent-id` is what _applyLineEntrances() queries — see the file header.
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId); line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
line.setAttribute('data-parent-tab', family.parentId); line.setAttribute('data-parent-tab', edge.parentId);
line.setAttribute('data-child-tab', child.edge.childId); line.setAttribute('data-child-tab', edge.childId);
group.appendChild(line); svg.appendChild(line);
}
// Direction marker at each CHILD end, after every path so no stroke covers it. // Direction marker at the CHILD end. A circle rather than an SVG <marker>:
for (const { route, child } of routes) { // markers need a <defs> block and fight the dash pattern.
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(route.endX)); dot.setAttribute('cx', String(geom.endX));
dot.setAttribute('cy', String(route.endY)); dot.setAttribute('cy', String(geom.endY));
// Fallback radius only: styles.css sizes the dot through `--lineage-dot-r` // Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// (larger in an emphasized family), and `lineage-dot-pulse` breathes from it. // works, so the two have to be changed together.
dot.setAttribute('r', '2.5'); dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : '')); dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', child.edge.childId); dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color); if (color) dot.style.setProperty('--lineage-color', color);
group.appendChild(dot); svg.appendChild(dot);
}
svg.appendChild(group);
this._lineageEdgeCount += routes.length;
} }
}, },
/** /**
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a * The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
* scroll moves both endpoints without firing any render, so the lines would slide * scroll moves both endpoints without firing any render, so the arcs would slide off
* off their tabs. Passive listener, and the redraw is the normal coalesced one. * their tabs. Passive listener, and the redraw is the normal coalesced one.
* *
* Installed once; the guard also keeps a re-init from stacking listeners. * Installed once; the guard also keeps a re-init from stacking listeners.
*/ */
@@ -365,24 +248,11 @@ Object.assign(CodemanApp.prototype, {
this._lineageScrollHandler = () => { this._lineageScrollHandler = () => {
// Sidebar layout and the vertical rail scroll the SAME element // Sidebar layout and the vertical rail scroll the SAME element
// vertically, and there the subagent/ultracode connectors anchor to tab // vertically, and there the subagent/ultracode connectors anchor to tab
// rects too (the sidebar skips lineage entirely, and the rail can show // rects too (the sidebar skips lineage arcs entirely, and the rail can
// connectors with zero lineage edges, so _lineageEdgeCount alone would // show connectors with zero lineage edges, so _lineageEdgeCount alone
// never redraw them). // would never redraw them).
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines(); if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
}; };
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true }); strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
// ⚠ A SELECTION RESIZES TABS AFTER THE REDRAW. The active tab reveals its gear
// and close icons by transitioning their padding (styles.css), so it keeps
// widening for ~150ms after `_updateActiveTabImmediate()` has already redrawn,
// and the tab it was selected from shrinks. That can move tabs or re-wrap a row,
// which left a family's trunk hanging under the tab's OLD position. Redraw once
// a size transition inside the strip ends (coalesced, like every other redraw).
this._lineageTransitionHandler = (event) => {
const prop = event.propertyName || '';
if (!(prop === 'width' || prop === 'max-width' || prop.startsWith('padding'))) return;
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
};
strip.addEventListener('transitionend', this._lineageTransitionHandler);
}, },
}); });
+68 -88
View File
@@ -596,22 +596,6 @@ body {
max-height: var(--tab-strip-max-height, 40vh); max-height: var(--tab-strip-max-height, 40vh);
} }
/* Lineage routing room (`_syncLineageGutter()` in session-lineage.js, geometry in
computeLineageTree, constants.js). Present only while some tab has spawned
another, and never toggled by the selection, so switching tabs cannot resize
the header (and with it the terminal). The bottom padding is the last row's
gap; a wrapped strip also widens the row gap the routes run in, and opens the
spine channel on the left that joins one row's gap to another's. */
.session-tabs.lineage-tree {
padding-bottom: 14px;
}
.session-tabs.lineage-tree.tabs-two-rows,
.session-tabs.lineage-tree.tabs-auto-wrap {
row-gap: 12px;
padding-left: 20px;
}
.session-tabs::-webkit-scrollbar { .session-tabs::-webkit-scrollbar {
width: 4px; width: 4px;
height: 0; height: 0;
@@ -1697,10 +1681,9 @@ html[data-line-anim="packet"] .connection-line.line-enter {
⚠ The 100% frame deliberately omits `opacity`, which makes the browser take ⚠ The 100% frame deliberately omits `opacity`, which makes the browser take
the endpoint from the element's own computed value: a subagent line rests at the endpoint from the element's own computed value: a subagent line rests at
0.9, a lineage line at 1 (its `.lineage-family` group carries the 0.9, a lineage line at 0.72, and a WORKING lineage line at 0.95. Pinning 0.9
translucency), a proxied one at 0.5. Pinning 0.9 here - as `line-enter-fade` here - as `line-enter-fade` above still does - lands every lineage line on the
above still does - lands every lineage line on the wrong opacity and snaps it wrong opacity and snaps it when the class comes off. */
when the class comes off. */
html[data-line-anim="blur"] .connection-line.line-enter { html[data-line-anim="blur"] .connection-line.line-enter {
animation-name: line-enter-blur; animation-name: line-enter-blur;
animation-duration: calc(380ms * var(--anim-enter-scale, 1)); animation-duration: calc(380ms * var(--anim-enter-scale, 1));
@@ -8537,6 +8520,12 @@ kbd {
color: var(--text-dim); color: var(--text-dim);
} }
/* An action with no key bound (Close Session by default): say so, quietly. */
.shortcut-overlay-unbound {
font-size: 0.75rem;
font-style: italic;
}
.shortcut-overlay-footer { .shortcut-overlay-footer {
margin-top: 0.75rem; margin-top: 0.75rem;
padding-top: 0.75rem; padding-top: 0.75rem;
@@ -10682,121 +10671,112 @@ kbd {
} }
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) ===== /* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
One quiet orthogonal tree per spawning tab, every family always drawn and Deliberately quieter and thinner than the subagent lines above so the two
routed through the gaps between tab rows (computeLineageTree in constants.js). layers read as different things in the same SVG.
The SELECTED tab's families (what it spawned, and the family it was spawned
into) are emphasized by `.lineage-family--focus`: thicker, full opacity, a
larger end dot, drawn last (owner call 2026-10-07: see every connection always,
thicker when selected). The routing room itself is reserved on the strip by
`.session-tabs.lineage-tree` near the top of this file.
⚠ QUIET ON PURPOSE (owner call 2026-10-06, "B2"): 1.5px, solid, rounded
corners and a 1px dark outline in place of the old double glow. The old lines
were glowing dashed beziers that hung into the terminal, and with ten children
across a wrapped strip they read as a tangle. The lines no longer cross text,
so they no longer need a glow to survive it. Dashes now MEAN something: only a
working child's route is dashed.
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
per path and dot from the CodemanLineage.COLORS palette (per SPAWNING tab, per line from the CodemanLineage.COLORS palette (per child, first-seen order,
first-seen order). The first spawning tab gets no override and falls through to owner call 2026-08-15: several connected tabs must get several colours). The
--session-blue, which EVERY skin block already defines and tunes for its own FIRST line gets no override, so it falls through to --session-blue, which EVERY
background. Do not add a per-skin `.lineage-line` override inside the skin block already defines and tunes for its own background; a lone arc therefore
html:not([data-skin="og"]) block: a bare class rule in there resolves to still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
(0,2,1) and would outrank this one from a surprising place. `.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
class rule in there resolves to (0,2,1) and would outrank this one from a
Opacity lives on the family GROUP (`.lineage-family`), not on the paths: a surprising place.
parent's routes overlap exactly along the trunk, and translucent paths would
stack there into a brighter line than the branches. */
.lineage-family {
opacity: 0.75;
}
.lineage-family--focus {
opacity: 1;
}
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
lines in blue that they are better visible"). Violet sits close to the terminal's
own dim foreground and lost contrast the moment it crossed text. Hue therefore no
longer separates this layer from the subagent lines, so the separation rests
entirely on SHAPE (this one hangs under the strip and never reaches a window),
weight and dash: keep those differences intact. Per skin the two are not even the
same blue, since --session-blue is tuned per palette while the subagent rule
hardcodes #3b82f6. */
/* ⚠ QUIETER THAN THE SUBAGENT LINES, NOT INVISIBLE. The first cut ran 2px at 0.55
with a single 5px glow, which reads on a design mock and disappears on a real
1080p desktop: a faint thread over terminal text, exactly what it is drawn on
top of. The weight stays UNDER the subagent lines' 3px so the two layers still
separate, and the second, wider glow is what buys the contrast instead: it lifts
the line off the terminal without thickening it. Dashes scale with the stroke
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
exactly two dash cycles, so it has to move with them. */
.connection-line.lineage-line { .connection-line.lineage-line {
stroke: var(--lineage-color, var(--session-blue, #2b8fd9)); stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
stroke-width: 1.5; stroke-width: 2.5;
stroke-dasharray: none; stroke-dasharray: 5 5;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; opacity: 0.72;
opacity: 1; --line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
--line-glow: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9)); drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
filter: var(--line-glow); filter: var(--line-glow);
} }
/* The selected tab's families: thicker (the group above makes them opaque). */ /* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
.lineage-family--focus .connection-line.lineage-line { the line exists to signal, and pairing the brightness with the marching dashes
stroke-width: 2.5; left every worker's arc at the resting 0.72 for anyone who turns motion off. */
}
/* A working child's route is dashed even with motion turned off; the march
below is the extra. Routes start at the PARENT, so dashes on the shared trunk
stay in phase between working siblings. */
.connection-line.lineage-line--working { .connection-line.lineage-line--working {
stroke-dasharray: 4 3; opacity: 0.95;
}
.connection-line.lineage-line:hover {
opacity: 1;
stroke-width: 3;
} }
/* The radius rides a variable so the pulse below can breathe from whichever size
the dot rests at; the `r` attribute session-lineage.js sets is only a fallback. */
.lineage-line-dot { .lineage-line-dot {
--lineage-dot-r: 2.5px;
r: var(--lineage-dot-r);
fill: var(--lineage-color, var(--session-blue, #2b8fd9)); fill: var(--lineage-color, var(--session-blue, #2b8fd9));
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9)); opacity: 0.85;
filter: drop-shadow(0 0 4px var(--lineage-color, var(--session-blue, #2b8fd9)))
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
} }
.lineage-family--focus .lineage-line-dot { /* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
--lineage-dot-r: 3.5px;
}
/* A line re-anchored to a collapsed group's header (its row is hidden): quieter
and dotted, so it reads as "somewhere in there" rather than a real row. */ and dotted, so it reads as "somewhere in there" rather than a real row. */
.connection-line.lineage-line--proxied { .connection-line.lineage-line--proxied {
stroke-dasharray: 1 5; stroke-dasharray: 2 7;
opacity: 0.5; opacity: 0.46;
} }
.connection-line.lineage-line--proxied.lineage-line--working { .connection-line.lineage-line--proxied.lineage-line--working {
opacity: 0.65; opacity: 0.62;
} }
.lineage-line-dot.lineage-line-dot--proxied { .lineage-line-dot.lineage-line-dot--proxied {
opacity: 0.5; opacity: 0.46;
} }
/* The working route marches from parent to child, so the line itself carries /* The child end marches while that worker is actually working, so the line
the signal. Motion is opt-out-able at the OS level. */ itself carries the signal. Motion is opt-out-able at the OS level. */
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.connection-line.lineage-line--working { .connection-line.lineage-line--working {
animation: lineage-flow 1.1s linear infinite; animation: lineage-flow 1.1s linear infinite;
} }
.lineage-line-dot--working { .lineage-line-dot--working {
opacity: 1;
animation: lineage-dot-pulse 1.4s ease-in-out infinite; animation: lineage-dot-pulse 1.4s ease-in-out infinite;
} }
} }
/* Two full dash cycles, so the march loops seamlessly. Tied to the working /* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray`
`stroke-dasharray` above: at `4 3` the cycle is 7px, so this is -14. Leaving above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that
them out of step makes the dashes jump once per iteration. */ matched the old `4 4`. Leaving them out of step makes the dashes jump once per
iteration. */
@keyframes lineage-flow { @keyframes lineage-flow {
to { to {
stroke-dashoffset: -14; stroke-dashoffset: -20;
} }
} }
/* Breathes from the dot's own resting radius (`--lineage-dot-r`). */
@keyframes lineage-dot-pulse { @keyframes lineage-dot-pulse {
0%, 100% { 0%, 100% {
opacity: 0.75; opacity: 0.75;
r: var(--lineage-dot-r); r: 3.5;
} }
50% { 50% {
opacity: 1; opacity: 1;
r: calc(var(--lineage-dot-r) + 0.8px); r: 4.5;
} }
} }
+10 -634
View File
@@ -1,640 +1,16 @@
// src/web/public/terminal-split.js // src/web/public/terminal-split.js
/** /**
* @fileoverview SplitTerminalPane — a second, independent live terminal pane * @fileoverview Split-pane orchestration: opens a second live session
* ("Pane B") for split-view sessions. Deliberately plainer than the primary * ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no * a draggable divider, a session picker, and auto-collapse when either
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only * session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
* feature by nature — see docs/split-pane-sessions-plan.md.
* *
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency terminal-tile.js (window.TerminalTile)
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) * @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight) * @loadorder 7.5 of 16, loaded after terminal-tile.js and before respawn-ui.js
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
*/ */
(function (global) {
// How long a scroll-to-top history pull may hold Pane B's live output.
const HISTORY_PULL_TIMEOUT_MS = 10000;
/**
* Minimal chunked write for Pane B's own xterm instance — write() in
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
* giant synchronous write that blocks the main thread while parsing a long
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
* (terminal-ui.js): that one is wired into session-switch generation
* counters and the live-output gate this simpler, independently
* created/destroyed pane has no equivalent of.
*/
function writeChunked(terminal, buffer, isDestroyed) {
if (!buffer) return Promise.resolve();
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
terminal.write(buffer);
return Promise.resolve();
}
// Resolves once the LAST chunk is written (or the pane was destroyed
// mid-replay), so _loadBuffer() below can hold its single-flight flag
// across the whole replay rather than just the fetch that precedes it.
return new Promise((resolve) => {
let offset = 0;
const writeNext = () => {
if (isDestroyed() || !terminal) {
resolve();
return;
}
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
offset += chunk.length;
terminal.write(chunk);
if (offset < buffer.length) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
else setTimeout(writeNext, 16);
} else {
resolve();
}
};
writeNext();
});
}
class SplitTerminalPane {
constructor(sessionId, mountEl, opts = {}) {
this.sessionId = sessionId;
this.mountEl = mountEl;
this.sessionMode = opts.mode;
this.fontSettings = opts.fontSettings || {};
// Live reference (not a snapshot) to the app's detachedSessions Set —
// detaching this session AFTER the split is already open must still be
// seen by _sendResize() below, or it re-creates the exact PTY-size
// fight the split picker already refuses to open at pick time.
this.detachedSessions = opts.detachedSessions;
this.terminal = null;
this.fitAddon = null;
this.ws = null;
this._wsReady = false;
this._wsClosed = false;
this._destroyed = false;
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
this._bufferLoading = false;
this._bufferRefreshPending = false;
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
// `_liveQueue` is non-null from the pull's response until its finally
// block: live frames are held there with their arrival time instead of
// written under the replay. `_markerOwed` is the "disconnected" marker a
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
this._historyPullAt = 0;
this._historyPullUseless = false;
this._liveQueue = null;
this._markerOwed = false;
this._onWheel = null;
}
async connect() {
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
scrollback: DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.open(this.mountEl);
this.fitAddon.fit();
this._installWheelListener();
this.terminal.onData((data) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ t: 'i', d: data }));
}
});
// Pane B has no gates of its own by default, so every app-level chord
// that the document capture-phase handler (app.js) only preventDefault()s
// — never stopPropagation()s — reaches xterm here too and writes its raw
// byte/escape sequence into THIS session's PTY on top of whatever the app
// action already did to Pane A (COD-153; mirrors the primary pane's own
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
// registry-aware predicates so a rebind or a disable restores plain
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
// default (plain-text paste): Pane B has no image-paste trap to route it
// to, so intercepting it here would only break paste.
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
if (
ev.altKey &&
!ev.ctrlKey &&
!ev.shiftKey &&
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
// swallow — in a plain shell session this is the user's own
// job-control tool and must reach the PTY, but in every other mode
// (claude/omp/pi/codex/...) it silently stops an unattended agent
// loop dead. Pane B has its own PTY/session and must not send a
// suspend into a non-shell one just because the primary pane's own
// gate lives elsewhere.
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey &&
this.sessionMode !== 'shell'
) {
return false;
}
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
// every Enter variant, so an Ink app (Claude Code) can't tell a
// newline from a submit. Without this gate, Pane B's onData would
// send that bare \r straight over the WS and submit an incomplete
// prompt instead of adding a line to it. Targets THIS pane's own
// session (this.sessionId), never the primary pane's
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B is deliberately plainer — see the fileoverview).
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
}
return false;
}
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
// selection, Ctrl+C copies THIS pane's own selection instead of
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
// explicit, never-falls-through copy chord, and the predicate above
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
// a branch that assigns c.key only for a couple of special cases
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
// for Ctrl+Shift+C either way — this is not about an accidental
// interrupt byte reaching the PTY (verified live: it does not).
// Gating this whole block on hasSelection() (an earlier draft) meant
// that with no selection Ctrl+Shift+C skipped straight to `return
// true`, silently ceding the keystroke to the BROWSER's own handling
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
// attempt to copy, unlike Pane A, which always intercepts it.
// Re-implemented against this.terminal rather than reusing
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
// and would copy the wrong pane's selection.
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
const raw = this.terminal?.getSelection?.() || '';
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
// Both clean options are read for THIS pane, never the primary one:
// the gutter width comes from this.sessionId's own run mode, and the
// partial-first-line flag from this terminal's own selection range.
// Passing neither left Pane B keeping a margin Pane A dropped, on the
// same split and the same keystroke.
const range = global.app?._normalisedSelectionRange?.(this.terminal);
const selection = isColumnSelection
? raw
: (global.CodemanCopySelection?.clean?.(raw, {
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
firstLinePartial: !!range && range.start.x > 0,
}) ?? raw);
if (selection.trim()) {
ev.preventDefault();
void global.app._copyText?.(selection).then((ok) => {
this.terminal?.clearSelection?.();
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
});
return false;
}
// Nothing worth copying — clear for feedback (a padding-only
// selection cleans to '' and this press still falls through to the
// PTY as 0x03, matching the primary pane's own rule).
if (this.terminal?.hasSelection?.()) {
this.terminal.clearSelection?.();
global.app.showToast?.('Nothing to copy', 'warning');
}
// Ctrl+Shift+C never falls through, even with nothing to copy —
// matches terminal-ui.js's own ev.shiftKey branch.
if (ev.shiftKey) {
ev.preventDefault();
return false;
}
}
return true;
});
// Load existing scrollback before going live. The WS below is
// subscribe-only (ws-routes.ts sends nothing on connect, only future
// 'terminal' events), so without this Pane B stays blank until the
// target session happens to produce new output. It LOOKED
// intermittent rather than always-broken because _sendResize() below
// often nudges the shared session's real tmux window to a new size,
// and tmux repaints its current screen on resize — that repaint was
// getting captured and streamed here, incidentally populating the
// pane. When Pane B's computed dimensions happened to already match
// the session's last-known size, Session.resize() (session.ts) skips
// the resize as a no-op, no repaint fires, and the pane stayed blank.
// The await covers the whole chunked replay, not just the fetch, so a
// live frame from the socket below can never land in the middle of it.
await this._loadBuffer();
if (this._destroyed) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this._wsReady = true;
this._sendResize();
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.t === 'o') {
this._onLiveOutput(msg.d);
} else if (msg.t === 'c') {
this._onLiveClear();
} else if (msg.t === 'r') {
// Server-triggered refresh (SSE backpressure cleared, terminal
// data was dropped). The primary pane routes this to
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
// buffer loader for the same reason connect() does.
this._refreshBuffer();
}
} catch {
/* Malformed frame — ignore, matches primary pane's tolerance. */
}
};
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
// must go false on a drop or fit()/_sendResize() silently no-ops on a
// closed socket per the WebSocket spec (no exception, no log). No
// reconnect logic here — Pane B is deliberately plainer than the
// primary pane (see the fileoverview above); a drop just stops
// resizing until the parent recreates the pane. But onData already
// silently drops keystrokes while _wsReady is false (below), so
// without a visible marker a dropped socket left Pane B looking
// normal while it quietly ate everything typed into it. v1 scope is
// "say so", not reconnect — collapsing the split would lose the
// user's place in Pane B's scrollback for a transient blip.
this.ws.onclose = () => this._onSocketClosed();
this.ws.onerror = () => {
// onclose fires after onerror — cleanup happens there.
};
}
// The socket's close, split out of connect() so the tests can drive it.
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
// written now, it would sit above the output a pull is still holding (flushed
// after it on a skip, a downgrade or a failed fetch), above a refresh's
// replay, or in the middle of a chunked replay. A pull still waiting for its
// response holds the marker too, for as long as the request takes (up to its
// budget, see _pullHistory()).
_onSocketClosed() {
this._wsReady = false;
this._wsClosed = true;
if (this._bufferLoading) this._markerOwed = true;
else this._writeDisconnectedMarker();
}
// Settles a marker the pane owes: set when a close lands during a load (the
// replay would otherwise sit below it) or when a load wipes the terminal on
// a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses
// a write() on a later tick: a marker written here would land in the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
// The refresh re-owes the marker on a closed socket and stamps it itself.
if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed;
this._markerOwed = false;
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a load that ends owing it on a
// closed socket can write it (see _stampMarkerIfOwed()).
_writeDisconnectedMarker() {
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
}
// Fetches and writes the session's current scrollback. Used both by
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
// scoped to `this.activeSessionId` and clears/rewrites the primary
// terminal, neither of which applies to this independent pane, so this is
// a standalone equivalent rather than a call into it.
//
// Mirrors the primary pane's own mode check (app.js's selectSession /
// _onSessionNeedsRefresh): a shell session can retain hundreds of
// thousands of plain scrollback lines, so pulling `?full=1` there parses
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
// get one full replay. `fetch` here goes through the global wrapper
// (constants.js), which already prefixes CodemanBase — unlike the raw
// WebSocket URL above, which does not.
//
// Single-flight: the flag is held across the fetch AND the chunked write
// (writeChunked resolves after its last chunk), so two replays can never
// interleave their chunks into one terminal. A second call while one is
// in flight is dropped here; _refreshBuffer() is the caller that queues
// a trailing re-run instead.
async _loadBuffer() {
if (this._bufferLoading) return;
this._bufferLoading = true;
try {
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
const payload = (await res.json())?.data ?? {};
if (payload.terminalBuffer && this.terminal) {
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
}
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
// Ends a single-flight load (initial, refresh or history pull): clears the
// flag, then runs the ONE trailing refresh that arrived while it was busy.
_endBufferLoad() {
this._bufferLoading = false;
if (this._bufferRefreshPending && !this._destroyed) {
this._bufferRefreshPending = false;
this._refreshBuffer();
}
}
// Live terminal output. Written straight through, except while a history
// pull is replaying: a capture is current only up to the instant tmux took
// it, so a frame arriving mid-replay is held with its arrival time and
// replayed behind the snapshot by _pullHistory() (the primary pane's
// _finishBufferLoad `since` rule), never written underneath it.
_onLiveOutput(data) {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
else this.terminal?.write(data);
}
// The server's `{t:'c'}` clear frame takes the same route as output, for the
// same reason: clearing straight away, mid-replay, would wipe the half-written
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
// one it is restoring. Queued, it lands in order with the frames around it.
_onLiveClear() {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
else this.terminal?.clear();
}
// Capture phase, because xterm's own wheel handler stopPropagation()s every
// event it consumes, so a bubbling listener here would never see the wheel
// while the pane still has scrollback to scroll. Passive: this only observes,
// xterm keeps doing the scrolling.
_installWheelListener() {
this._onWheel = (ev) => {
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
};
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
}
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
// output (`cat` of a file longer than the screen) instead of scrolling it,
// so this pane's xterm ends up with about one screen of scrollback while
// tmux holds every line — and nothing here ever went back to ask, so the
// history was unreachable. The primary pane has the same pull
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
// own. Shell only: a non-shell CLI's history is out of scope for this pull
// (its load already takes `full=1`; codex and Claude's inline renderer do
// grow tmux history, this just isn't how they recover it). The alternate-
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
// tmux the browser xterm never enters the alternate buffer.
_maybeLoadMoreHistory() {
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
if (this._bufferLoading) return;
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
// _sendResize(): a detached session's own window already owns its PTY
// size and scrollback, so Pane B has nothing of its own to reconcile.
if (this.detachedSessions?.has(this.sessionId)) return;
const active = this.terminal.buffer.active;
if (active.type !== 'normal' || active.viewportY !== 0) return;
// Momentum scrolling fires this dozens of times per flick, so cooldown
// rather than latch; a pull that could only have downgraded the pane
// waits far longer.
const cooldown = this._historyPullUseless ? 60000 : 4000;
const now = Date.now();
if (now - this._historyPullAt < cooldown) return;
this._historyPullAt = now;
void this._pullHistory();
}
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
// main thread) and replays it under the reader's current place. Holds the
// single-flight flag across the fetch AND the replay, like _loadBuffer().
async _pullHistory() {
this._bufferLoading = true;
let replayed = false;
let capturedAt = 0;
// Two budgets on one signal. The request itself gets the primary pane's
// (CodemanFetchDeadline, constants.js): live output is not held while it
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
// for that whole budget. Once the headers land live output IS held, so the
// body read gets the short one instead: a body that hangs would otherwise
// freeze the pane for the long budget. Aborting lands in the catch below,
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
// be re-armed, hence the controller; without AbortController the pull
// simply has no deadline.
const controller = global.AbortController ? new global.AbortController() : null;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
clearTimeout(abortTimer);
abortTimer = setTimeout(() => controller.abort(), ms);
};
try {
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
signal: controller?.signal,
});
armDeadline(HISTORY_PULL_TIMEOUT_MS);
// The cutoff below is the response's arrival, the same `since` rule the
// primary pane uses (_finishBufferLoad). It is a client clock standing in
// for the instant tmux took the capture, which lies somewhere in the
// round trip, so a frame in that window can be lost or doubled. Bounded
// by one round trip and not closable without a server-side capture time.
capturedAt = performance.now();
// Opened only now: a frame from before the response is either replaced by
// the capture or written unchanged, so holding it for the round trip
// bought nothing and froze the pane for as long as the fetch took.
this._liveQueue = [];
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer;
const term = this.terminal;
if (!buffer || !term || this._destroyed) return;
const rowsBefore = term.buffer.active.length;
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
// lines, so a window of short lines can carry more rows than this pane
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
const scrollbackCap = term.options?.scrollback || 0;
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
// Nothing to gain (this also covers a downgrade, which would delete
// history mid-scroll), and a reset+rewrite would jump the viewport. An
// untruncated window IS all of tmux's history and the next burst can add
// more, so keep the 4 s cooldown. A truncated window can never reach past
// what the pane shows, and every ask costs the server a capture-pane of
// the whole history (`tail` is cut after it): back off to 60 s, as the
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
// off too, since no window can ever fit in it.
if (rowsIncoming <= rowsBefore || paneFull) {
if (payload.truncated || paneFull) this._historyPullUseless = true;
return;
}
this._historyPullUseless = false;
term.write('\x1bc');
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(term, buffer, () => this._destroyed);
if (this._destroyed || !this.terminal) return;
// xterm parses asynchronously: an empty write's callback fires only
// after everything before it, so the row count below is the settled one.
await new Promise((resolve) => this.terminal.write('', resolve));
if (this._destroyed || !this.terminal) return;
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
// rows down; land there and the recovered history sits above it.
const delta = this.terminal.buffer.active.length - rowsBefore;
if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop();
} catch {
/* Best-effort — live output keeps arriving whatever happens here. */
} finally {
clearTimeout(abortTimer);
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
// earlier ones are already in it. With no replay, every held frame is.
const cutoff = replayed ? capturedAt : 0;
for (const entry of queued) {
if (entry.at < cutoff) continue;
if (entry.clear) this.terminal?.clear();
else this.terminal?.write(entry.data);
}
// Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
// one written before it, which would paint a fresh, current-looking
// history while onData keeps silently dropping every keystroke on the
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
// is left to that refresh, which writes it below its own replay.
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
// frames in a row used to start two concurrent replays, each clearing
// the terminal under the other's chunked write. A refresh that arrives
// mid-replay is COALESCED into one trailing re-run rather than ignored:
// the in-flight fetch may predate the drop the new frame is reporting,
// and no further frame is coming to correct stale content.
_refreshBuffer() {
if (this._bufferLoading) {
this._bufferRefreshPending = true;
return;
}
this.terminal?.clear();
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
// trailing refresh behind a pull that the socket's close then interrupts),
// so a refresh on a closed socket owes it back once its replay is written.
if (this._wsClosed) this._markerOwed = true;
void this._loadBuffer();
}
// Local reflow only — no PTY resize frame. Split out so a divider drag
// can reflow both panes at the browser's paint rate (rAF) while sending
// the actual `{t:'z'}` resize once, at drag end, matching the primary
// pane's own convention (throttledResize in terminal-ui.js).
localFit() {
if (!this.fitAddon) return;
this.fitAddon.fit();
}
fit() {
this.localFit();
this._sendResize();
}
_sendResize() {
if (!this._wsReady || !this.fitAddon) return;
// One PTY cannot hold two sizes (mirrors sendResize's own
// detachedElsewhere yield in terminal-ui.js): the session got detached
// to its own window AFTER this split was opened, so its own window now
// owns the PTY's size and Pane B must stand aside.
if (this.detachedSessions?.has(this.sessionId)) return;
const dims = this.fitAddon.proposeDimensions();
if (!dims) return;
// Send the real proposed dimensions unclamped, matching the primary
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
// A 40/10 floor here misreported Pane B's real width to the PTY at the
// divider's own reachable 20% floor position, causing real
// output-wrapping bugs.
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
}
destroy() {
this._destroyed = true;
if (this._onWheel) {
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
this._onWheel = null;
}
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
// onclose fires asynchronously AFTER close(); without this it ran
// its "disconnected" write against a pane already torn down.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
this.fitAddon = null;
}
}
global.SplitTerminalPane = SplitTerminalPane;
})(window);
Object.assign(CodemanApp.prototype, { Object.assign(CodemanApp.prototype, {
/** /**
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions * Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
@@ -777,7 +153,7 @@ Object.assign(CodemanApp.prototype, {
// B's own session tab while split can otherwise land here with // B's own session tab while split can otherwise land here with
// sessionId === activeSessionId: two live WebSockets to the same // sessionId === activeSessionId: two live WebSockets to the same
// session, each independently claiming PTY dimensions via its own `{t:'z',...}` // session, each independently claiming PTY dimensions via its own `{t:'z',...}`
// resize frame. Refuse before creating any DOM or SplitTerminalPane. // resize frame. Refuse before creating any DOM or TerminalTile.
if (sessionId === this.activeSessionId) return; if (sessionId === this.activeSessionId) return;
// The picker's own exclusions (buildSplitPickerSessions in constants.js), // The picker's own exclusions (buildSplitPickerSessions in constants.js),
// re-applied here: the menu can sit open while a listed session's CLI // re-applied here: the menu can sit open while a listed session's CLI
@@ -818,7 +194,7 @@ Object.assign(CodemanApp.prototype, {
container.appendChild(paneB); container.appendChild(paneB);
paneB.style.flexBasis = '50%'; paneB.style.flexBasis = '50%';
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), { this._splitPane = new window.TerminalTile(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
mode: session?.mode, mode: session?.mode,
fontSettings: this.loadAppSettingsFromStorage?.() || {}, fontSettings: this.loadAppSettingsFromStorage?.() || {},
detachedSessions: this.detachedSessions, detachedSessions: this.detachedSessions,
@@ -900,7 +276,7 @@ Object.assign(CodemanApp.prototype, {
// frame). Coalesced to one call per animation frame below — a raw // frame). Coalesced to one call per animation frame below — a raw
// mousemove stream fires far faster than the browser repaints, and // mousemove stream fires far faster than the browser repaints, and
// without the rAF gate each event did a full xterm reflow on BOTH // without the rAF gate each event did a full xterm reflow on BOTH
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has // panes AND sent Pane B a `{t:'z'}` resize frame (TerminalTile has
// no client-side "dims unchanged" skip), which fanned out into a // no client-side "dims unchanged" skip), which fanned out into a
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty // `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
// of each dragging across half a wide viewport. // of each dragging across half a wide viewport.
+887
View File
@@ -0,0 +1,887 @@
// src/web/public/terminal-tile.js
/**
* @fileoverview TerminalTile: one independent live terminal pane bound to one
* session, with its own xterm instance and its own
* `/ws/sessions/:id/terminal` WebSocket. The split pane (terminal-split.js)
* uses one as its second pane ("Pane B"); the tile grid planned in
* docs/tile-grid-plan.md reuses the same class for every tile.
*
* Deliberately plainer than the primary pane (this.terminal/this._ws in
* terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile
* handlers, no keyboard accessory bar. Desktop-only by nature; see
* docs/split-pane-sessions-plan.md.
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
(function (global) {
// How long a scroll-to-top history pull may hold Pane B's live output.
const HISTORY_PULL_TIMEOUT_MS = 10000;
/**
* Minimal chunked write for Pane B's own xterm instance — write() in
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
* giant synchronous write that blocks the main thread while parsing a long
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
* (terminal-ui.js): that one is wired into session-switch generation
* counters and the live-output gate this simpler, independently
* created/destroyed pane has no equivalent of.
*/
function writeChunked(terminal, buffer, isDestroyed) {
if (!buffer) return Promise.resolve();
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
terminal.write(buffer);
return Promise.resolve();
}
// Resolves once the LAST chunk is written (or the pane was destroyed
// mid-replay), so _loadBuffer() below can hold its single-flight flag
// across the whole replay rather than just the fetch that precedes it.
return new Promise((resolve) => {
let offset = 0;
const writeNext = () => {
if (isDestroyed() || !terminal) {
resolve();
return;
}
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
offset += chunk.length;
terminal.write(chunk);
if (offset < buffer.length) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
else setTimeout(writeNext, 16);
} else {
resolve();
}
};
writeNext();
});
}
class TerminalTile {
constructor(sessionId, mountEl, opts = {}) {
this.sessionId = sessionId;
this.mountEl = mountEl;
this.sessionMode = opts.mode;
this.fontSettings = opts.fontSettings || {};
// Live reference (not a snapshot) to the app's detachedSessions Set —
// detaching this session AFTER the split is already open must still be
// seen by _sendResize() below, or it re-creates the exact PTY-size
// fight the split picker already refuses to open at pick time.
this.detachedSessions = opts.detachedSessions;
this.terminal = null;
this.fitAddon = null;
this.ws = null;
this._wsReady = false;
this._wsClosed = false;
this._destroyed = false;
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
this._bufferLoading = false;
this._bufferRefreshPending = false;
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
// `_liveQueue` is non-null from the pull's response until its finally
// block: live frames are held there with their arrival time instead of
// written under the replay. `_markerOwed` is the "disconnected" marker a
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
this._historyPullAt = 0;
this._historyPullUseless = false;
this._liveQueue = null;
this._markerOwed = false;
this._onWheel = null;
// `{ ws, lastRecvAt }`, registered with the app's input-socket map while
// this pane's socket is open, so the exactly-once input queue delivers this
// session's keystrokes over it (app.js _inputSocketFor). Null otherwise.
this._inputHandle = null;
// Reconnect state. `_socketUrl` is set once connect() opens the first
// socket: a pane that never connected has nothing to reconnect to.
// `_reconnectAttempts` counts consecutive failed opens and is reset ONLY by
// a successful open (resetting it per attempt is the tight-loop bug the
// primary pane's _disconnectWs documents). `_stoppedCode` is the close code
// that ended the pane for good; `onExit(code)` tells the owner once.
this._socketUrl = null;
this._reconnectAttempts = 0;
this._reconnectTimer = null;
this._stoppedCode = null;
this._markerText = TerminalTile.MARKER_RECONNECTING;
this.onExit = typeof opts.onExit === 'function' ? opts.onExit : null;
// The `{ cols, rows }` last sent in a `{t:'z'}` frame, so an unchanged size
// is not resent (each one costs a `tmux resize-window` and a SIGWINCH).
// Cleared on every open: a fresh socket must announce its size, which is
// also what registers it as a desktop viewer server-side.
this._lastSentDims = null;
// Whether the pointer is over a link in THIS pane (the primary pane's own
// flag, app._linkHovered, belongs to its terminal alone).
this._linkHovered = false;
this._onFocusIn = null;
}
async connect() {
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
scrollback: DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.open(this.mountEl);
this.fitAddon.fit();
// File paths and URLs printed here are clickable, through the SAME
// provider as the primary pane (registerFilePathLinkProvider,
// terminal-ui.js), and open against THIS pane's session.
global.app?.registerFilePathLinkProvider?.({
terminal: this.terminal,
getSessionId: () => this.sessionId,
setHovered: (hovered) => {
this._linkHovered = hovered;
},
});
this._installWheelListener();
// Focusing this terminal makes it the pane the keyboard is in, so the
// app-level shortcuts, voice and paste act on it (app._focusedPane).
this._onFocusIn = () => global.app?._noteFocusedTile?.(this);
this.terminal.textarea?.addEventListener('focus', this._onFocusIn);
this.terminal.onData((data) => this._onTerminalData(data));
// Pane B has no gates of its own by default, so every app-level chord
// that the document capture-phase handler (app.js) only preventDefault()s
// — never stopPropagation()s — reaches xterm here too and writes its raw
// byte/escape sequence into THIS session's PTY on top of whatever the app
// action already did to Pane A (COD-153; mirrors the primary pane's own
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
// registry-aware predicates so a rebind or a disable restores plain
// terminal behavior here too. Ctrl+V goes through the primary pane's
// paste trap (image-input.js), aimed at this pane (below).
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
if (
ev.altKey &&
!ev.ctrlKey &&
!ev.shiftKey &&
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
return false;
}
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
// a pasted image uploads to this pane's session and its path is typed
// here, and pasted text goes into this xterm with its bracketed-paste
// markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it
// xterm's default only ever pasted text.
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId });
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
// swallow — in a plain shell session this is the user's own
// job-control tool and must reach the PTY, but in every other mode
// (claude/omp/pi/codex/...) it silently stops an unattended agent
// loop dead. Pane B has its own PTY/session and must not send a
// suspend into a non-shell one just because the primary pane's own
// gate lives elsewhere.
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey &&
this.sessionMode !== 'shell'
) {
return false;
}
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
// every Enter variant, so an Ink app (Claude Code) can't tell a
// newline from a submit. Without this gate, Pane B's onData would
// send that bare \r straight over the WS and submit an incomplete
// prompt instead of adding a line to it. Targets THIS pane's own
// session (this.sessionId), never the primary pane's
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B is deliberately plainer — see the fileoverview).
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
}
return false;
}
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
// selection, Ctrl+C copies THIS pane's own selection instead of
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
// explicit, never-falls-through copy chord, and the predicate above
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
// a branch that assigns c.key only for a couple of special cases
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
// for Ctrl+Shift+C either way — this is not about an accidental
// interrupt byte reaching the PTY (verified live: it does not).
// Gating this whole block on hasSelection() (an earlier draft) meant
// that with no selection Ctrl+Shift+C skipped straight to `return
// true`, silently ceding the keystroke to the BROWSER's own handling
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
// attempt to copy, unlike Pane A, which always intercepts it.
// Re-implemented against this.terminal rather than reusing
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
// and would copy the wrong pane's selection.
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
const raw = this.terminal?.getSelection?.() || '';
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
// Both clean options are read for THIS pane, never the primary one:
// the gutter width comes from this.sessionId's own run mode, and the
// partial-first-line flag from this terminal's own selection range.
// Passing neither left Pane B keeping a margin Pane A dropped, on the
// same split and the same keystroke.
const range = global.app?._normalisedSelectionRange?.(this.terminal);
const selection = isColumnSelection
? raw
: (global.CodemanCopySelection?.clean?.(raw, {
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
firstLinePartial: !!range && range.start.x > 0,
}) ?? raw);
if (selection.trim()) {
ev.preventDefault();
void global.app._copyText?.(selection).then((ok) => {
this.terminal?.clearSelection?.();
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
});
return false;
}
// Nothing worth copying — clear for feedback (a padding-only
// selection cleans to '' and this press still falls through to the
// PTY as 0x03, matching the primary pane's own rule).
if (this.terminal?.hasSelection?.()) {
this.terminal.clearSelection?.();
global.app.showToast?.('Nothing to copy', 'warning');
}
// Ctrl+Shift+C never falls through, even with nothing to copy —
// matches terminal-ui.js's own ev.shiftKey branch.
if (ev.shiftKey) {
ev.preventDefault();
return false;
}
}
return true;
});
// Load existing scrollback before going live. The WS below is
// subscribe-only (ws-routes.ts sends nothing on connect, only future
// 'terminal' events), so without this Pane B stays blank until the
// target session happens to produce new output. It LOOKED
// intermittent rather than always-broken because _sendResize() below
// often nudges the shared session's real tmux window to a new size,
// and tmux repaints its current screen on resize — that repaint was
// getting captured and streamed here, incidentally populating the
// pane. When Pane B's computed dimensions happened to already match
// the session's last-known size, Session.resize() (session.ts) skips
// the resize as a no-op, no repaint fires, and the pane stayed blank.
// The await covers the whole chunked replay, not just the fetch, so a
// live frame from the socket below can never land in the middle of it.
await this._loadBuffer();
if (this._destroyed) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
// The tab's own connection identity plus a `:tile` suffix. The server
// supersedes a socket that reuses a cid on the same session (4010), so a
// pane must never share the primary pane's exact cid: were both ever on
// one session they would evict each other in a loop. Input frames still
// carry the BARE clientId, which is what the server dedups on.
const app = global.app;
const cid = app?._clientId ? `${app._clientId}:${app._wsTabNonce}:tile` : '';
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
this._socketUrl = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal${cidQuery}`;
this._openSocket();
}
// Opens a socket and makes it THE socket. A previous one is detached first
// (handlers nulled, then closed), and every handler below checks it still
// belongs to the current socket: a replacement opened while the old socket
// still looked alive (a half-open connection whose close has not landed)
// makes the server supersede the old one with a 4010, and that late close
// must not stop a pane that is already running on its successor.
_openSocket() {
if (this._destroyed || !this._socketUrl) return;
this._detachSocket();
const ws = new WebSocket(this._socketUrl);
this.ws = ws;
ws.onopen = () => {
if (ws !== this.ws) return;
this._onSocketOpen();
};
ws.onmessage = (event) => {
if (ws !== this.ws) return;
if (this._inputHandle) this._inputHandle.lastRecvAt = Date.now();
try {
const msg = JSON.parse(event.data);
if (msg.t === 'o') {
this._onLiveOutput(msg.d);
} else if (msg.t === 'c') {
this._onLiveClear();
} else if (msg.t === 'r') {
// Server-triggered refresh (SSE backpressure cleared, terminal
// data was dropped). The primary pane routes this to
// _onSessionNeedsRefresh (app.js) — Pane B has its own
// buffer loader for the same reason connect() does.
this._refreshBuffer();
} else if (msg.t === 'ia') {
// Input ACK. The frame names no session, so it is this pane's.
global.app?._onWsInputAck?.(msg.seq, msg, this.sessionId);
} else if (msg.t === 'zc') {
this._onPtyGeometryReport(msg.c, msg.r);
}
} catch {
/* Malformed frame — ignore, matches primary pane's tolerance. */
}
};
// _wsReady must go false on a drop or fit()/_sendResize() silently
// no-op on a closed socket per the WebSocket spec (no exception, no log).
// Input is not lost meanwhile: it waits in the app's durable queue and
// goes out over HTTP or the next socket. The "disconnected" marker says
// so on screen, and a transient close reconnects (_onSocketClosed).
ws.onclose = (event) => {
if (ws !== this.ws) return;
this._onSocketClosed(event);
};
ws.onerror = () => {
// onclose fires after onerror — cleanup happens there.
};
}
// Lets go of the current socket without running its close handling.
_detachSocket() {
const ws = this.ws;
if (!ws) return;
ws.onopen = null;
ws.onmessage = null;
// onclose fires asynchronously AFTER close(); without this it ran its
// "disconnected" write against a pane already torn down or replaced.
ws.onclose = null;
ws.onerror = null;
try {
ws.close();
} catch {
/* Already closed. */
}
this.ws = null;
this._wsReady = false;
this._unregisterInputSocket();
}
// A socket came up. After a drop this is a reconnect: the gap left nothing
// to replay (output frames carry no sequence number), so the buffer is
// refreshed. The closed state is reset FIRST, or the refresh would re-owe
// the "disconnected" marker (_refreshBuffer does on a closed socket) and
// stamp it under a healthy pane.
_onSocketOpen() {
const reconnected = this._wsClosed;
this._wsReady = true;
this._wsClosed = false;
this._markerOwed = false;
this._reconnectAttempts = 0;
this._lastSentDims = null;
this._registerInputSocket();
this._sendResize();
if (reconnected) this._refreshBuffer();
}
// Opens a replacement socket now instead of waiting out the backoff (for an
// owner that just learned the server is back). No-op while the current
// socket is open, after a permanent stop, or once destroyed.
reconnectNow() {
if (this._destroyed || this._stoppedCode !== null || !this._socketUrl) return;
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
this._openSocket();
}
// The socket's close, split out of connect() so the tests can drive it.
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
// written now, it would sit above the output a pull is still holding (flushed
// after it on a skip, a downgrade or a failed fetch), above a refresh's
// replay, or in the middle of a chunked replay. A pull still waiting for its
// response holds the marker too, for as long as the request takes (up to its
// budget, see _pullHistory()).
//
// Then decides what comes next. Codes that cannot get better stop the pane
// for good and report once through `onExit(code)`: 4004/4009 (the session
// is gone), 4003 (refused: Host/Origin/owner, a retry gets the same answer)
// and 4010 (another socket with this pane's cid took over; only ever
// reaches here for the CURRENT socket, see _openSocket). Everything else,
// including the redelivery sweep force-closing a silent socket (1005), is
// transient and reconnects on the primary pane's backoff ladder
// (CodemanWsReconnect, constants.js) plus jitter.
_onSocketClosed(event) {
this._wsReady = false;
this._wsClosed = true;
this._unregisterInputSocket();
const code = event?.code;
const permanent = TerminalTile.STOP_MARKERS[code];
this._markerText = permanent || TerminalTile.MARKER_RECONNECTING;
if (this._bufferLoading) this._markerOwed = true;
else this._writeDisconnectedMarker();
if (this._destroyed) return;
if (permanent) {
this._stop(code);
return;
}
this._scheduleReconnect(code);
}
_scheduleReconnect(code) {
if (this._destroyed || !this._socketUrl || this._reconnectTimer) return;
const plan = global.CodemanWsReconnect?.plan?.(code ?? 1006, this._reconnectAttempts) || {
action: 'reconnect',
delayMs: 1000,
};
if (plan.action === 'give-up') {
this._stop(code);
return;
}
this._reconnectAttempts++;
const delay = plan.delayMs + Math.floor(Math.random() * 250); // jitter: tiles must not reconnect in lockstep
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this._openSocket();
}, delay);
}
_stop(code) {
if (this._stoppedCode !== null) return;
this._stoppedCode = code ?? null;
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
if (!this._destroyed) this.onExit?.(code);
}
// Keystrokes and pastes go through the app's exactly-once input queue (seq,
// ACK, persisted until delivered, redelivered after a drop), over this
// pane's own socket while it is open and the HTTP fallback while it is not.
// What xterm GENERATES must not be queued: a query reply (DA/CPR/OSC) is
// dropped, as the primary pane drops it, because forwarding it types
// "0;276;0c" into the CLI, and replaying one after a reload would do so
// again into a later screen. A focus or mouse report is real input the
// program asked for, but nobody typed it: it goes out once, never
// persisted. Same predicates as the primary pane (terminal-ui.js onData).
_onTerminalData(data) {
const input = global.CodemanTerminalInput;
if (input?.shouldSuppressTerminalQueryResponse?.(data)) return;
const app = global.app;
if (input?.isTerminalFocusOrMouseReport?.(data)) {
app?._sendInputEphemeral?.(this.sessionId, data);
return;
}
app?._sendInputAsync?.(this.sessionId, data);
}
// Joins the app's input-socket map for this session and flushes anything
// already queued for it (typed while the socket was down, or left over from
// a reload) over the fresh socket. Called from onopen.
_registerInputSocket() {
const app = global.app;
if (!this.ws || !app?._registerInputSocket) return;
this._unregisterInputSocket();
this._inputHandle = { ws: this.ws, lastRecvAt: 0 };
app._registerInputSocket(this.sessionId, this._inputHandle);
app._onWsReady?.(this.sessionId);
}
// Leaves the map; only this pane's own handle is removed (a replacement
// socket's registration survives a late close of the old one).
_unregisterInputSocket() {
if (!this._inputHandle) return;
global.app?._unregisterInputSocket?.(this.sessionId, this._inputHandle);
this._inputHandle = null;
}
// Settles a marker the pane owes: set when a close lands during a load (the
// replay would otherwise sit below it) or when a load wipes the terminal on
// a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses
// a write() on a later tick: a marker written here would land in the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
// The refresh re-owes the marker on a closed socket and stamps it itself.
if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed;
this._markerOwed = false;
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a load that ends owing it on a
// closed socket can write it (see _stampMarkerIfOwed()).
_writeDisconnectedMarker() {
this.terminal?.write(`\r\n\x1b[2m${this._markerText}\x1b[0m\r\n`);
}
// Fetches and writes the session's current scrollback. Used both by
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
// scoped to `this.activeSessionId` and clears/rewrites the primary
// terminal, neither of which applies to this independent pane, so this is
// a standalone equivalent rather than a call into it.
//
// Mirrors the primary pane's own mode check (app.js's selectSession /
// _onSessionNeedsRefresh): a shell session can retain hundreds of
// thousands of plain scrollback lines, so pulling `?full=1` there parses
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
// get one full replay. `fetch` here goes through the global wrapper
// (constants.js), which already prefixes CodemanBase — unlike the raw
// WebSocket URL above, which does not.
//
// Single-flight: the flag is held across the fetch AND the chunked write
// (writeChunked resolves after its last chunk), so two replays can never
// interleave their chunks into one terminal. A second call while one is
// in flight is dropped here; _refreshBuffer() is the caller that queues
// a trailing re-run instead.
async _loadBuffer() {
if (this._bufferLoading) return;
this._bufferLoading = true;
try {
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
const payload = (await res.json())?.data ?? {};
if (payload.terminalBuffer && this.terminal) {
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
}
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
// Ends a single-flight load (initial, refresh or history pull): clears the
// flag, then runs the ONE trailing refresh that arrived while it was busy.
_endBufferLoad() {
this._bufferLoading = false;
if (this._bufferRefreshPending && !this._destroyed) {
this._bufferRefreshPending = false;
this._refreshBuffer();
}
}
// Live terminal output. Written straight through, except while a history
// pull is replaying: a capture is current only up to the instant tmux took
// it, so a frame arriving mid-replay is held with its arrival time and
// replayed behind the snapshot by _pullHistory() (the primary pane's
// _finishBufferLoad `since` rule), never written underneath it.
_onLiveOutput(data) {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
else this.terminal?.write(data);
}
// The server's `{t:'c'}` clear frame takes the same route as output, for the
// same reason: clearing straight away, mid-replay, would wipe the half-written
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
// one it is restoring. Queued, it lands in order with the frames around it.
_onLiveClear() {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
else this.terminal?.clear();
}
// Capture phase, because xterm's own wheel handler stopPropagation()s every
// event it consumes, so a bubbling listener here would never see the wheel
// while the pane still has scrollback to scroll. Passive: this only observes,
// xterm keeps doing the scrolling.
_installWheelListener() {
this._onWheel = (ev) => {
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
};
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
}
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
// output (`cat` of a file longer than the screen) instead of scrolling it,
// so this pane's xterm ends up with about one screen of scrollback while
// tmux holds every line — and nothing here ever went back to ask, so the
// history was unreachable. The primary pane has the same pull
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
// own. Shell only: a non-shell CLI's history is out of scope for this pull
// (its load already takes `full=1`; codex and Claude's inline renderer do
// grow tmux history, this just isn't how they recover it). The alternate-
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
// tmux the browser xterm never enters the alternate buffer.
_maybeLoadMoreHistory() {
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
if (this._bufferLoading) return;
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
// _sendResize(): a detached session's own window already owns its PTY
// size and scrollback, so Pane B has nothing of its own to reconcile.
if (this.detachedSessions?.has(this.sessionId)) return;
const active = this.terminal.buffer.active;
if (active.type !== 'normal' || active.viewportY !== 0) return;
// Momentum scrolling fires this dozens of times per flick, so cooldown
// rather than latch; a pull that could only have downgraded the pane
// waits far longer.
const cooldown = this._historyPullUseless ? 60000 : 4000;
const now = Date.now();
if (now - this._historyPullAt < cooldown) return;
this._historyPullAt = now;
void this._pullHistory();
}
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
// main thread) and replays it under the reader's current place. Holds the
// single-flight flag across the fetch AND the replay, like _loadBuffer().
async _pullHistory() {
this._bufferLoading = true;
let replayed = false;
let capturedAt = 0;
// Two budgets on one signal. The request itself gets the primary pane's
// (CodemanFetchDeadline, constants.js): live output is not held while it
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
// for that whole budget. Once the headers land live output IS held, so the
// body read gets the short one instead: a body that hangs would otherwise
// freeze the pane for the long budget. Aborting lands in the catch below,
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
// be re-armed, hence the controller; without AbortController the pull
// simply has no deadline.
const controller = global.AbortController ? new global.AbortController() : null;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
clearTimeout(abortTimer);
abortTimer = setTimeout(() => controller.abort(), ms);
};
try {
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
signal: controller?.signal,
});
armDeadline(HISTORY_PULL_TIMEOUT_MS);
// The cutoff below is the response's arrival, the same `since` rule the
// primary pane uses (_finishBufferLoad). It is a client clock standing in
// for the instant tmux took the capture, which lies somewhere in the
// round trip, so a frame in that window can be lost or doubled. Bounded
// by one round trip and not closable without a server-side capture time.
capturedAt = performance.now();
// Opened only now: a frame from before the response is either replaced by
// the capture or written unchanged, so holding it for the round trip
// bought nothing and froze the pane for as long as the fetch took.
this._liveQueue = [];
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer;
const term = this.terminal;
if (!buffer || !term || this._destroyed) return;
const rowsBefore = term.buffer.active.length;
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
// lines, so a window of short lines can carry more rows than this pane
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
const scrollbackCap = term.options?.scrollback || 0;
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
// Nothing to gain (this also covers a downgrade, which would delete
// history mid-scroll), and a reset+rewrite would jump the viewport. An
// untruncated window IS all of tmux's history and the next burst can add
// more, so keep the 4 s cooldown. A truncated window can never reach past
// what the pane shows, and every ask costs the server a capture-pane of
// the whole history (`tail` is cut after it): back off to 60 s, as the
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
// off too, since no window can ever fit in it.
if (rowsIncoming <= rowsBefore || paneFull) {
if (payload.truncated || paneFull) this._historyPullUseless = true;
return;
}
this._historyPullUseless = false;
term.write('\x1bc');
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(term, buffer, () => this._destroyed);
if (this._destroyed || !this.terminal) return;
// xterm parses asynchronously: an empty write's callback fires only
// after everything before it, so the row count below is the settled one.
await new Promise((resolve) => this.terminal.write('', resolve));
if (this._destroyed || !this.terminal) return;
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
// rows down; land there and the recovered history sits above it.
const delta = this.terminal.buffer.active.length - rowsBefore;
if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop();
} catch {
/* Best-effort — live output keeps arriving whatever happens here. */
} finally {
clearTimeout(abortTimer);
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
// earlier ones are already in it. With no replay, every held frame is.
const cutoff = replayed ? capturedAt : 0;
for (const entry of queued) {
if (entry.at < cutoff) continue;
if (entry.clear) this.terminal?.clear();
else this.terminal?.write(entry.data);
}
// Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
// one written before it, which would paint a fresh, current-looking
// history while onData keeps silently dropping every keystroke on the
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
// is left to that refresh, which writes it below its own replay.
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
// frames in a row used to start two concurrent replays, each clearing
// the terminal under the other's chunked write. A refresh that arrives
// mid-replay is COALESCED into one trailing re-run rather than ignored:
// the in-flight fetch may predate the drop the new frame is reporting,
// and no further frame is coming to correct stale content.
_refreshBuffer() {
if (this._bufferLoading) {
this._bufferRefreshPending = true;
return;
}
this.terminal?.clear();
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
// trailing refresh behind a pull that the socket's close then interrupts),
// so a refresh on a closed socket owes it back once its replay is written.
if (this._wsClosed) this._markerOwed = true;
void this._loadBuffer();
}
// Local reflow only — no PTY resize frame. Split out so a divider drag
// can reflow both panes at the browser's paint rate (rAF) while sending
// the actual `{t:'z'}` resize once, at drag end, matching the primary
// pane's own convention (throttledResize in terminal-ui.js).
localFit() {
if (!this.fitAddon) return;
this.fitAddon.fit();
}
// Reflow to the container and tell the PTY, as one step: the xterm and the
// PTY must never disagree about size (#464), and a font change is a size
// change too, so the font setters call this rather than localFit().
// `force` resends an unchanged size.
fit({ force = false } = {}) {
this.localFit();
this._sendResize({ force });
}
_sendResize({ force = false } = {}) {
if (!this._wsReady || !this.fitAddon || !this.terminal) return;
// One PTY cannot hold two sizes (mirrors sendResize's own
// detachedElsewhere yield in terminal-ui.js): the session got detached
// to its own window AFTER this split was opened, so its own window now
// owns the PTY's size and Pane B must stand aside.
if (this.detachedSessions?.has(this.sessionId)) return;
// A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and
// fit() then leaves the xterm alone: there is no size worth reporting.
const dims = this.fitAddon.proposeDimensions();
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
// Report what the xterm actually holds, so the PTY gets exactly the size
// the pane renders at. Unclamped, unlike the primary pane's 40x10 floor:
// a floor here misreported Pane B's width at the divider's reachable 20%
// position (about 28 columns), causing real output-wrapping bugs, and a
// floored xterm would be wider than its container. The server enforces
// its own valid range ([1,500]/[1,200] in ws-routes.ts).
const cols = this.terminal.cols;
const rows = this.terminal.rows;
const last = this._lastSentDims;
if (!force && last && last.cols === cols && last.rows === rows) return;
this._lastSentDims = { cols, rows };
this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' }));
}
// The geometry the PTY actually holds (`{t:'zc'}`, the server's answer to
// every resize). A PTY and a terminal that disagree on WIDTH render
// garbled, so a different column count is adopted; rows stay local, as in
// the primary pane (_onPtyGeometryReport in terminal-ui.js, #464). The
// pure verdict is the primary's too (reconcilePtyGeometry, constants.js).
_onPtyGeometryReport(cols, rows) {
const terminal = this.terminal;
if (!terminal) return;
const verdict = global.CodemanTerminalGeometry?.reconcilePtyGeometry?.(
{ cols: terminal.cols, rows: terminal.rows },
{ cols, rows }
);
if (!verdict?.adopt) return;
terminal.resize(verdict.cols, terminal.rows);
this._lastSentDims = { cols: verdict.cols, rows: terminal.rows };
}
destroy() {
this._destroyed = true;
// Anything still queued for this session stays in the app's queue and is
// delivered over HTTP by the redelivery sweep, so closing the pane mid-
// keystroke loses nothing.
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
if (this._onWheel) {
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
this._onWheel = null;
}
this._detachSocket();
if (this._onFocusIn) {
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
this._onFocusIn = null;
}
// A destroyed pane cannot hold the keyboard: shortcuts fall back to the
// primary terminal (_focusedPane also skips a destroyed tile on its own).
if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null);
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
this.fitAddon = null;
}
}
// The marker a pane writes when its socket drops: a transient drop says it is
// reconnecting; a permanent stop says why, keyed by close code. All start
// with `[disconnected` so a reader (and a test) can tell any of them apart
// from session output.
TerminalTile.MARKER_RECONNECTING = '[disconnected, reconnecting…]';
TerminalTile.STOP_MARKERS = {
4003: '[disconnected: the server refused this connection]',
4004: '[disconnected: the session ended]',
4009: '[disconnected: the session ended]',
4010: '[disconnected: another connection took over this pane]',
};
global.TerminalTile = TerminalTile;
})(window);
+113 -42
View File
@@ -544,6 +544,9 @@ Object.assign(CodemanApp.prototype, {
this._installMobileTapMouseGuard(); this._installMobileTapMouseGuard();
this._installShiftDragSelection(); this._installShiftDragSelection();
this._installTouchSelectionFocusGuard(); this._installTouchSelectionFocusGuard();
// Focus coming back to the primary terminal ends a second pane's claim on
// the keyboard (see _focusedPane).
this.terminal.textarea?.addEventListener('focus', () => this._noteFocusedTile(null));
// Let xterm's CompositionHelper own IME key events. In particular, a // Let xterm's CompositionHelper own IME key events. In particular, a
// non-composing keyCode 229 is how an active IME commits numbers and // non-composing keyCode 229 is how an active IME commits numbers and
@@ -1416,7 +1419,7 @@ Object.assign(CodemanApp.prototype, {
// without this call Pane B never learned about a window resize, an // without this call Pane B never learned about a window resize, an
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently // Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
// stayed at whatever size it was last dragged to. // stayed at whatever size it was last dragged to.
this._splitPane?.fit(); this._forEachTile?.((tile) => tile.fit());
}, 300); // Trailing-edge: only fire after 300ms of no resize events }, 300); // Trailing-edge: only fire after 300ms of no resize events
}; };
@@ -1827,9 +1830,24 @@ Object.assign(CodemanApp.prototype, {
* Register a custom link provider for xterm.js that detects file paths * Register a custom link provider for xterm.js that detects file paths
* in terminal output and makes them clickable. * in terminal output and makes them clickable.
* When clicked, opens a floating log viewer window with live streaming. * When clicked, opens a floating log viewer window with live streaming.
*
* `target` defaults to the primary terminal and the active session. A second
* terminal (the split pane) passes its own `{ terminal, getSessionId,
* setHovered }`, so a path printed there opens against THAT pane's session and
* hovering it never flips the primary pane's `_linkHovered`. Only the primary
* registration is kept on `_terminalLinkProvider`, which the touch path reads.
* Returns the provider.
*/ */
registerFilePathLinkProvider() { registerFilePathLinkProvider(target = {}) {
const self = this; const self = this;
const terminal = target.terminal || this.terminal;
const getSessionId = target.getSessionId || (() => this.activeSessionId);
const setHovered =
target.setHovered ||
((hovered) => {
this._linkHovered = hovered;
});
const isPrimary = terminal === this.terminal;
// Debug: Track if provider is being invoked // Debug: Track if provider is being invoked
let lastInvokedLine = -1; let lastInvokedLine = -1;
@@ -1842,7 +1860,7 @@ Object.assign(CodemanApp.prototype, {
console.debug('[LinkProvider] Checking line:', bufferLineNumber); console.debug('[LinkProvider] Checking line:', bufferLineNumber);
} }
const buffer = self.terminal.buffer.active; const buffer = terminal.buffer.active;
// provideLinks passes 1-based line number, getLine expects 0-based // provideLinks passes 1-based line number, getLine expects 0-based
const line = buffer.getLine(bufferLineNumber - 1); const line = buffer.getLine(bufferLineNumber - 1);
@@ -1866,7 +1884,7 @@ Object.assign(CodemanApp.prototype, {
const logical = window.CodemanTerminalLines?.terminalLogicalLine( const logical = window.CodemanTerminalLines?.terminalLogicalLine(
buffer, buffer,
bufferLineNumber - 1, bufferLineNumber - 1,
self.terminal.cols, terminal.cols,
MAX_STITCHED_ROWS MAX_STITCHED_ROWS
); );
if (!logical) { if (!logical) {
@@ -1919,10 +1937,10 @@ Object.assign(CodemanApp.prototype, {
window.open(text, '_blank', 'noopener,noreferrer'); window.open(text, '_blank', 'noopener,noreferrer');
}, },
hover() { hover() {
self._linkHovered = true; setHovered(true);
}, },
leave() { leave() {
self._linkHovered = false; setHovered(false);
}, },
}); });
}; };
@@ -1978,17 +1996,18 @@ Object.assign(CodemanApp.prototype, {
// path clicked in the response viewer previewed fine. The preview // path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external // reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree. // paths route there and the two surfaces agree.
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) { const sessionId = getSessionId();
self.openFilePreview(text, self.activeSessionId); if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
self.openFilePreview(text, sessionId);
return; return;
} }
self.openLogViewerWindow(text, self.activeSessionId); self.openLogViewerWindow(text, sessionId);
}, },
hover() { hover() {
self._linkHovered = true; setHovered(true);
}, },
leave() { leave() {
self._linkHovered = false; setHovered(false);
}, },
}); });
}; };
@@ -2031,10 +2050,11 @@ Object.assign(CodemanApp.prototype, {
// produce), so the tap path asks this SAME provider what is under the finger // produce), so the tap path asks this SAME provider what is under the finger
// rather than growing a second, driftable copy of the patterns. // rather than growing a second, driftable copy of the patterns.
// See _terminalLinkAtPoint. // See _terminalLinkAtPoint.
this._terminalLinkProvider = provider; if (isPrimary) this._terminalLinkProvider = provider;
this.terminal.registerLinkProvider(provider); terminal.registerLinkProvider(provider);
console.log('[LinkProvider] File path link provider registered'); console.log('[LinkProvider] File path link provider registered');
return provider;
}, },
/** /**
@@ -4480,8 +4500,45 @@ Object.assign(CodemanApp.prototype, {
// Terminal Controls // Terminal Controls
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
/**
* The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary, tile }`.
*
* The ONE place a shortcut, voice or paste should ask "which pane?", rather
* than reading `this.terminal` / `this.activeSessionId`, which always mean the
* primary pane. It answers with the pane whose terminal was focused LAST, not
* with `document.activeElement`: clicking the mic or a header button moves
* DOM focus to that button, and the dictation it starts still belongs to the
* pane the user was typing in. A second pane (the split pane's Pane B) claims
* it from its own terminal's focus; the primary terminal's focus gives it back.
*/
_focusedPane() {
const tile = this._focusedTile;
if (tile && !tile._destroyed && tile.terminal) {
return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile };
}
return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true, tile: null };
},
/** Record which second pane holds the keyboard (null: the primary terminal). */
_noteFocusedTile(tile) {
this._focusedTile = tile || null;
},
/**
* Run `fn(tile)` for every secondary terminal pane on screen: today the split
* pane's second terminal, when one is open. Font, weight, family and skin
* changes go through here so they reach every pane without a special case
* per pane kind. Agent Teams terminals size themselves and are not tiles.
*/
_forEachTile(fn) {
if (this._splitPane?.terminal) fn(this._splitPane);
},
// Clears the pane the keyboard is in. The chord itself also reaches that
// pane's xterm (the capture handler only preventDefault()s), so the ^L lands
// in the same pane whose display is cleared, never a different one.
clearTerminal() { clearTerminal() {
this.terminal.clear(); this._focusedPane().terminal?.clear();
}, },
/** Insert editable text at the active prompt without pressing Enter. */ /** Insert editable text at the active prompt without pressing Enter. */
@@ -4545,6 +4602,14 @@ Object.assign(CodemanApp.prototype, {
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation"). * Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
*/ */
async restoreTerminalSize() { async restoreTerminalSize() {
// A second pane owns its own geometry: refit it and force its PTY to the
// size it renders at (TerminalTile.fit), whatever another device set.
const pane = this._focusedPane();
if (!pane.isPrimary) {
pane.tile.fit({ force: true });
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
return;
}
if (!this.activeSessionId) { if (!this.activeSessionId) {
this.showToast('No active session', 'warning'); this.showToast('No active session', 'warning');
return; return;
@@ -4639,15 +4704,18 @@ Object.assign(CodemanApp.prototype, {
* terminal._core for cell dimensions, and falls back to cleaning normally if * terminal._core for cell dimensions, and falls back to cleaning normally if
* a future xterm renames it. SelectionMode.COLUMN is 3. * a future xterm renames it. SelectionMode.COLUMN is 3.
*/ */
cleanedTerminalSelection(text) { cleanedTerminalSelection(text, target = {}) {
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : ''); // `target` names a second terminal (the split pane) and its session; both
// default to the primary pane, whose `this.terminal` this file otherwise reads.
const terminal = target.terminal || this.terminal;
const raw = text ?? (terminal?.hasSelection?.() ? terminal.getSelection() : '');
if (!raw) return ''; if (!raw) return '';
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw; if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
const clean = window.CodemanCopySelection?.clean; const clean = window.CodemanCopySelection?.clean;
if (!clean) return raw; if (!clean) return raw;
const range = this._normalisedSelectionRange(); const range = this._normalisedSelectionRange(terminal);
return clean(raw, { return clean(raw, {
margin: this._cliGutterColumns(), margin: this._cliGutterColumns(target.sessionId),
firstLinePartial: !!range && range.start.x > 0, firstLinePartial: !!range && range.start.x > 0,
}); });
}, },
@@ -4717,8 +4785,11 @@ Object.assign(CodemanApp.prototype, {
// Copy the current terminal selection. Goes through _copyText (Clipboard API, // Copy the current terminal selection. Goes through _copyText (Clipboard API,
// then a hidden-textarea + execCommand fallback) because install.sh's LAN // then a hidden-textarea + execCommand fallback) because install.sh's LAN
// option serves plain HTTP, where navigator.clipboard is undefined. // option serves plain HTTP, where navigator.clipboard is undefined.
async copyTerminalSelection(text) { async copyTerminalSelection(text, target = {}) {
const selection = this.cleanedTerminalSelection(text); // Every terminal touched below is the TARGET one: clearing or refocusing the
// primary after copying from the split pane would hit the wrong pane.
const terminal = target.terminal || this.terminal;
const selection = this.cleanedTerminalSelection(text, target);
// trim(), not emptiness: a multi-row drag across padding cleans to newlines // trim(), not emptiness: a multi-row drag across padding cleans to newlines
// alone, which are truthy, and a bare newline pasted into a chat composer // alone, which are truthy, and a bare newline pasted into a chat composer
// or a shell submits the line. decideAutoCopy applies the same rule. // or a shell submits the line. decideAutoCopy applies the same rule.
@@ -4727,7 +4798,7 @@ Object.assign(CodemanApp.prototype, {
// selection, so a padding-only selection left set can no longer swallow a // selection, so a padding-only selection left set can no longer swallow a
// later interrupt; it cleans to '' and the press reaches the PTY. What the // later interrupt; it cleans to '' and the press reaches the PTY. What the
// clear avoids is a highlight that sits there having copied nothing. // clear avoids is a highlight that sits there having copied nothing.
this.terminal?.clearSelection?.(); terminal?.clearSelection?.();
this.showToast('Nothing to copy', 'warning'); this.showToast('Nothing to copy', 'warning');
return false; return false;
} }
@@ -4735,14 +4806,14 @@ Object.assign(CodemanApp.prototype, {
if (ok) { if (ok) {
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already // Clearing is what makes a second Ctrl+C an interrupt (and xterm already
// drops the selection on any keypress, so this matches existing feel). // drops the selection on any keypress, so this matches existing feel).
this.terminal.clearSelection?.(); terminal.clearSelection?.();
this.showToast('Copied to clipboard', 'success'); this.showToast('Copied to clipboard', 'success');
} else { } else {
this.showToast('Failed to copy', 'error'); this.showToast('Failed to copy', 'error');
} }
// The execCommand fallback focuses a temp textarea, so hand focus back. This // The execCommand fallback focuses a temp textarea, so hand focus back. This
// is the CJK-aware focus router, not xterm's raw focus(). // is the CJK-aware focus router, not xterm's raw focus().
this.terminal.focus(); terminal.focus();
return ok; return ok;
}, },
@@ -5688,10 +5759,10 @@ Object.assign(CodemanApp.prototype, {
// Update overlay font cache and re-render at new cell dimensions // Update overlay font cache and re-render at new cell dimensions
this._localEchoOverlay?.refreshFont(); this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont(); this._predictiveEcho?.refreshFont();
if (this._splitPane?.terminal) { this._forEachTile?.((tile) => {
this._splitPane.terminal.options.fontSize = size; tile.terminal.options.fontSize = size;
this._splitPane.fitAddon?.fit(); tile.fit(); // a font change is a size change: tell its PTY too (#464)
} });
}, },
/** /**
@@ -5717,10 +5788,10 @@ Object.assign(CodemanApp.prototype, {
this._refitAfterCellSizeChange(); this._refitAfterCellSizeChange();
this._localEchoOverlay?.refreshFont(); this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont(); this._predictiveEcho?.refreshFont();
if (this._splitPane?.terminal) { this._forEachTile?.((tile) => {
this._splitPane.terminal.options.fontFamily = resolved; tile.terminal.options.fontFamily = resolved;
this._splitPane.fitAddon?.fit(); tile.fit(); // a font change is a size change: tell its PTY too (#464)
} });
}, },
/** /**
@@ -5772,11 +5843,11 @@ Object.assign(CodemanApp.prototype, {
/* pane not laid out yet — its own resize observer refits it */ /* pane not laid out yet — its own resize observer refits it */
} }
} }
if (this._splitPane?.terminal) { this._forEachTile?.((tile) => {
this._splitPane.terminal.options.fontWeight = fontWeight; tile.terminal.options.fontWeight = fontWeight;
this._splitPane.terminal.options.fontWeightBold = fontWeightBold; tile.terminal.options.fontWeightBold = fontWeightBold;
this._splitPane.fitAddon?.fit(); tile.fit(); // a font change is a size change: tell its PTY too (#464)
} });
}, },
loadFontSize() { loadFontSize() {
@@ -6245,13 +6316,13 @@ Object.assign(CodemanApp.prototype, {
} }
} }
} }
if (this._splitPane?.terminal) { this._forEachTile?.((tile) => {
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio; tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
this._splitPane.terminal.options.theme = { ...theme }; tile.terminal.options.theme = { ...theme };
try { try {
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1); tile.terminal.refresh(0, tile.terminal.rows - 1);
} catch {} } catch {}
} });
}, },
}); });
+48 -10
View File
@@ -554,6 +554,11 @@ const VoiceInput = {
_analyserSource: null, // MediaStreamSource for level meter _analyserSource: null, // MediaStreamSource for level meter
_audioContext: null, // AudioContext for level meter _audioContext: null, // AudioContext for level meter
_levelAnimFrame: null, // rAF handle for level meter _levelAnimFrame: null, // rAF handle for level meter
// The session dictation was started FOR, captured in start(). Transcripts
// arrive seconds later and the green send button / compose overlay can be
// used later still; reading app.activeSessionId at that point sent the text
// to whatever tab the user had switched to in the meantime.
_targetSessionId: null,
init() { init() {
this._initRecognition(); this._initRecognition();
@@ -663,10 +668,12 @@ const VoiceInput = {
start() { start() {
if (this.isRecording) return; if (this.isRecording) return;
if (!app.activeSessionId) { const target = app._focusedPane?.()?.sessionId || app.activeSessionId;
if (!target) {
app.showToast('No active session', 'warning'); app.showToast('No active session', 'warning');
return; return;
} }
this._targetSessionId = target;
this._retryCount = 0; this._retryCount = 0;
const provider = this._resolveProvider(); const provider = this._resolveProvider();
@@ -959,8 +966,31 @@ const VoiceInput = {
this.stop(); this.stop();
}, },
/** The session this dictation belongs to (see _targetSessionId). */
_targetSession() {
return this._targetSessionId || app.activeSessionId;
},
/**
* Send text to the dictation's own session. The active session keeps going
* through app.sendInput() exactly as before; any other session goes straight
* to the durable queue with the same useMux flag sendInput() passes.
*/
_sendToTarget(target, text) {
if (target === app.activeSessionId) return app.sendInput(text);
// Closed while dictating: say so instead of queueing text for a session
// that will only answer 404 (and never typing it into some other tab).
if (app.sessions && !app.sessions.has(target)) {
app.showToast?.('That session has closed; dictation not sent', 'warning');
return Promise.resolve();
}
app._sendInputAsync(target, text, { useMux: true });
return Promise.resolve();
},
_insertText(text) { _insertText(text) {
if (!app.activeSessionId || !text.trim()) return; const target = this._targetSession();
if (!target || !text.trim()) return;
const trimmed = text.trim(); const trimmed = text.trim();
const mode = this._getDeepgramConfig().insertMode || 'direct'; const mode = this._getDeepgramConfig().insertMode || 'direct';
@@ -975,14 +1005,17 @@ const VoiceInput = {
this._showComposeOverlay(trimmed); this._showComposeOverlay(trimmed);
} }
} else { } else {
// Direct mode: inject into local echo overlay if available, else send to PTY // Direct mode: inject into local echo overlay if available, else send to PTY.
if (app._localEchoEnabled && app._localEchoOverlay) { // The overlay belongs to the ACTIVE session's terminal, so text dictated
// for any other session must not be typed into it.
const isActive = target === app.activeSessionId;
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
app._localEchoOverlay.appendText(trimmed); app._localEchoOverlay.appendText(trimmed);
} else { } else {
app.sendInput(trimmed).catch(() => {}); this._sendToTarget(target, trimmed).catch(() => {});
} }
this._showVoiceSendBtn(); this._showVoiceSendBtn();
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150); setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
} }
}, },
@@ -1008,10 +1041,15 @@ const VoiceInput = {
// Click handler // Click handler
this._voiceSendHandler = () => { this._voiceSendHandler = () => {
if (!app.activeSessionId) return; const target = this._targetSession();
if (!target) return;
// Simulate Enter key: if local echo is active, flush its buffer + send \r; // Simulate Enter key: if local echo is active, flush its buffer + send \r;
// otherwise just send \r directly to the PTY // otherwise just send \r directly to the PTY. Both the overlay and the
if (app._localEchoEnabled && app._localEchoOverlay) { // predictions belong to the ACTIVE session's terminal, so a dictation
// for another session just sends its Enter there.
if (target !== app.activeSessionId) {
this._sendToTarget(target, '\r').catch(() => {});
} else if (app._localEchoEnabled && app._localEchoOverlay) {
const text = app._localEchoOverlay.pendingText || ''; const text = app._localEchoOverlay.pendingText || '';
app._localEchoOverlay.clear(); app._localEchoOverlay.clear();
app._localEchoOverlay.suppressBufferDetection(); app._localEchoOverlay.suppressBufferDetection();
@@ -1065,7 +1103,7 @@ const VoiceInput = {
const send = () => { const send = () => {
const val = textarea.value.trim(); const val = textarea.value.trim();
overlay.remove(); overlay.remove();
if (val) app.sendInput(val + '\r').catch(() => {}); if (val) this._sendToTarget(this._targetSession(), val + '\r').catch(() => {});
}; };
const cancel = () => overlay.remove(); const cancel = () => overlay.remove();
const newInput = () => { const newInput = () => {
+104
View File
@@ -0,0 +1,104 @@
/**
* @fileoverview Ctrl+W never closes a session; it reaches the terminal.
*
* "Close Session" used to be bound to Ctrl+W by default. Ctrl+W is "delete the
* previous word" in every shell, readline prompt and agent CLI, so muscle memory
* killed the session (its tmux pane and CLI, with no confirm) in the middle of a
* sentence; with the split pane open it was not even the pane being typed in.
* The action now has NO default key: the capture-phase shortcut handler lets
* Ctrl+W through, and xterm sends ^W to the CLI like any other key. The action
* stays in the registry so a user can still bind a key to it in App Settings →
* Shortcuts.
*
* Real code under test: constants.js + app.js (DEFAULT_SHORTCUTS,
* getShortcutRegistry, matchesShortcutEvent) in a `vm` context.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
type Shortcut = { id: string; action?: string; bindings?: unknown[]; disabled?: boolean };
type App = {
getShortcutRegistry(): Shortcut[];
matchesShortcutEvent(e: Record<string, unknown>, s: Shortcut): boolean;
loadAppSettingsFromStorage: () => Record<string, unknown>;
};
function makeApp(settings: Record<string, unknown> = {}): App {
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn() },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context);
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
const app = Object.create(CodemanApp.prototype) as App;
app.loadAppSettingsFromStorage = () => settings;
return app;
}
const keydown = (overrides: Record<string, unknown>) => ({
type: 'keydown',
key: 'w',
code: 'KeyW',
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
...overrides,
});
describe('Ctrl+W is left to the terminal', () => {
it('Close Session is still in the registry, with no default key', () => {
const close = makeApp()
.getShortcutRegistry()
.find((s) => s.id === 'close-session');
expect(close?.action).toBe('killActiveSession');
expect(close?.bindings).toEqual([]);
});
it.each([
['Ctrl+W', { ctrlKey: true }],
['Cmd+W', { metaKey: true }],
])('no default shortcut answers %s, so the capture handler lets it reach xterm', (_name, mods) => {
const app = makeApp();
const event = keydown(mods);
const matched = app.getShortcutRegistry().filter((s) => !s.disabled && s.action && app.matchesShortcutEvent(event, s));
expect(matched.map((s) => s.id)).toEqual([]);
});
it('a user can still bind a key to Close Session', () => {
const app = makeApp({
shortcutOverrides: { 'close-session': { bindings: [{ modifiers: ['ctrl', 'shift'], key: 'w' }] } },
});
const close = app.getShortcutRegistry().find((s) => s.id === 'close-session')!;
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true, shiftKey: true, key: 'W' }), close)).toBe(true);
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true }), close)).toBe(false);
});
it('the shortcut overlay says an unbound action is not bound', () => {
const appSource = read('app.js');
const overlay = appSource.slice(appSource.indexOf('renderShortcutOverlay() {'), appSource.indexOf('closeShortcutOverlay() {'));
expect(overlay).toContain("if (s.bindings.length === 0) return '<span class=\"shortcut-overlay-unbound\">not bound</span>';");
});
});
+2 -3
View File
@@ -166,9 +166,8 @@ describe('entrance animation styles', () => {
expect(blur).not.toBeNull(); expect(blur).not.toBeNull();
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2); expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
// The 100% frame deliberately omits opacity so the endpoint comes from the // The 100% frame deliberately omits opacity so the endpoint comes from the
// element's own resting value: 0.9 on a subagent line, 1 on a lineage // element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
// line (its family group is translucent), 0.5 on a proxied one. Pinning a // line, 0.95 on a working one. Pinning a number here snaps three of them.
// number here snaps them.
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/); expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/); expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
}); });
+150
View File
@@ -0,0 +1,150 @@
/**
* @fileoverview App-level terminal shortcuts act on the pane the keyboard is in.
*
* With the split pane open, the global shortcut handler (app.js, capture phase)
* used to resolve every terminal action against the PRIMARY pane: Ctrl+L typed
* into Pane B cleared Pane A's display while xterm sent the ^L into Pane B's
* PTY, and Ctrl+Shift+R restored Pane A's size. `_focusedPane()` (terminal-ui.js)
* now answers with the pane whose terminal was focused last, and the actions
* that are about a TERMINAL (clear, restore size) go through it.
*
* Close Session is not one of them: it has no default key any more (Ctrl+W is
* left to the terminal as delete-word, see ctrl-w-never-closes.test.ts), and a
* key a user binds to it closes the active session, as it always did.
*
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
function loadApp() {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
window: {},
document: {
body: { classList: { contains: () => false } },
activeElement: null,
addEventListener: vi.fn(),
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn(), error: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.activeSessionId = 'session-a';
app.terminal = { clear: vi.fn(), cols: 120, rows: 40 };
app.showToast = vi.fn();
app.sendResize = vi.fn(async () => true);
app.getTerminalDimensions = () => ({ cols: 120, rows: 40 });
return app;
}
function paneB(overrides: Record<string, unknown> = {}) {
return {
sessionId: 'session-b',
terminal: { clear: vi.fn(), cols: 60, rows: 30 },
fit: vi.fn(),
_destroyed: false,
...overrides,
};
}
describe('_focusedPane()', () => {
it('is the primary pane until another pane claims the keyboard', () => {
const app = loadApp();
const pane = app._focusedPane();
expect(pane.isPrimary).toBe(true);
expect(pane.terminal).toBe(app.terminal);
expect(pane.sessionId).toBe('session-a');
});
it('is the second pane after its terminal was focused, and the primary again after the primary was', () => {
const app = loadApp();
const tile = paneB();
app._noteFocusedTile(tile);
expect(app._focusedPane()).toMatchObject({ isPrimary: false, sessionId: 'session-b', terminal: tile.terminal });
app._noteFocusedTile(null);
expect(app._focusedPane().isPrimary).toBe(true);
});
it('never answers with a destroyed pane', () => {
const app = loadApp();
const tile = paneB();
app._noteFocusedTile(tile);
tile._destroyed = true;
expect(app._focusedPane().isPrimary).toBe(true);
});
});
describe('terminal shortcuts follow the focused pane', () => {
it('Ctrl+L clears the focused second pane, not the primary', () => {
const app = loadApp();
const tile = paneB();
app._noteFocusedTile(tile);
app.clearTerminal();
expect(tile.terminal.clear).toHaveBeenCalledTimes(1);
expect(app.terminal.clear).not.toHaveBeenCalled();
});
it('Ctrl+L still clears the primary when it holds the keyboard', () => {
const app = loadApp();
app.clearTerminal();
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
});
it("Ctrl+Shift+R forces the focused second pane's size onto its own PTY", async () => {
const app = loadApp();
const tile = paneB();
app._noteFocusedTile(tile);
await app.restoreTerminalSize();
expect(tile.fit).toHaveBeenCalledWith({ force: true });
expect(app.sendResize).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success');
});
it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => {
const app = loadApp();
await app.restoreTerminalSize();
expect(app.sendResize).toHaveBeenCalledWith('session-a', { force: true });
});
});
describe('Close Session (user-bound key only) stays on the active session', () => {
it('killActiveSession closes activeSessionId and never consults the focused pane', () => {
const appSource = read('app.js');
const body = appSource.slice(
appSource.indexOf('async killActiveSession() {'),
appSource.indexOf('async killAllSessions() {')
);
expect(body).toContain('await this.closeSession(this.activeSessionId);');
expect(body).not.toContain('_focusedPane');
});
});
+3 -1
View File
@@ -38,7 +38,9 @@ describe('help modal shortcuts', () => {
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal')); const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
it('documents implemented global and tab shortcuts', () => { it('documents implemented global and tab shortcuts', () => {
expectShortcut(helpModal, ['Ctrl', 'W'], 'Close Session'); // Ctrl+W is NOT an app shortcut: it is delete-word in the terminal, and as
// Close Session it killed sessions with no confirm (ctrl-w-never-closes.test.ts).
expect(helpModal).not.toMatch(/<kbd>Ctrl<\/kbd>\s*\+\s*<kbd>W<\/kbd>/i);
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session'); expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session'); expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session'); expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
+30 -1
View File
@@ -143,6 +143,7 @@ function loadImageInputApp() {
app.activeSessionId = 'session-1'; app.activeSessionId = 'session-1';
app.showToast = vi.fn(); app.showToast = vi.fn();
app.sendInput = vi.fn(async () => {}); app.sendInput = vi.fn(async () => {});
app._sendInputAsync = vi.fn();
app._normalizeImageForUpload = vi.fn(async (file) => file); app._normalizeImageForUpload = vi.fn(async (file) => file);
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path); app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
return app as Record<string, any>; return app as Record<string, any>;
@@ -199,6 +200,7 @@ describe('image upload insertion policy', () => {
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']); expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
expect(app.sendInput).not.toHaveBeenCalled(); expect(app.sendInput).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
}); });
it('preserves terminal insertion by default', async () => { it('preserves terminal insertion by default', async () => {
@@ -207,6 +209,33 @@ describe('image upload insertion policy', () => {
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]); const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']); expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png'); // The same delivery sendInput() uses (durable queue, useMux for the POST
// fallback), but addressed to the session the batch was uploaded to.
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/legacy.png', { useMux: true });
});
it('inserts into the session the upload started in, even after a tab switch mid-upload', async () => {
const app = loadImageInputApp();
// The user switches tabs while the upload is in flight. sendInput() re-read
// activeSessionId after the awaits, so the paths used to land in session-2.
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => {
app.activeSessionId = 'session-2';
return file.path;
});
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-1', { path: '/tmp/shot.png' });
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/shot.png', { useMux: true });
expect(app.sendInput).not.toHaveBeenCalled();
});
it('uploads to and inserts into an explicitly named session', async () => {
const app = loadImageInputApp();
await app._uploadAndInsertImages([{ path: '/tmp/pane-b.png' }], { sessionId: 'session-b' });
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-b', { path: '/tmp/pane-b.png' });
expect(app._sendInputAsync).toHaveBeenCalledWith('session-b', '/tmp/pane-b.png', { useMux: true });
}); });
}); });
+1 -1
View File
@@ -97,7 +97,7 @@ describe('the client never queues or keeps an undeliverable frame', () => {
}); });
it('drops a frame the WebSocket refused with an error ACK', () => { it('drops a frame the WebSocket refused with an error ACK', () => {
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {')); const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {'));
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/); expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
}); });
+281
View File
@@ -0,0 +1,281 @@
/**
* @fileoverview The durable input layer delivers over ANY registered terminal
* socket, not only the primary one (`_inputSocketFor`, app.js).
*
* The exactly-once queue (`_sendInputAsync` → `_reliableSend` → `_drainSession`,
* ACKed by `{t:'ia'}`, swept by `_redeliverSweep`) used to read the single
* primary socket (`this._ws` / `this._wsSessionId`) directly. A second terminal
* bound to another session (the split pane's Pane B, later a grid tile) had no
* way in, so its keystrokes went out as seq-less frames with no ACK, no retry
* and no idle-alert acknowledgement. These tests pin the seam: a registered
* socket gets seq frames, its ACK lands on ITS session's queue, a stale
* registration can never evict the socket that replaced it, and the sweep
* judges a socket's silence by that socket's own last frame.
*
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadCodemanAppClass() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
document: { addEventListener: vi.fn() },
localStorage: {
length: 0,
key: vi.fn(),
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
type Frame = { t: string; d: string; seq?: number; cid?: string };
type FakeSocket = { readyState: number; send: (data: string) => void; close: ReturnType<typeof vi.fn> };
type Handle = { ws: FakeSocket; lastRecvAt: number };
type Rec = { seq: number; data: string; sentAt: number; tries: number };
type App = {
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
_sendInputEphemeral: (sessionId: string, input: string) => void;
_inputSocketFor: (sessionId: string) => { ws: FakeSocket; lastRecvAt: number } | null;
_registerInputSocket: (sessionId: string, handle: Handle) => void;
_unregisterInputSocket: (sessionId: string, handle: Handle) => void;
_onWsInputAck: (seq: number, msg: Record<string, unknown>, sessionId?: string) => void;
_onWsReady: (sessionId: string) => void;
_redeliverSweep: () => void;
_pendingDeliveries: Map<string, Rec[]>;
_seqCounters: Map<string, number>;
_ws: FakeSocket | null;
_wsSessionId: string | null;
_wsLastRecvAt: number;
_reliableAckTimeoutMs: number;
markIdleAlertSeen: ReturnType<typeof vi.fn>;
};
function makeApp(): App {
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
app._clientId = 'c-test';
app._seqCounters = new Map();
app._pendingDeliveries = new Map();
app._postDraining = new Set();
app._persistReliableState = vi.fn();
app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.markIdleAlertSeen = vi.fn();
app.activeSessionId = 'primary';
app._ws = null;
app._wsSessionId = null;
app._wsLastRecvAt = 0;
app._reliableAckTimeoutMs = 4000;
return app as unknown as App;
}
function fakeSocket(frames: Frame[]): FakeSocket {
return { readyState: 1, send: (d: string) => frames.push(JSON.parse(d) as Frame), close: vi.fn() };
}
describe('a registered second socket joins the exactly-once queue', () => {
it('delivers seq-tagged frames over the registered socket for its session', () => {
const app = makeApp();
const primaryFrames: Frame[] = [];
const tileFrames: Frame[] = [];
app._ws = fakeSocket(primaryFrames);
app._wsSessionId = 'primary';
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
app._sendInputAsync('other', 'x');
app._sendInputAsync('other', 'y');
expect(tileFrames.map((f) => [f.d, f.seq, f.cid])).toEqual([
['x', 1, 'c-test'],
['y', 2, 'c-test'],
]);
// Nothing for the other session leaks onto the primary socket.
expect(primaryFrames).toEqual([]);
});
it('still prefers the primary socket for the primary session', () => {
const app = makeApp();
const primaryFrames: Frame[] = [];
app._ws = fakeSocket(primaryFrames);
app._wsSessionId = 'primary';
app._sendInputAsync('primary', 'a');
expect(primaryFrames.map((f) => f.d)).toEqual(['a']);
});
it('ignores a registered socket that is not OPEN (falls back to POST)', async () => {
const app = makeApp();
const posts: Array<{ input: string; seq: number }> = [];
global.fetch = vi.fn(async (_url, init) => {
posts.push(JSON.parse(String(init?.body)));
return new Response('{}', { status: 200 });
});
const closing = { ...fakeSocket([]), readyState: 3 };
app._registerInputSocket('other', { ws: closing, lastRecvAt: 0 });
expect(app._inputSocketFor('other')).toBeNull();
app._sendInputAsync('other', 'z');
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
});
it('routes ephemeral input over the registered socket too', () => {
const app = makeApp();
const tileFrames: Frame[] = [];
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
app._sendInputEphemeral('other', '\x1b[<64;1;1M');
expect(tileFrames).toEqual([{ t: 'i', d: '\x1b[<64;1;1M' }]);
});
});
describe('ACK routing', () => {
it('drops the record from the queue of the session the ACK arrived for', () => {
const app = makeApp();
app._ws = fakeSocket([]);
app._wsSessionId = 'primary';
app._registerInputSocket('other', { ws: fakeSocket([]), lastRecvAt: 0 });
app._sendInputAsync('primary', 'p');
app._sendInputAsync('other', 'o');
// Both sessions issued seq 1. The ACK names no session, so the socket's own
// session decides which queue it drains; the primary's must be untouched.
app._onWsInputAck(1, { t: 'ia', seq: 1 }, 'other');
expect(app._pendingDeliveries.get('other')).toBeUndefined();
expect(app._pendingDeliveries.get('primary')?.map((r) => r.data)).toEqual(['p']);
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('other');
});
it('defaults to the primary socket session when no session is passed', () => {
const app = makeApp();
app._ws = fakeSocket([]);
app._wsSessionId = 'primary';
app._sendInputAsync('primary', 'p');
app._onWsInputAck(1, { t: 'ia', seq: 1 });
expect(app._pendingDeliveries.get('primary')).toBeUndefined();
});
it('re-sends everything pending over a freshly registered socket on ready', () => {
const app = makeApp();
const first: Frame[] = [];
const firstHandle = { ws: fakeSocket(first), lastRecvAt: 0 };
app._registerInputSocket('other', firstHandle);
app._sendInputAsync('other', 'a');
expect(first.map((f) => f.d)).toEqual(['a']);
// The socket dies without an ACK; a replacement opens and asks for a flush.
firstHandle.ws.readyState = 3;
const second: Frame[] = [];
app._registerInputSocket('other', { ws: fakeSocket(second), lastRecvAt: 0 });
app._onWsReady('other');
expect(second.map((f) => [f.d, f.seq])).toEqual([['a', 1]]);
});
});
describe('registration ownership', () => {
it('a stale handle cannot unregister the socket that replaced it', () => {
const app = makeApp();
const oldHandle = { ws: fakeSocket([]), lastRecvAt: 0 };
const newFrames: Frame[] = [];
const newHandle = { ws: fakeSocket(newFrames), lastRecvAt: 0 };
app._registerInputSocket('other', oldHandle);
app._registerInputSocket('other', newHandle);
// The old socket's close lands late and tries to clean up after itself.
app._unregisterInputSocket('other', oldHandle);
app._sendInputAsync('other', 'still-here');
expect(newFrames.map((f) => f.d)).toEqual(['still-here']);
});
it('the owning handle does unregister', () => {
const app = makeApp();
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
app._registerInputSocket('other', handle);
app._unregisterInputSocket('other', handle);
expect(app._inputSocketFor('other')).toBeNull();
});
it('stamps lastRecvAt at registration so a fresh socket never looks silent', () => {
const app = makeApp();
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
const before = Date.now();
app._registerInputSocket('other', handle);
expect(handle.lastRecvAt).toBeGreaterThanOrEqual(before);
});
});
describe('the redelivery sweep judges each socket by its own last frame', () => {
function staleRecord(app: App, sessionId: string) {
const rec = app._pendingDeliveries.get(sessionId)![0];
rec.sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
}
it('force-closes a silent registered socket with a stale record', () => {
const app = makeApp();
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
app._registerInputSocket('other', handle);
app._sendInputAsync('other', 'a');
staleRecord(app, 'other');
handle.lastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
// The primary socket is chatty; that must not vouch for the other one.
app._ws = fakeSocket([]);
app._wsSessionId = 'primary';
app._wsLastRecvAt = Date.now();
app._redeliverSweep();
expect(handle.ws.close).toHaveBeenCalledTimes(1);
expect(app._ws.close).not.toHaveBeenCalled();
});
it('re-drives (does not close) a registered socket that is still receiving', () => {
const app = makeApp();
const frames: Frame[] = [];
const handle = { ws: fakeSocket(frames), lastRecvAt: 0 };
app._registerInputSocket('other', handle);
app._sendInputAsync('other', 'a');
staleRecord(app, 'other');
handle.lastRecvAt = Date.now();
// A SILENT primary must not condemn the live registered socket either.
app._wsLastRecvAt = 0;
app._redeliverSweep();
expect(handle.ws.close).not.toHaveBeenCalled();
expect(frames.map((f) => f.d)).toEqual(['a', 'a']);
});
});
+1 -1
View File
@@ -42,7 +42,7 @@ describe('the duplicate ACK carries what the client needs', () => {
describe('the client lifts itself over the watermark', () => { describe('the client lifts itself over the watermark', () => {
const handler = appSource.slice( const handler = appSource.slice(
appSource.indexOf('_onWsInputAck(seq, msg)'), appSource.indexOf('_onWsInputAck(seq, msg, sessionId'),
appSource.indexOf('/** Called from ws.onopen') appSource.indexOf('/** Called from ws.onopen')
); );
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest';
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public'); const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => { describe.each(['terminal-ui.js', 'terminal-tile.js'])('%s Shift/Ctrl+Enter handler', (file) => {
const src = readFileSync(join(PUBLIC, file), 'utf8'); const src = readFileSync(join(PUBLIC, file), 'utf8');
it('swallows every event type for Shift/Ctrl+Enter', () => { it('swallows every event type for Shift/Ctrl+Enter', () => {
+4 -4
View File
@@ -8,8 +8,8 @@
* worked all along because Chromium fires no keypress for it. * worked all along because Chromium fires no keypress for it.
* *
* The page is the real app served by a real WebServer, so the handlers under test are the ones * The page is the real app served by a real WebServer, so the handlers under test are the ones
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real * terminal-ui.js (the main pane, `app.terminal`) and terminal-tile.js (Pane B, a real
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is * `TerminalTile`) attach. Nothing restates their predicate. What stands in for the server is
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and * only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
* no session exists behind the ids, so nothing is ever typed into a real pane. * no session exists behind the ids, so nothing is ever typed into a real pane.
* *
@@ -74,7 +74,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw
const mount = document.createElement('div'); const mount = document.createElement('div');
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;'; mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
document.body.appendChild(mount); document.body.appendChild(mount);
const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' }); const pane = new w.TerminalTile(paneBId, mount, { mode: 'claude' });
void pane.connect().catch(() => {}); void pane.connect().catch(() => {});
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket. // What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
w.__paneBData = [] as string[]; w.__paneBData = [] as string[];
@@ -200,7 +200,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw
expect(shift.sendKeys).toEqual([]); expect(shift.sendKeys).toEqual([]);
}); });
it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => { it("Pane B (terminal-tile.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
const shift = await pressIn('paneB', 'Shift+Enter'); const shift = await pressIn('paneB', 'Shift+Enter');
expect(shift.focused).toBe(true); expect(shift.focused).toBe(true);
expect(shift.data).toEqual([]); expect(shift.data).toEqual([]);
+7 -7
View File
@@ -1,4 +1,4 @@
/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */ /** @fileoverview Real Chromium + real WebSocket coverage for TerminalTile (Task 4 of the split-pane-sessions plan). */
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright'; import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js'; import { WebServer } from '../src/web/server.js';
@@ -6,7 +6,7 @@ import { WebServer } from '../src/web/server.js';
const PORT = 3175; const PORT = 3175;
const BASE_URL = `http://localhost:${PORT}`; const BASE_URL = `http://localhost:${PORT}`;
describe('SplitTerminalPane in a real browser', () => { describe('TerminalTile in a real browser', () => {
let server: WebServer; let server: WebServer;
let browser: Browser; let browser: Browser;
let page: Page; let page: Page;
@@ -37,7 +37,7 @@ describe('SplitTerminalPane in a real browser', () => {
// Testing section) — the shell PTY only spawns once this is called, and // Testing section) — the shell PTY only spawns once this is called, and
// without it the WS opens but no bytes ever flow, and the echo assertion // without it the WS opens but no bytes ever flow, and the echo assertion
// below would hang until its own timeout for reasons unrelated to // below would hang until its own timeout for reasons unrelated to
// SplitTerminalPane. // TerminalTile.
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' }); await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id; return id;
}); });
@@ -48,7 +48,7 @@ describe('SplitTerminalPane in a real browser', () => {
mount.style.height = '300px'; mount.style.height = '300px';
document.body.appendChild(mount); document.body.appendChild(mount);
const pane = new (window as any).SplitTerminalPane(id, mount); const pane = new (window as any).TerminalTile(id, mount);
pane.connect(); pane.connect();
// Wait for the WS to open, then send a real input frame — testMode's // Wait for the WS to open, then send a real input frame — testMode's
@@ -107,7 +107,7 @@ describe('SplitTerminalPane in a real browser', () => {
}); });
const id = (await res.json()).data.session.id; const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' }); await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
// Write directly to the session (not through SplitTerminalPane, which // Write directly to the session (not through TerminalTile, which
// does not exist yet). Poll the real ?full=1 capture (same endpoint // does not exist yet). Poll the real ?full=1 capture (same endpoint
// connect() below will use) rather than a fixed delay — the shell's // connect() below will use) rather than a fixed delay — the shell's
// own startup can race an early write and, on this box, a startup // own startup can race an early write and, on this box, a startup
@@ -135,7 +135,7 @@ describe('SplitTerminalPane in a real browser', () => {
mount.style.height = '300px'; mount.style.height = '300px';
document.body.appendChild(mount); document.body.appendChild(mount);
const pane = new (window as any).SplitTerminalPane(id, mount); const pane = new (window as any).TerminalTile(id, mount);
await pane.connect(); await pane.connect();
// xterm's write() parses asynchronously (it queues data and processes it // xterm's write() parses asynchronously (it queues data and processes it
@@ -191,7 +191,7 @@ describe('SplitTerminalPane in a real browser', () => {
mount.style.height = '300px'; mount.style.height = '300px';
document.body.appendChild(mount); document.body.appendChild(mount);
const pane = new (window as any).SplitTerminalPane(id, mount); const pane = new (window as any).TerminalTile(id, mount);
await pane.connect(); await pane.connect();
await new Promise((resolve) => { await new Promise((resolve) => {
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100)); const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
+28 -1
View File
@@ -63,7 +63,13 @@ function fakeTerminal(options: Record<string, unknown> = {}) {
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } }; return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
} }
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | null } = {}) { function makeApp(
opts: {
teammates?: number;
terminal?: ReturnType<typeof fakeTerminal> | null;
splitPane?: { terminal: ReturnType<typeof fakeTerminal>; fit: () => void } | null;
} = {}
) {
const fit = vi.fn(); const fit = vi.fn();
const teammateFits: ReturnType<typeof vi.fn>[] = []; const teammateFits: ReturnType<typeof vi.fn>[] = [];
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>(); const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
@@ -74,6 +80,10 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
} }
const app = { const app = {
applyTerminalFontWeights: mixin.applyTerminalFontWeights, applyTerminalFontWeights: mixin.applyTerminalFontWeights,
// Secondary panes (the split pane's second terminal) are reached through
// this helper, so it is wired for real like the geometry chain below.
_forEachTile: mixin._forEachTile,
_splitPane: opts.splitPane ?? null,
// The REAL geometry chain, not stubs. A font change moves the cell size, so // The REAL geometry chain, not stubs. A font change moves the cell size, so
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit // it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
// through the one function that floors the result and reports it (#464). // through the one function that floors the result and reports it (#464).
@@ -98,6 +108,23 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
} }
describe('applyTerminalFontWeights', () => { describe('applyTerminalFontWeights', () => {
it('reaches an open split pane: same weights, refit AND reported to its PTY', () => {
const splitTerminal = fakeTerminal();
// fit(), not localFit(): a weight change can move the cell size, and the
// split pane's PTY must hear about the new geometry like the primary's does.
const fit = vi.fn();
const { app } = makeApp({ splitPane: { terminal: splitTerminal, fit } });
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
terminalFontWeight: '300',
terminalFontWeightBold: '700',
});
expect(splitTerminal.options.fontWeight).toBe(300);
expect(splitTerminal.options.fontWeightBold).toBe(700);
expect(fit).toHaveBeenCalledTimes(1);
});
it('writes both slots to the live terminal', () => { it('writes both slots to the live terminal', () => {
const { app, fit } = makeApp(); const { app, fit } = makeApp();
+146
View File
@@ -0,0 +1,146 @@
/**
* @fileoverview The file-path / URL link provider can serve a second terminal.
*
* `registerFilePathLinkProvider()` (terminal-ui.js) was hardwired to the
* primary terminal and the ACTIVE session: a path clicked anywhere opened
* against `activeSessionId`. It now takes an optional target `{ terminal,
* getSessionId, setHovered }`, so the split pane's second terminal (and later a
* grid tile) gets clickable paths that open against ITS session. With no target
* it behaves exactly as before, and only the primary registration is kept on
* `_terminalLinkProvider`, which the touch-tap path reads.
*
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
type Link = { text: string; activate: (ev: unknown, text: string) => void; hover: () => void; leave: () => void };
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
function loadHarness() {
const CodemanApp = function CodemanApp(this: unknown) {};
const windowRef: Record<string, unknown> = {};
const context = vm.createContext({
window: windowRef,
document: {
body: { classList: { contains: () => false } },
activeElement: null,
addEventListener: vi.fn(),
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
return new (CodemanApp as unknown as new () => Record<string, any>)();
}
/** A one-screen xterm stand-in holding `lines`, recording every provider registered on it. */
function fakeTerminal(lines: string[]) {
const providers: Provider[] = [];
return {
cols: 80,
providers,
registerLinkProvider: (p: Provider) => providers.push(p),
buffer: {
active: {
length: lines.length,
getLine: (row: number) =>
row >= 0 && row < lines.length
? {
isWrapped: false,
translateToString: (trim?: boolean) => (trim === false ? lines[row].padEnd(80) : lines[row]),
}
: undefined,
},
},
};
}
function linksOn(provider: Provider, line: number): Link[] {
let out: Link[] = [];
provider.provideLinks(line, (links) => {
out = links || [];
});
return out;
}
function makeApp() {
const app = loadHarness();
app.activeSessionId = 'primary-session';
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._isExternalPreviewPath = () => false;
return app;
}
describe('registerFilePathLinkProvider with no target (the primary pane)', () => {
it('registers on the primary terminal, keeps the provider for the tap path, opens against the active session', () => {
const app = makeApp();
app.terminal = fakeTerminal(['wrote /tmp/shot.png']);
const provider = app.registerFilePathLinkProvider();
expect(app.terminal.providers).toEqual([provider]);
expect(app._terminalLinkProvider).toBe(provider);
const [link] = linksOn(provider, 1);
link.activate({}, link.text);
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/shot.png', 'primary-session');
link.hover();
expect(app._linkHovered).toBe(true);
});
});
describe('registerFilePathLinkProvider with a target (a second pane)', () => {
it("registers on the target terminal and opens against the target's session, not the active one", () => {
const app = makeApp();
app.terminal = fakeTerminal([]);
const primaryProvider = app.registerFilePathLinkProvider();
const paneTerminal = fakeTerminal(['tail -f /var/log/app.log']);
const setHovered = vi.fn();
const provider = app.registerFilePathLinkProvider({
terminal: paneTerminal,
getSessionId: () => 'pane-session',
setHovered,
});
expect(paneTerminal.providers).toEqual([provider]);
expect(app.terminal.providers).toEqual([primaryProvider]);
// The tap path's provider is still the primary one.
expect(app._terminalLinkProvider).toBe(primaryProvider);
const [link] = linksOn(provider, 1);
link.activate({}, link.text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/var/log/app.log', 'pane-session');
link.hover();
link.leave();
expect(setHovered.mock.calls).toEqual([[true], [false]]);
expect(app._linkHovered).toBeUndefined();
});
it('reads the session at click time, so a pane rebound to another session follows it', () => {
const app = makeApp();
let sessionId = 'first';
const paneTerminal = fakeTerminal(['see /tmp/a.pdf']);
const provider = app.registerFilePathLinkProvider({ terminal: paneTerminal, getSessionId: () => sessionId });
sessionId = 'second';
const [link] = linksOn(provider, 1);
link.activate({}, link.text);
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/a.pdf', 'second');
});
});
+691
View File
@@ -0,0 +1,691 @@
/**
* @fileoverview TerminalTile input rides the app's exactly-once queue, and only
* what a human typed is queued.
*
* Before, the split pane's second terminal sent every xterm `onData` chunk as a
* bare `{t:'i', d}` frame: no seq, no ACK, dropped while the socket was down,
* never acknowledged an idle alert. It now goes through `app._sendInputAsync`,
* over the pane's own socket registered in the app's input-socket map. That
* queue PERSISTS and REDELIVERS, so it must never hold what xterm generates on
* its own: a query reply (DA/CPR/OSC) is dropped, exactly as the primary pane
* drops it, and a focus or mouse report goes out once, ephemeral.
*
* The socket's lifecycle is pinned here too: a transient drop reconnects on the
* primary pane's backoff and refreshes the buffer without leaving a stale
* "disconnected" marker under a healthy pane; codes that cannot get better stop
* the pane once (`onExit`); a late close from a REPLACED socket is ignored; and
* destroy() cancels a pending reconnect.
*
* Real code under test: constants.js + app.js (the queue) + terminal-ui.js (the
* shared input predicates) + terminal-tile.js, in one `vm` context. xterm, the
* fit addon and WebSocket are fakes; `connect()` runs for real.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number };
class FakeSocket {
static OPEN = 1;
static instances: FakeSocket[] = [];
readyState = 0;
sent: Frame[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: ((ev?: { code: number }) => void) | null = null;
onerror: (() => void) | null = null;
constructor(public url: string) {
FakeSocket.instances.push(this);
}
send(data: string) {
this.sent.push(JSON.parse(data) as Frame);
}
close = vi.fn(() => {
this.readyState = 3;
});
open() {
this.readyState = 1;
this.onopen?.();
}
receive(msg: object) {
this.onmessage?.({ data: JSON.stringify(msg) });
}
inputFrames() {
return this.sent.filter((f) => f.t === 'i');
}
}
/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */
class FakeFit {
static proposed = { cols: 80, rows: 24 };
term: FakeTerminal | null = null;
fit() {
const { cols, rows } = FakeFit.proposed;
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
this.term?.resize(cols, rows);
}
proposeDimensions() {
return { ...FakeFit.proposed };
}
}
class FakeTerminal {
static last: FakeTerminal | null = null;
options: Record<string, unknown>;
cols = 80;
rows = 24;
dataCb: ((data: string) => void) | null = null;
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
constructor(options: Record<string, unknown>) {
this.options = { ...options };
FakeTerminal.last = this;
}
loadAddon(addon: FakeFit) {
addon.term = this;
}
open() {}
onData(cb: (data: string) => void) {
this.dataCb = cb;
}
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
focusListeners: Array<() => void> = [];
textarea = {
addEventListener: (type: string, fn: () => void) => {
if (type === 'focus') this.focusListeners.push(fn);
},
removeEventListener: (type: string, fn: () => void) => {
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
},
};
focusTextarea() {
for (const fn of this.focusListeners) fn();
}
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
this.keyHandler = fn;
}
registerLinkProvider() {}
writes: string[] = [];
write(data: string, cb?: () => void) {
this.writes.push(data);
cb?.();
}
clear() {
this.writes.push('<CLEAR>');
}
resizes: Array<[number, number]> = [];
resize(cols: number, rows: number) {
this.resizes.push([cols, rows]);
this.cols = cols;
this.rows = rows;
}
dispose() {}
type(data: string) {
this.dataCb?.(data);
}
}
const fetchMock = vi.fn();
function loadContext() {
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
// running inside this context.
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: FakeSocket,
Terminal: FakeTerminal,
FitAddon: { FitAddon: FakeFit },
fetch: (...args: unknown[]) => fetchMock(...args),
location: { protocol: 'http:', host: 'codeman.test' },
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: windowStub,
MobileDetection: {
isTouchDevice: () => false,
isHandheldDevice: () => false,
getDeviceType: () => 'desktop',
},
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;',
context
);
return {
CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp,
windowStub,
};
}
const { CodemanApp, windowStub } = loadContext();
type App = Record<string, unknown> & {
_pendingDeliveries: Map<string, Array<{ seq: number; data: string }>>;
markIdleAlertSeen: ReturnType<typeof vi.fn>;
};
function makeApp(): App {
const app = Object.create(CodemanApp.prototype) as App;
app._clientId = 'c-test';
app._wsTabNonce = 'nonce-1';
app._seqCounters = new Map();
app._pendingDeliveries = new Map();
app._postDraining = new Set();
app._extraInputSockets = new Map();
app._persistReliableState = vi.fn();
app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.markIdleAlertSeen = vi.fn();
app.showToast = vi.fn();
// The key handler's chord gates read the shortcut registry, which reads these.
app.loadAppSettingsFromStorage = () => ({});
app._ws = null;
app._wsSessionId = null;
app._estimateReplayRows = (text: string) => text.split('\n').length;
return app;
}
type Tile = {
connect(): Promise<void>;
destroy(): void;
reconnectNow(): void;
fit(opts?: { force?: boolean }): void;
detachedSessions?: Set<string>;
ws: FakeSocket | null;
_reconnectAttempts: number;
};
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
/**
* Every tile a test creates, destroyed after it. A tile closed with real timers
* schedules a real reconnect, and one firing during a LATER test opens a socket
* there (FakeSocket.instances is shared), which flaked under full-suite load.
*/
const liveTiles: Tile[] = [];
async function connectTile(app: App, opts: Record<string, unknown> = {}) {
windowStub.app = app;
const tile = new TerminalTile(
's-tile',
{ addEventListener: vi.fn(), removeEventListener: vi.fn() },
{ mode: 'claude', ...opts }
);
liveTiles.push(tile);
await tile.connect();
const ws = FakeSocket.instances.at(-1)!;
return { tile, ws, term: FakeTerminal.last! };
}
afterEach(() => {
for (const tile of liveTiles.splice(0)) tile.destroy();
vi.useRealTimers();
});
beforeEach(() => {
FakeFit.proposed = { cols: 80, rows: 24 };
FakeSocket.instances = [];
fetchMock.mockReset();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: '' } }),
}));
});
describe('TerminalTile socket identity', () => {
it('connects with the tab identity plus a :tile suffix, never the primary pane cid', async () => {
const { ws } = await connectTile(makeApp());
const cid = new URL(ws.url).searchParams.get('cid');
expect(cid).toBe('c-test:nonce-1:tile');
});
});
describe('TerminalTile input through the exactly-once queue', () => {
it('sends typed input as seq-tagged frames with the bare clientId once the socket opens', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('h');
term.type('i');
expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([
['h', 1, 'c-test'],
['i', 2, 'c-test'],
]);
});
it('drops a record on its ACK and acknowledges the session idle alert', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('x');
expect(app._pendingDeliveries.get('s-tile')).toHaveLength(1);
ws.receive({ t: 'ia', seq: 1 });
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-tile');
});
it('flushes input typed before the socket opened, in order, once it does', async () => {
const app = makeApp();
// POSTs fail, as they would while the server restarts, so the input waits.
const { ws, term } = await connectTile(app);
fetchMock.mockImplementation(async () => ({ ok: false, status: 503, json: async () => ({}) }));
term.type('a');
term.type('b');
await new Promise((r) => setTimeout(r, 0));
expect(ws.inputFrames()).toEqual([]);
ws.open();
expect(ws.inputFrames().map((f) => [f.d, f.seq])).toEqual([
['a', 1],
['b', 2],
]);
});
it('keeps queuing after the socket closes (HTTP fallback), instead of dropping keystrokes', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
ws.readyState = 3;
ws.onclose?.({ code: 1006 });
const posts: Array<{ input: string; seq: number }> = [];
fetchMock.mockImplementation(async (_url: string, init?: { body?: string }) => {
if (init?.body) posts.push(JSON.parse(init.body));
return { ok: true, status: 200, json: async () => ({}) };
});
term.type('z');
await new Promise((r) => setTimeout(r, 0));
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
expect(ws.inputFrames()).toEqual([]);
});
});
describe('what xterm generates never enters the durable queue', () => {
it('drops a DA query reply entirely', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('\x1b[?1;2c');
expect(ws.inputFrames()).toEqual([]);
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
});
it('sends a mouse report once, without a seq, and never persists it', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('\x1b[<0;10;5M');
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[<0;10;5M' }]);
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
});
it('sends a focus report once, without a seq', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('\x1b[I');
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]);
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
});
});
describe('TerminalTile leaves the input-socket map', () => {
it('on close, so a later keystroke cannot be written into a dead socket', async () => {
const app = makeApp();
const { ws } = await connectTile(app);
ws.open();
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(true);
ws.onclose?.({ code: 1006 });
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
});
it('on destroy, while pending input stays queued for the HTTP sweep', async () => {
const app = makeApp();
const { tile, ws, term } = await connectTile(app);
ws.open();
term.type('q');
tile.destroy();
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['q']);
});
});
const isMarker = (data: string) => data.includes('[disconnected');
/**
* Lets the async buffer refresh settle: the fetch, the body read, the chunked
* write AND the load's finally block, which is where a stale marker would be
* stamped. Too few turns here and that assertion passes vacuously.
*/
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
describe('TerminalTile reconnects after a transient drop', () => {
it('reopens on the backoff, refreshes the buffer, and leaves no stale marker', async () => {
vi.useFakeTimers();
const app = makeApp();
const { tile, ws, term } = await connectTile(app);
ws.open();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: 'fresh screen' } }),
}));
ws.readyState = 3;
ws.onclose?.({ code: 1006 });
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining('[disconnected, reconnecting')]);
expect(FakeSocket.instances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(300);
expect(FakeSocket.instances).toHaveLength(2);
const ws2 = FakeSocket.instances[1];
expect(ws2.url).toBe(ws.url);
ws2.open();
await settle();
// The refresh cleared the pane and replayed the current screen, and nothing
// after that clear is a marker: the pane is healthy again.
const lastClear = term.writes.lastIndexOf('<CLEAR>');
expect(lastClear).toBeGreaterThan(-1);
expect(term.writes.slice(lastClear)).toContain('fresh screen');
expect(term.writes.slice(lastClear).some(isMarker)).toBe(false);
expect(tile._reconnectAttempts).toBe(0);
expect(tile.ws).toBe(ws2);
});
it('counts failed attempts and resets the count only on a successful open', async () => {
vi.useFakeTimers();
const { tile, ws } = await connectTile(makeApp());
ws.open();
ws.onclose?.({ code: 1006 });
await vi.advanceTimersByTimeAsync(300);
FakeSocket.instances[1].onclose?.({ code: 1006 }); // the retry fails too
expect(tile._reconnectAttempts).toBe(2);
await vi.advanceTimersByTimeAsync(1000);
FakeSocket.instances[2].open();
expect(tile._reconnectAttempts).toBe(0);
});
it('reconnects after the redelivery sweep force-closes a silent socket (1005)', async () => {
vi.useFakeTimers();
const { ws } = await connectTile(makeApp());
ws.open();
ws.onclose?.({ code: 1005 });
await vi.advanceTimersByTimeAsync(300);
expect(FakeSocket.instances).toHaveLength(2);
});
it('reconnectNow() skips the backoff, but never replaces an open socket', async () => {
vi.useFakeTimers();
const { tile, ws } = await connectTile(makeApp());
ws.open();
tile.reconnectNow();
expect(FakeSocket.instances).toHaveLength(1);
ws.readyState = 3;
ws.onclose?.({ code: 1006 });
tile.reconnectNow();
expect(FakeSocket.instances).toHaveLength(2);
// The backoff timer it pre-empted must not open a third socket later.
await vi.advanceTimersByTimeAsync(20_000);
expect(FakeSocket.instances).toHaveLength(2);
});
});
describe('TerminalTile stops for good on codes that cannot get better', () => {
it.each([
[4004, 'the session ended'],
[4009, 'the session ended'],
[4003, 'the server refused this connection'],
[4010, 'another connection took over this pane'],
])('close %i: no reconnect, onExit once, marker says why', async (code, reason) => {
vi.useFakeTimers();
const onExit = vi.fn();
const { tile, ws, term } = await connectTile(makeApp(), { onExit });
ws.open();
ws.onclose?.({ code });
await vi.advanceTimersByTimeAsync(30_000);
tile.reconnectNow();
expect(FakeSocket.instances).toHaveLength(1);
expect(onExit).toHaveBeenCalledTimes(1);
expect(onExit).toHaveBeenCalledWith(code);
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining(reason)]);
});
});
describe('TerminalTile ignores a socket it already replaced', () => {
it('a late close (4010) from the old socket neither stops the pane nor unregisters its successor', async () => {
vi.useFakeTimers();
const onExit = vi.fn();
const app = makeApp();
const { ws, term } = await connectTile(app, { onExit });
ws.open();
const lateClose = ws.onclose!;
ws.onclose?.({ code: 1006 });
await vi.advanceTimersByTimeAsync(300);
const ws2 = FakeSocket.instances[1];
ws2.open();
// The server supersedes the old socket by cid; its close arrives late.
lateClose({ code: 4010 });
expect(onExit).not.toHaveBeenCalled();
term.type('still typing');
expect(ws2.inputFrames().map((f) => f.d)).toEqual(['still typing']);
});
});
describe('TerminalTile destroy()', () => {
it('cancels a pending reconnect and never reports an exit afterwards', async () => {
vi.useFakeTimers();
const onExit = vi.fn();
const { tile, ws } = await connectTile(makeApp(), { onExit });
ws.open();
ws.onclose?.({ code: 1006 });
tile.destroy();
await vi.advanceTimersByTimeAsync(30_000);
expect(FakeSocket.instances).toHaveLength(1);
expect(onExit).not.toHaveBeenCalled();
});
});
describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', () => {
const resizeFrames = (ws: FakeSocket) => ws.sent.filter((f) => f.t === 'z');
it('announces its size as a desktop viewer when the socket opens', async () => {
const { ws } = await connectTile(makeApp());
ws.open();
expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
});
it('does not resend an unchanged size, sends a changed one, and force resends', async () => {
const { tile, ws } = await connectTile(makeApp());
ws.open();
tile.fit();
expect(resizeFrames(ws)).toHaveLength(1);
FakeFit.proposed = { cols: 100, rows: 30 };
tile.fit();
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' });
tile.fit({ force: true });
expect(resizeFrames(ws)).toHaveLength(3);
});
it('re-announces an unchanged size on a reconnected socket', async () => {
vi.useFakeTimers();
const { ws } = await connectTile(makeApp());
ws.open();
ws.onclose?.({ code: 1006 });
await vi.advanceTimersByTimeAsync(300);
const ws2 = FakeSocket.instances[1];
ws2.open();
expect(resizeFrames(ws2)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
});
it('applies no 40-column floor: a pane at the divider clamp gets its real width on both sides', async () => {
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
FakeFit.proposed = { cols: 28, rows: 30 };
tile.fit();
expect(term.cols).toBe(28);
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 28, r: 30, v: 'desktop' });
});
it('reports nothing while hidden (the fit addon measures NaN)', async () => {
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
FakeFit.proposed = { cols: NaN, rows: NaN };
tile.fit();
expect(resizeFrames(ws)).toHaveLength(1);
expect([term.cols, term.rows]).toEqual([80, 24]);
});
it('stands aside for a session detached into its own window', async () => {
const { tile, ws } = await connectTile(makeApp(), { detachedSessions: new Set(['s-tile']) });
ws.open();
FakeFit.proposed = { cols: 120, rows: 40 };
tile.fit();
expect(resizeFrames(ws)).toEqual([]);
});
it('adopts the column count the PTY reports, keeping its own rows', async () => {
const { ws, term } = await connectTile(makeApp());
ws.open();
ws.receive({ t: 'zc', c: 132, r: 50 });
expect([term.cols, term.rows]).toEqual([132, 24]);
});
it('leaves the pane alone when the PTY agrees on width', async () => {
const { ws, term } = await connectTile(makeApp());
ws.open();
const before = term.resizes.length;
ws.receive({ t: 'zc', c: 80, r: 60 });
expect(term.resizes.length).toBe(before);
});
});
describe('TerminalTile links and paste follow THIS pane', () => {
it('registers the shared link provider on its own terminal, resolving its own session', async () => {
const app = makeApp();
const register = vi.fn();
app.registerFilePathLinkProvider = register;
const { term } = await connectTile(app);
expect(register).toHaveBeenCalledTimes(1);
const target = register.mock.calls[0][0] as { terminal: unknown; getSessionId: () => string };
expect(target.terminal).toBe(term);
expect(target.getSessionId()).toBe('s-tile');
});
it("routes Ctrl+V into the paste trap with this pane's terminal and session", async () => {
const app = makeApp();
const paste = vi.fn();
app._handleImagePaste = paste;
const { term } = await connectTile(app);
const handled = term.keyHandler!({ type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' });
expect(handled).toBe(false);
expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile' });
});
it('leaves Ctrl+Shift+V (voice input) out of the paste trap', async () => {
const app = makeApp();
const paste = vi.fn();
app._handleImagePaste = paste;
const { term } = await connectTile(app);
term.keyHandler!({ type: 'keydown', key: 'V', ctrlKey: true, shiftKey: true, code: 'KeyV' });
expect(paste).not.toHaveBeenCalled();
});
});
describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => {
const app = makeApp();
const { tile, term } = await connectTile(app);
expect(app._focusedTile ?? null).toBeNull();
term.focusTextarea();
expect(app._focusedTile).toBe(tile);
tile.destroy();
expect(app._focusedTile).toBeNull();
expect(term.focusListeners).toEqual([]);
});
});
describe('the server coming back kicks Pane B', () => {
it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => {
// handleInit needs a whole app to run, so the wiring is pinned by source;
// reconnectNow() itself is exercised above.
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const start = appSource.indexOf('if (keepTerminal && restoreId === previousActiveId');
const branch = appSource.slice(start, appSource.indexOf('} else {', start));
expect(start).toBeGreaterThan(-1);
expect(branch).toContain('this._splitPane?.reconnectNow?.();');
});
});
@@ -1,8 +1,9 @@
// test/split-pane-terminal-unit.test.ts // test/terminal-tile-unit.test.ts
// Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like // Port: N/A (no server/browser; TerminalTile is loaded via `vm`, like
// split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches). // split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches).
// //
// Unit coverage for the two SplitTerminalPane (terminal-split.js) fixes from // Unit coverage for the two TerminalTile (terminal-tile.js, the split pane's
// Pane B until it moved out of terminal-split.js) fixes from
// the final review of #453 that need no browser: destroy() nulling EVERY socket // the final review of #453 that need no browser: destroy() nulling EVERY socket
// handler (onclose used to survive it and fire its "disconnected" write into a // handler (onclose used to survive it and fire its "disconnected" write into a
// pane already torn down), and the `{t:'r'}` server-refresh path being // pane already torn down), and the `{t:'r'}` server-refresh path being
@@ -15,7 +16,7 @@
// The last block covers the scroll-to-top history pull: a burst of output leaves // The last block covers the scroll-to-top history pull: a burst of output leaves
// a shell pane's xterm with about one screen of scrollback while tmux holds every // a shell pane's xterm with about one screen of scrollback while tmux holds every
// line, and Pane B (a separate xterm from the primary pane) never went back to // line, and Pane B (a separate xterm from the primary pane) never went back to
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js. // ask. See _maybeLoadMoreHistory / _pullHistory in terminal-tile.js.
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import vm from 'node:vm'; import vm from 'node:vm';
@@ -73,9 +74,9 @@ const fetchMock = vi.fn();
const rafQueue: Array<() => void> = []; const rafQueue: Array<() => void> = [];
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */ /** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = []; const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8'); const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8');
function loadSplitTerminalPane() { function loadTerminalTile() {
const context = vm.createContext({ const context = vm.createContext({
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() }, console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
// The primary pane's row estimator, reduced to a line count: the pull only // The primary pane's row estimator, reduced to a line count: the pull only
@@ -111,18 +112,18 @@ function loadSplitTerminalPane() {
}); });
// The module's tail patches CodemanApp.prototype; nothing on it runs here. // The module's tail patches CodemanApp.prototype; nothing on it runs here.
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context); vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest }) return (context.window as { TerminalTile: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
.SplitTerminalPane; .TerminalTile;
} }
const SplitTerminalPane = loadSplitTerminalPane(); const TerminalTile = loadTerminalTile();
function makePane( function makePane(
mode = 'claude', mode = 'claude',
mount: unknown = {}, mount: unknown = {},
opts: { detachedSessions?: Set<string> } = {} opts: { detachedSessions?: Set<string> } = {}
): PaneUnderTest & { terminal: FakeTerminal } { ): PaneUnderTest & { terminal: FakeTerminal } {
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts }); const pane = new TerminalTile('s1', mount, { mode, ...opts });
pane.terminal = { pane.terminal = {
// xterm invokes a write's callback once everything before it is parsed. // xterm invokes a write's callback once everything before it is parsed.
write: vi.fn((_data: string, done?: () => void) => done?.()), write: vi.fn((_data: string, done?: () => void) => done?.()),
@@ -175,7 +176,8 @@ function deferred<T>() {
return { promise, resolve }; return { promise, resolve };
} }
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('Pane B disconnected'); // Every marker variant (reconnecting, session ended, refused, taken over) starts the same way.
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[disconnected');
/** Lets every microtask the vm-side promise chain queued run. */ /** Lets every microtask the vm-side promise chain queued run. */
const settle = () => new Promise((r) => setTimeout(r, 0)); const settle = () => new Promise((r) => setTimeout(r, 0));
@@ -187,7 +189,7 @@ beforeEach(() => {
clock = 0; clock = 0;
}); });
describe('SplitTerminalPane.destroy()', () => { describe('TerminalTile.destroy()', () => {
it('nulls every WebSocket handler, onclose included, before closing the socket', () => { it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
const pane = makePane(); const pane = makePane();
const terminal = pane.terminal; const terminal = pane.terminal;
@@ -210,7 +212,7 @@ describe('SplitTerminalPane.destroy()', () => {
}); });
}); });
describe('SplitTerminalPane server-refresh single-flight', () => { describe('TerminalTile server-refresh single-flight', () => {
it('a refresh with nothing in flight clears and fetches straight away', async () => { it('a refresh with nothing in flight clears and fetches straight away', async () => {
const pane = makePane(); const pane = makePane();
fetchMock.mockResolvedValueOnce(jsonResponse('one')); fetchMock.mockResolvedValueOnce(jsonResponse('one'));
@@ -329,7 +331,7 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
}); });
}); });
describe('SplitTerminalPane scroll-to-top history pull', () => { describe('TerminalTile scroll-to-top history pull', () => {
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => { it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
const pane = makePane('shell'); const pane = makePane('shell');
const term = pane.terminal; const term = pane.terminal;
@@ -721,8 +723,11 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
expect(connect).toContain('this._installWheelListener();'); expect(connect).toContain('this._installWheelListener();');
expect(connect).toContain('this._onLiveClear();'); expect(connect).toContain('this._onLiveClear();');
expect(connect).not.toContain('this.terminal.clear();'); expect(connect).not.toContain('this.terminal.clear();');
// The tests below drive the close through _onSocketClosed() directly. // The tests below drive the close through _onSocketClosed() directly; the
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();'); // socket's own handler forwards the close event (and its code) there, and
// only for the current socket (_openSocket).
expect(connect).toContain('this._onSocketClosed(event);');
expect(connect).toMatch(/ws\.onclose = \(event\) => \{\s*if \(ws !== this\.ws\) return;/);
}); });
it('a close with no pull running writes the marker straight away', () => { it('a close with no pull running writes the marker straight away', () => {
@@ -746,9 +751,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
void pane._pullHistory(); void pane._pullHistory();
await settle(); await settle();
const marker = expect.stringContaining('Pane B disconnected'); const marker = expect.stringContaining('[disconnected');
const writes = pane.terminal.write.mock.calls.map((c) => c[0]); const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/)); expect(writes.at(-1)).toEqual(expect.stringMatching(/\[disconnected/));
expect(pane.terminal.write).toHaveBeenCalledWith(marker); expect(pane.terminal.write).toHaveBeenCalledWith(marker);
}); });
@@ -1003,7 +1008,7 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
await settle(); await settle();
for (const call of pane.terminal.write.mock.calls) { for (const call of pane.terminal.write.mock.calls) {
expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/)); expect(call[0]).toEqual(expect.not.stringMatching(/\[disconnected/));
} }
}); });
+175
View File
@@ -0,0 +1,175 @@
/**
* @fileoverview Dictation lands in the session it was started for.
*
* `VoiceInput` (voice-input.js) used to read `app.activeSessionId` when the
* transcript ARRIVED, and again when the green send button or the compose
* overlay's Send was pressed. Both happen seconds after recording started, so a
* user who switched tabs in between had their dictation typed into the other
* session. The target is now captured in `start()` (through `_focusedPane()`,
* so a second terminal pane can claim it later) and every send path uses it.
*
* Loaded via `vm` with a stubbed `app` (no jsdom).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const voiceSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/voice-input.js'), 'utf8');
type Voice = {
start: () => void;
_insertText: (text: string) => void;
_resolveProvider: () => string;
_startWebSpeech: () => void;
_showComposeOverlay: (text: string) => void;
_voiceSendHandler: (() => void) | null;
_targetSessionId: string | null;
};
function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string } = {}) {
const sendInput = vi.fn(async () => {});
const sendInputAsync = vi.fn();
const appendText = vi.fn();
const showToast = vi.fn();
const gear = {
classList: { contains: () => false, add: vi.fn(), remove: vi.fn() },
innerHTML: '',
title: '',
getAttribute: () => null,
setAttribute: vi.fn(),
removeAttribute: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
const app: Record<string, unknown> = {
activeSessionId: 'session-a',
sessions: new Map([
['session-a', {}],
['session-b', {}],
]),
sendInput,
_sendInputAsync: sendInputAsync,
showToast,
terminal: { focus: vi.fn() },
_localEchoEnabled: !!opts.localEcho,
_localEchoOverlay: opts.localEcho ? { appendText, pendingText: '', clear: vi.fn() } : null,
};
if (opts.focused) app._focusedPane = () => ({ sessionId: opts.focused });
const context = vm.createContext({
console,
setTimeout: (fn: () => void) => fn(),
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
app,
localStorage: {
getItem: (key: string) =>
key === 'codeman-voice-settings' ? JSON.stringify({ insertMode: opts.insertMode || 'direct' }) : null,
setItem: () => {},
},
document: {
querySelector: (sel: string) => (sel === '.btn-settings' ? gear : null),
createElement: () => ({}),
body: { appendChild: () => {} },
},
window: {},
navigator: {},
});
vm.runInContext(`${voiceSource}\nglobalThis.__VoiceInput = VoiceInput;`, context);
const voice = (context as unknown as { __VoiceInput: Voice }).__VoiceInput;
// Recording itself is out of scope: start() only has to pick the target.
voice._resolveProvider = () => 'webspeech';
voice._startWebSpeech = vi.fn();
return { voice, app, sendInput, sendInputAsync, appendText, showToast, gear };
}
describe('dictation target', () => {
it('sends to the session recording started in, even after a tab switch', () => {
const { voice, app, sendInput, sendInputAsync } = load();
voice.start();
app.activeSessionId = 'session-b'; // user switched tabs while speaking
voice._insertText('fix the login bug');
expect(sendInputAsync).toHaveBeenCalledWith('session-a', 'fix the login bug', { useMux: true });
expect(sendInput).not.toHaveBeenCalled();
});
it('keeps the existing path when the target is still the active session', () => {
const { voice, sendInput, sendInputAsync } = load();
voice.start();
voice._insertText('hello');
expect(sendInput).toHaveBeenCalledWith('hello');
expect(sendInputAsync).not.toHaveBeenCalled();
});
it('never types another session dictation into the active local-echo overlay', () => {
const { voice, app, appendText, sendInputAsync } = load({ localEcho: true });
voice.start();
app.activeSessionId = 'session-b';
voice._insertText('for session a');
expect(appendText).not.toHaveBeenCalled();
expect(sendInputAsync).toHaveBeenCalledWith('session-a', 'for session a', { useMux: true });
});
it('still uses the local-echo overlay for the active session', () => {
const { voice, appendText, sendInput } = load({ localEcho: true });
voice.start();
voice._insertText('typed locally');
expect(appendText).toHaveBeenCalledWith('typed locally');
expect(sendInput).not.toHaveBeenCalled();
});
it('takes its target from the focused pane when one is reported', () => {
const { voice, sendInputAsync } = load({ focused: 'session-b' });
voice.start();
voice._insertText('into pane b');
expect(sendInputAsync).toHaveBeenCalledWith('session-b', 'into pane b', { useMux: true });
});
it('the green send button sends Enter to the dictation target', () => {
const { voice, app, gear, sendInput, sendInputAsync } = load();
voice.start();
voice._insertText('ship it');
app.activeSessionId = 'session-b';
const handler = gear.addEventListener.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as () => void;
handler();
expect(sendInputAsync).toHaveBeenLastCalledWith('session-a', '\r', { useMux: true });
// Only the original insert went through sendInput (target was active then).
expect(sendInput).toHaveBeenCalledTimes(1);
});
it('drops dictation for a session that closed meanwhile, with a toast', () => {
const { voice, app, sendInput, sendInputAsync, showToast } = load();
voice.start();
app.activeSessionId = 'session-b';
(app.sessions as Map<string, unknown>).delete('session-a');
voice._insertText('too late');
expect(sendInput).not.toHaveBeenCalled();
expect(sendInputAsync).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith('That session has closed; dictation not sent', 'warning');
});
it('refuses to start with no session at all', () => {
const { voice, app, showToast } = load();
app.activeSessionId = null;
voice.start();
expect(showToast).toHaveBeenCalledWith('No active session', 'warning');
expect(voice._targetSessionId).toBeNull();
});
});