diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 0b3a5396..c2ac4e77 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -801,7 +801,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ### Split-pane sessions -**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell 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 copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `buildSplitPickerSessions()` also excludes any session with `pid === null` (an exited CLI, a crash-looped session whose breaker tripped, a restore that never re-attached): Pane B has no equivalent of `selectSession()`'s auto re-attach POST, so a pane opened onto one has nothing reading its tmux pane — no `terminal` events ever arrive, and `Session.write()` silently drops every keystroke with no ack either way, so the loss is invisible behind a socket that reports healthy. Pane B's keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. That includes the click report a tile hand-encodes for a mouse-strip CLI: `TerminalTile._installClickListener` passes `ephemeral: true` in its target, which `_sendSyntheticSgrTap` honours, while the primary pane's own report (no flag) stays on `_sendInputAsync` as before. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-plan.md`. +**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell 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 copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `buildSplitPickerSessions()` also excludes any session with `pid === null` (an exited CLI, a crash-looped session whose breaker tripped, a restore that never re-attached): Pane B has no equivalent of `selectSession()`'s auto re-attach POST, so a pane opened onto one has nothing reading its tmux pane — no `terminal` events ever arrive, and `Session.write()` silently drops every keystroke with no ack either way, so the loss is invisible behind a socket that reports healthy. Pane B's keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. That includes the click report a tile hand-encodes for a mouse-strip CLI: `TerminalTile._installClickListener` passes `ephemeral: true` in its target, which `_sendSyntheticSgrTap` honours, while the primary pane's own report (no flag) stays on `_sendInputAsync` as before. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ A server `{t:'c'}` frame is a REFRESH, never a bare `clear()` (`_onLiveClear()` calls `_refreshBuffer()`): its one emitter is a fresh Claude pane's first prompt (`Session.startInteractive`, "refresh after startup"), the primary pane answers it with a refetch and replay (`_onSessionClearTerminal`, which stands aside while the grid is open), and xterm's `clear()` keeps only the cursor's row, so a Claude session Run into the grid or Attached in a tile came up as a near-empty tile that an idle Claude never repainted. As a refresh it coalesces behind a load already running (a pull's held frames included) and waits its turn in the grid's load queue. ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-plan.md`. ### Tile grid diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 378c929f..e470f759 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -810,19 +810,20 @@ 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. + // The server's `{t:'c'}` frame, which is a refresh, not a wipe. Its one + // emitter (Session.startInteractive, session.ts) sends it once a fresh Claude + // pane first shows its prompt: the server has just trimmed its own buffer and + // means "refresh after startup". The primary pane refetches the capture and + // replays it (_onSessionClearTerminal, app.js), and while the grid is open + // that handler stands aside for the tiles. A bare xterm clear() here kept + // only the cursor's row and dropped the banner and every row above it, and an + // idle Claude never repaints static rows, so a Claude session Run into the + // grid (or Attached in a tile) sat there as a near-empty tile. So it takes + // the `{t:'r'}` route: single-flight, coalesced into one trailing refresh + // behind a load already running (a pull's held frames included), and paced + // by the grid's load queue. _onLiveClear() { - if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true }); - else this._clearTerminal(); - } - - // A clear leaves no rows above the screen, the pane's own overflow included. - _clearTerminal() { - this.terminal?.clear(); - this._overflowRows = 0; + this._refreshBuffer(); } // Capture phase, because xterm's own wheel handler stopPropagation()s every @@ -931,8 +932,8 @@ } // History rows in this xterm, for the paging gate: baseY less the rows this - // pane pushed up itself. Clamped, because a clear (Ctrl+L, a `{t:'c'}` - // frame) or an ED3/RIS in the stream drops rows behind this count's back. + // pane pushed up itself. Clamped, because a clear (Ctrl+L) or an ED3/RIS in + // the stream drops rows behind this count's back. _localRows() { const baseY = this.terminal?.buffer?.active?.baseY || 0; this._overflowRows = Math.min(this._overflowRows, baseY); @@ -1103,8 +1104,7 @@ const cutoff = replayed ? capturedAt : 0; for (const entry of queued) { if (entry.at < cutoff) continue; - if (entry.clear) this._clearTerminal(); - else this.terminal?.write(entry.data); + 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 diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index a3873bf1..74f2e7a2 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -387,6 +387,55 @@ describe('TerminalTile reconnects after a transient drop', () => { }); }); +describe("the server's {t:'c'} frame refreshes the tile (a Claude pane's first prompt)", () => { + // Session.startInteractive (session.ts) sends it once a fresh Claude pane + // shows its prompt, meaning "refresh after startup"; the primary pane refetches + // and replays (_onSessionClearTerminal). A tile used to run a bare xterm + // clear(), which kept only the cursor's row: banner and transcript gone, and an + // idle Claude never repaints them. + it('refetches the capture and replays it, never a bare clear', async () => { + const { ws, term } = await connectTile(makeApp()); + ws.open(); + fetchMock.mockClear(); + fetchMock.mockImplementation(async () => ({ + ok: true, + status: 200, + json: async () => ({ data: { terminalBuffer: 'Claude Code banner\r\n❯ ' } }), + })); + + ws.receive({ t: 'o', d: 'banner painted live' }); + ws.receive({ t: 'c' }); + await settle(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1'); + expect(term.writes.at(-1)).toBe('Claude Code banner\r\n❯ '); + }); + + it('two clear frames during one refresh fetch once more, not twice', async () => { + const { ws } = await connectTile(makeApp()); + ws.open(); + fetchMock.mockClear(); + let release!: () => void; + fetchMock.mockImplementationOnce( + () => + new Promise((resolveFetch) => { + release = () => + resolveFetch({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: 'a' } }) }); + }) + ); + + ws.receive({ t: 'c' }); + ws.receive({ t: 'c' }); + ws.receive({ t: 'c' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + release(); + await settle(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + describe('TerminalTile stops for good on codes that cannot get better', () => { it.each([ [4004, 'the session ended'], diff --git a/test/terminal-tile-unit.test.ts b/test/terminal-tile-unit.test.ts index 8eb28d48..5f602e49 100644 --- a/test/terminal-tile-unit.test.ts +++ b/test/terminal-tile-unit.test.ts @@ -670,53 +670,85 @@ describe('TerminalTile scroll-to-top history pull', () => { expect(pane.terminal.write).toHaveBeenCalledWith('refreshed'); }); - it('a clear frame during the pull is queued in order, never applied under the replay', async () => { + // The server's `{t:'c'}` means "refresh after startup" (its one emitter is a + // fresh Claude pane's first prompt, session.ts), and the primary pane answers + // it with a refetch and replay (_onSessionClearTerminal). The three below + // replace two tests that pinned it as a bare xterm clear(), which kept only + // the cursor's row: a Claude session Run into the grid came up a near-empty + // tile. + it('a clear frame during the pull is coalesced into one refresh behind it, never applied under the replay', async () => { const pane = makePane('shell'); const term = pane.terminal; const order: string[] = []; term.write.mockImplementation((data: string, done?: () => void) => { - order.push(`write:${data}`); + if (data) order.push(`write:${data}`); done?.(); }); term.clear.mockImplementation(() => order.push('clear')); const held = headersOnly(); - fetchMock.mockResolvedValueOnce(held.response); + fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('after startup')); pane._maybeLoadMoreHistory(); await settle(); pane._onLiveOutput('before'); pane._onLiveClear(); + pane._onLiveClear(); // a second one joins the same trailing refresh pane._onLiveOutput('after'); - // Held: clearing now would wipe a half-written snapshot. + // Held: nothing touches the screen under the pull, and nothing fetches yet. expect(order).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(pane._bufferRefreshPending).toBe(true); held.release(rowsOf(30)); // nothing to gain: no replay await settle(); - expect(order).toEqual(['write:before', 'clear', 'write:after']); + // The held frames land in order, then ONE refresh fetches the pane's + // current screen and replays it last. + expect(order.slice(0, 2)).toEqual(['write:before', 'write:after']); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + `/api/sessions/s1/terminal?tail=${TERMINAL_TAIL_SIZE}`, + expect.anything() + ); + expect(order.at(-1)).toBe('write:after startup'); expect(pane._liveQueue).toBeNull(); - - // With nothing in flight a clear frame applies straight away. - pane._onLiveClear(); - expect(order.at(-1)).toBe('clear'); + expect(pane._bufferLoading).toBe(false); + expect(pane._bufferRefreshPending).toBe(false); }); - it('a clear that arrived before the capture is not replayed after it', async () => { + it('a clear frame with nothing in flight refetches the capture and replays it, like a refresh frame', async () => { + const pane = makePane(); + fetchMock.mockResolvedValueOnce(jsonResponse('banner\r\n❯ ')); + + pane._onLiveClear(); + await settle(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything()); + expect(screenWrites(pane).at(-1)).toBe('banner\r\n❯ '); + expect(pane._bufferLoading).toBe(false); + }); + + it('a clear frame while a pull waits for its response does not touch the screen, and refreshes behind the pull', async () => { const pane = makePane('shell'); const term = pane.terminal; const response = deferred>(); - fetchMock.mockReturnValueOnce(response.promise); + fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed')); pane._maybeLoadMoreHistory(); clock = 1; - pane._onLiveClear(); // before the response: applied now, already in the capture - expect(term.clear).toHaveBeenCalledTimes(1); + pane._onLiveClear(); // before the response + expect(term.clear).not.toHaveBeenCalled(); + expect(pane._bufferRefreshPending).toBe(true); clock = 2; response.resolve(jsonResponse(rowsOf(100))); await settle(); - expect(term.write).toHaveBeenCalledWith('\x1bc'); - expect(term.clear).toHaveBeenCalledTimes(1); // not replayed after the capture + const writes = screenWrites(pane); + expect(writes).toContain(rowsOf(100)); // the pull still replayed + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(writes.at(-1)).toBe('refreshed'); + expect(pane._bufferLoading).toBe(false); }); it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => { diff --git a/test/tile-grid-load-queue.test.ts b/test/tile-grid-load-queue.test.ts index 69ff3ae0..b0628b7d 100644 --- a/test/tile-grid-load-queue.test.ts +++ b/test/tile-grid-load-queue.test.ts @@ -278,6 +278,24 @@ describe('refreshes', () => { expect(b.terminal?.writes.slice(-2)).toEqual(['', 'fresh']); }); + it("a server {t:'c'} (a Claude pane's first prompt) is a refresh through the same queue", async () => { + const { tiles } = makeGrid(['a', 'b']); + await connectAll(tiles); + const [a, b] = tiles; + a.ws?.receive({ t: 'r' }); + b.ws?.receive({ t: 'c' }); + await settle(); + + // b waits behind a, like any refresh: one capture in flight. + expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']); + expect(await drain('banner')).toBe(1); + expect(captures.map((c) => c.url)).toEqual([ + `/api/sessions/a/terminal?full=1&tail=${TAIL}${LINES}`, + `/api/sessions/b/terminal?full=1&tail=${TAIL}${LINES}`, + ]); + expect(b.terminal?.writes.at(-1)).toBe('banner'); + }); + it('a history pull jumps ahead of background refreshes', async () => { const { tiles } = makeGrid(['a', 'b', 'sh'], { modes: { sh: 'shell' } }); await connectAll(tiles);