diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index c2ac4e77..3640dfc5 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 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`. +**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 resets the screen only once its own capture is in hand), 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 settles it itself (its replay's queued `\x1bc` would wipe a marker written now, so it re-owes the marker on a closed socket and writes the one copy below its replay, and a refresh that writes nothing stamps the one still owed). The marker stays owed instead of being written twice; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously, so an async-parse fake there pins what ends up on screen. Anything that wipes the terminal on a closed socket (a pull's or a refresh's `\x1bc`) 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. ⚠️ A refresh (`{t:'r'}`, `{t:'c'}`, a reconnect) runs in the primary pane's order (`_onSessionNeedsRefresh`): fetch FIRST, so the pane keeps its last frame through the round trip and through a grid tile's wait; hold live frames from the response on (`_liveQueue`; the body read of a bounded window, a grid tile's or a shell's, gets the 10 s budget, while Pane B's unbounded `full=1` keeps the request's); then the queued in-stream `\x1bc` immediately before the replay, then the held frames that arrived after the response. Never xterm's `clear()`: it is synchronous while queued bytes are parsed after it (they fused into the snapshot) and it keeps the cursor's row and column, where the capture (raw rows, no home) then started. A failed, aborted or empty fetch writes nothing and resets nothing. ⚠️ 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 e470f759..c0841973 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -49,7 +49,8 @@ */ (function (global) { - // How long a scroll-to-top history pull may hold this pane's live output. + // How long a load may hold this pane's live output while it reads a bounded + // body: a scroll-to-top history pull, or a refresh of a bounded window. const HISTORY_PULL_TIMEOUT_MS = 10000; // How much of a replay is queued in xterm at once: a 1 MiB load goes in one @@ -666,10 +667,11 @@ // 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. + // A trailing refresh is about to run, and it settles the marker itself: + // a replay's queued `\x1bc` would wipe one written here (it re-owes the + // marker on a closed socket and stamps it below the replay), and a refresh + // that writes nothing stamps the one still owed. Stamped here as well, + // there would be two marker writes for one close. if (this._bufferRefreshPending && !this._destroyed) return; const owed = this._markerOwed; this._markerOwed = false; @@ -683,11 +685,12 @@ } // Fetches and writes the session's current scrollback. Used both by - // connect() (initial load) and by the `{t:'r'}` server-refresh frame - // (above). 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. + // connect() (initial load) and by the refresh frames (`{t:'r'}`, `{t:'c'}`) + // and a reconnect (_refreshBuffer). The primary pane's own + // _onSessionNeedsRefresh (app.js) is scoped to `this.activeSessionId` and + // rewrites the primary terminal, neither of which applies to this + // independent pane, so this is a standalone equivalent rather than a call + // into it, in the primary's order (below). // // Mirrors the primary pane's own mode check (app.js's selectSession / // _onSessionNeedsRefresh): a shell session can retain hundreds of @@ -699,6 +702,21 @@ // wrapper (constants.js), which already prefixes CodemanBase, unlike the // raw WebSocket URL above, which does not. // + // A refresh replaces what the pane shows, in the primary pane's order + // (_onSessionNeedsRefresh, _resetTerminalForReplay): fetch FIRST, so the + // pane keeps its last frame through the round trip (and through a grid + // tile's wait in the load queue); then the queued in-stream `\x1bc`, never + // xterm's clear(): clear() is synchronous while write() is parsed on a later + // tick, so live bytes still queued would land after it and fuse into the + // snapshot, and it keeps the cursor's row, column, SGR and margins, so the + // capture (raw rows, no home) started wherever the cursor sat. Live frames + // from the response onward are held (`_liveQueue`, the primary's + // _finishBufferLoad `since` rule, as _pullHistory() holds them) and only + // those that arrived after it are written behind the replay. A failed, + // aborted or empty fetch writes nothing and resets nothing: the pane keeps + // its last frame and every held frame. The initial load needs none of + // this: it runs before the pane has a socket, onto a fresh xterm. + // // 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 @@ -709,42 +727,60 @@ this._bufferLoading = true; await this._runLoad(refresh ? 'refresh' : 'initial', async () => { this._loadRunning = true; + let replayed = false; + let capturedAt = 0; + // A deadline covering the body as well as the headers (the primary + // pane's budgets, CodemanFetchDeadline): a capture that never answers + // would otherwise hold this pane's single-flight flag, and in the grid + // the one load queue every tile waits behind, forever. Re-armed once a + // refresh's headers land (below), so one signal carries both budgets. + 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 { if (this._destroyed) return; - if (refresh) { - // Cleared at the load's turn, not when it was asked for: a grid tile - // waiting in the queue keeps its last frame instead of sitting blank. - this.terminal?.clear(); - this._overflowRows = 0; - // 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; - } const shell = this.sessionMode === 'shell'; let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`; - // A deadline covering the body as well as the headers (the primary - // pane's budgets, CodemanFetchDeadline): a capture that never answers - // would otherwise hold this pane's single-flight flag, and in the grid - // the one load queue every tile waits behind, forever. - const controller = global.AbortController ? new global.AbortController() : null; this._loadAbort = controller; - const budget = global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000; - const timer = controller ? setTimeout(() => controller.abort(), budget) : null; + armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000); let payload; try { const res = await fetch( `/api/sessions/${this.sessionId}/terminal?${query}`, controller ? { signal: controller.signal } : undefined ); + if (refresh) { + // The response's arrival stands in for the instant tmux took the + // capture (see _pullHistory()). Frames from here on are news the + // capture cannot hold, so they wait for the replay. From now on + // live output IS held, so a bounded window's body (at most + // TERMINAL_TAIL_SIZE) gets the pull's short budget; an unbounded + // capture (the split's Pane B, up to 32 MB) keeps the request's. + capturedAt = performance.now(); + this._liveQueue = []; + if (shell || this.boundedLoad) armDeadline(HISTORY_PULL_TIMEOUT_MS); + } payload = (await res.json())?.data ?? {}; } finally { - clearTimeout(timer); + clearTimeout(abortTimer); this._loadAbort = null; } - if (payload.terminalBuffer && this.terminal) { + if (payload.terminalBuffer && this.terminal && !this._destroyed) { + if (refresh) { + this.terminal.write('\x1bc'); + this._overflowRows = 0; // the reset leaves nothing above the screen + replayed = true; + // The reset 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; + } await writeChunked( this.terminal, payload.terminalBuffer, @@ -756,7 +792,12 @@ } catch { /* Best-effort: live output still arrives once the socket connects. */ } finally { + clearTimeout(abortTimer); + this._loadAbort = null; this._loadRunning = false; + // Held frames before the marker, so the marker stays the last thing on + // screen (see _pullHistory()). + this._flushLiveQueue(replayed ? capturedAt : 0); this._stampMarkerIfOwed(); this._endBufferLoad(); } @@ -800,16 +841,29 @@ } } - // 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. + // Live terminal output. Written straight through, except while a refresh or + // 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 written behind the snapshot by that load's _flushLiveQueue() (the + // primary pane's _finishBufferLoad `since` rule), never underneath it. _onLiveOutput(data) { if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data }); else this.terminal?.write(data); } + // Releases the frames a load held (_liveQueue) and closes the queue. After a + // replay only those that arrived after the capture are news (`cutoff`, the + // response's arrival; earlier ones are already in it); with no replay + // (`cutoff` 0) every one is. + _flushLiveQueue(cutoff) { + const queued = this._liveQueue ?? []; + this._liveQueue = null; + for (const entry of queued) { + if (entry.at < cutoff) continue; + this.terminal?.write(entry.data); + } + } + // 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 @@ -1097,15 +1151,9 @@ clearTimeout(abortTimer); this._loadAbort = null; this._loadRunning = false; - 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; - this.terminal?.write(entry.data); - } + this._flushLiveQueue(replayed ? capturedAt : 0); // 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 @@ -1128,12 +1176,13 @@ return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`; } - // The `{t:'r'}` server-refresh path: clear, then replay. Two refresh - // frames in a row must not 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. + // The refresh path (`{t:'r'}`, `{t:'c'}`, a reconnect): fetch, then reset + // in-stream and replay (_loadBuffer). Two refresh frames in a row must not + // start two concurrent replays, each resetting 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; diff --git a/test/mocks/terminal-tile-fakes.ts b/test/mocks/terminal-tile-fakes.ts index f68428a0..eaddc2f1 100644 --- a/test/mocks/terminal-tile-fakes.ts +++ b/test/mocks/terminal-tile-fakes.ts @@ -64,7 +64,8 @@ export class FakeTerminal { /** * Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow * what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines - * beyond the screen, a clear leaves one line, and a resize recomputes it + * beyond the screen, a clear or an in-stream reset (RIS, `\x1bc`) leaves one + * line, and a resize recomputes it * (a row-shrinking fit pushes rows above the screen, a growing one pulls them * back). The viewport follows the bottom. Off, `baseY` stays where a test * puts it. @@ -155,7 +156,10 @@ export class FakeTerminal { // (its callback) that everything before it has been parsed. if (data) this.writes.push(data); if (data && this.emulate) { - this.lineCount += data.split('\n').length - 1; + // A replay's reset (RIS) empties the buffer, as clear() does. + const reset = data.lastIndexOf('\x1bc'); + if (reset !== -1) this.lineCount = 1; + this.lineCount += data.slice(reset === -1 ? 0 : reset + 2).split('\n').length - 1; this.settleRows(); } if (!this.holdParse) cb?.(); diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index 74f2e7a2..496cced1 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -333,12 +333,14 @@ describe('TerminalTile reconnects after a transient drop', () => { 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(''); - expect(lastClear).toBeGreaterThan(-1); - expect(term.writes.slice(lastClear)).toContain('fresh screen'); - expect(term.writes.slice(lastClear).some(isMarker)).toBe(false); + // The refresh reset the pane in-stream (never xterm's clear()) and replayed + // the current screen, and nothing after that reset is a marker: the pane is + // healthy again. + expect(term.writes).not.toContain(''); + const lastReset = term.writes.lastIndexOf('\x1bc'); + expect(lastReset).toBeGreaterThan(-1); + expect(term.writes.slice(lastReset)).toEqual(['\x1bc', 'fresh screen']); + expect(term.writes.slice(lastReset).some(isMarker)).toBe(false); expect(tile._reconnectAttempts).toBe(0); expect(tile.ws).toBe(ws2); }); diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 3c6b60a3..1ad5459e 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -436,11 +436,18 @@ describe('rows the tile pushed above the screen itself are not history', () => { expect(flushed(ws)).toEqual([]); }); - it('forgets the overflow on a server clear, so later real history counts', async () => { + it('forgets the overflow when a server clear refreshes the tile, so later real history counts', async () => { + // A `{t:'c'}` is a refresh (a fresh Claude pane's first prompt), and its + // in-stream reset leaves nothing above the screen; the fresh capture is one + // line, so it adds no overflow of its own. serveCapture(lines(40), 40); const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + expect(term.buffer.active.baseY).toBe(16); + serveCapture('fresh screen', 24); ws.receive({ t: 'c' }); + await vi.advanceTimersByTimeAsync(0); + expect(term.writes.slice(-2)).toEqual(['\x1bc', 'fresh screen']); expect(term.buffer.active.baseY).toBe(0); ws.receive({ t: 'o', d: '\r\n'.repeat(term.rows + 1) }); // two real lines above the screen expect(term.buffer.active.baseY).toBe(2); diff --git a/test/terminal-tile-unit.test.ts b/test/terminal-tile-unit.test.ts index 5f602e49..55f95e50 100644 --- a/test/terminal-tile-unit.test.ts +++ b/test/terminal-tile-unit.test.ts @@ -203,6 +203,10 @@ function holdParses(pane: { terminal: FakeTerminal }) { }; } +/** The in-stream reset (RIS) a refresh queues right before its replay, never xterm's clear(). */ +const RIS = '\x1bc'; +const resets = (pane: { terminal: FakeTerminal }) => screenWrites(pane).filter((data) => data === RIS).length; + /** Lets every microtask the vm-side promise chain queued run. */ const settle = () => new Promise((r) => setTimeout(r, 0)); @@ -236,17 +240,25 @@ describe('TerminalTile.destroy()', () => { }); describe('TerminalTile server-refresh single-flight', () => { - it('a refresh with nothing in flight clears and fetches straight away', async () => { + // These used to count xterm clear() calls: the refresh wiped the pane with a + // synchronous clear() BEFORE its fetch. It now fetches first and resets with + // the queued in-stream RIS right before the replay (CLAUDE.md, Terminal + // resilience), so they count that reset instead, and pin clear() at zero. + it('a refresh with nothing in flight fetches straight away, then resets in-stream and replays', async () => { const pane = makePane(); fetchMock.mockResolvedValueOnce(jsonResponse('one')); pane._refreshBuffer(); + // Fetching: the pane keeps its last frame, nothing written or cleared yet. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(pane.terminal.write).not.toHaveBeenCalled(); await settle(); - expect(pane.terminal.clear).toHaveBeenCalledTimes(1); + expect(pane.terminal.clear).not.toHaveBeenCalled(); // The second argument carries the load's deadline (an AbortSignal). expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything()); - expect(pane.terminal.write).toHaveBeenCalledWith('one'); + // The reset immediately before the replay, queued in the same stream. + expect(screenWrites(pane)).toEqual([RIS, 'one']); expect(pane._bufferLoading).toBe(false); }); @@ -260,35 +272,37 @@ describe('TerminalTile server-refresh single-flight', () => { expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`, expect.anything()); }); - it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => { + it('refreshes arriving mid-fetch neither reset nor fetch again, and run ONCE after the replay lands', async () => { const pane = makePane(); const first = deferred>(); const second = deferred>(); fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); pane._refreshBuffer(); - expect(pane.terminal.clear).toHaveBeenCalledTimes(1); + expect(resets(pane)).toBe(0); // fetch first expect(fetchMock).toHaveBeenCalledTimes(1); // Two more frames while the first replay is still in flight. pane._refreshBuffer(); pane._refreshBuffer(); - expect(pane.terminal.clear).toHaveBeenCalledTimes(1); + expect(resets(pane)).toBe(0); expect(fetchMock).toHaveBeenCalledTimes(1); expect(pane._bufferRefreshPending).toBe(true); first.resolve(jsonResponse('replay-1')); await settle(); - expect(pane.terminal.write).toHaveBeenCalledWith('replay-1'); - // Exactly one trailing re-run for the two coalesced frames, not two. - expect(pane.terminal.clear).toHaveBeenCalledTimes(2); + expect(screenWrites(pane)).toEqual([RIS, 'replay-1']); + // Exactly one trailing re-run for the two coalesced frames, not two; it is + // fetching, so it has not reset anything yet. expect(fetchMock).toHaveBeenCalledTimes(2); + expect(resets(pane)).toBe(1); second.resolve(jsonResponse('replay-2')); await settle(); - expect(screenWrites(pane).at(-1)).toBe('replay-2'); + expect(screenWrites(pane)).toEqual([RIS, 'replay-1', RIS, 'replay-2']); + expect(pane.terminal.clear).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledTimes(2); expect(pane._bufferLoading).toBe(false); expect(pane._bufferRefreshPending).toBe(false); @@ -303,9 +317,11 @@ describe('TerminalTile server-refresh single-flight', () => { pane._refreshBuffer(); await settle(); - // Every slice queued at once, then the empty write whose callback ends the - // replay: nothing waits for an animation frame. + // The reset, then every slice queued at once, then the empty write whose + // callback ends the replay: nothing waits for an animation frame. + expect(pane.terminal.write.mock.calls[0][0]).toBe(RIS); expect(pane.terminal.write.mock.calls.map((call) => call[0].length)).toEqual([ + RIS.length, TERMINAL_CHUNK_SIZE, TERMINAL_CHUNK_SIZE, 5, @@ -314,17 +330,19 @@ describe('TerminalTile server-refresh single-flight', () => { // xterm is still parsing: the replay, and with it the flag, is not done. expect(pane._bufferLoading).toBe(true); - // A refresh mid-parse must not clear the terminal under the replay, nor + // A refresh mid-parse must not reset the terminal under the replay, nor // start a second fetch. pane._refreshBuffer(); - expect(pane.terminal.clear).toHaveBeenCalledTimes(1); + expect(resets(pane)).toBe(1); expect(fetchMock).toHaveBeenCalledTimes(1); fetchMock.mockResolvedValueOnce(jsonResponse('after')); xterm.parse(); await settle(); - // Parsed: the coalesced refresh runs now, once. - expect(pane.terminal.clear).toHaveBeenCalledTimes(2); + // Parsed: the coalesced refresh runs now, once, and resets before its replay. + expect(resets(pane)).toBe(2); + expect(screenWrites(pane).slice(-2)).toEqual([RIS, 'after']); + expect(pane.terminal.clear).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledTimes(2); expect(pane._bufferLoading).toBe(true); @@ -344,7 +362,10 @@ describe('TerminalTile server-refresh single-flight', () => { pane._refreshBuffer(); await settle(); - const queued = () => pane.terminal.write.mock.calls.reduce((n, call) => n + call[0].length, 0); + // The replay's own bytes; the reset queued ahead of them is checked apart. + expect(pane.terminal.write.mock.calls[0][0]).toBe(RIS); + const queued = () => + pane.terminal.write.mock.calls.slice(1).reduce((n: number, call: unknown[]) => n + (call[0] as string).length, 0); expect(queued()).toBe(TERMINAL_TAIL_SIZE); xterm.parse(); @@ -403,6 +424,137 @@ describe('TerminalTile server-refresh single-flight', () => { }); }); +describe('TerminalTile refresh order: fetch, then the in-stream reset, then the held frames', () => { + // The primary pane's order (_onSessionNeedsRefresh / _resetTerminalForReplay, + // CLAUDE.md "Terminal resilience"). The refresh used to wipe the pane with a + // synchronous xterm clear() before its fetch and wrote live frames straight + // through the fetch and the replay: queued bytes fused into the snapshot, the + // capture started at the old cursor column, and a failed fetch left it blank. + it.each([ + ['the request fails', () => fetchMock.mockRejectedValueOnce(new Error('offline'))], + ['the capture is empty', () => fetchMock.mockResolvedValueOnce(jsonResponse(''))], + ])('when %s, the screen is left exactly as it was', async (_label, arrange) => { + const pane = makePane(); + arrange(); + + pane._refreshBuffer(); + await settle(); + + expect(pane.terminal.write).not.toHaveBeenCalled(); + expect(pane.terminal.clear).not.toHaveBeenCalled(); + expect(pane._liveQueue).toBeNull(); + expect(pane._bufferLoading).toBe(false); + }); + + it('a body read that fails after the headers resets nothing and writes every held frame, in order', async () => { + const pane = makePane(); + const held = headersOnly(); + fetchMock.mockResolvedValueOnce(held.response); + + pane._refreshBuffer(); + await settle(); // headers landed: the queue is open + pane._onLiveOutput('frame-A'); + pane._onLiveOutput('frame-B'); + expect(pane.terminal.write).not.toHaveBeenCalled(); + held.fail(); + await settle(); + + expect(screenWrites(pane)).toEqual(['frame-A', 'frame-B']); + expect(pane.terminal.clear).not.toHaveBeenCalled(); + expect(pane._liveQueue).toBeNull(); + expect(pane._bufferLoading).toBe(false); + }); + + it('writes frames from before the response straight through, holds later ones, and writes them behind the replay', async () => { + const pane = makePane(); + const xterm = holdParses(pane); + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise); + + pane._refreshBuffer(); + expect(pane._liveQueue).toBeNull(); // the round trip holds nothing + clock = 1; + pane._onLiveOutput('early'); // the pane keeps painting; the capture then replaces it + clock = 2; // the response arrives: this is the cutoff + response.resolve(jsonResponse('snapshot')); + await settle(); + // The replay is queued and xterm is still parsing it. + expect(pane._liveQueue).not.toBeNull(); + clock = 3; + pane._onLiveOutput('late'); // news the capture cannot hold + expect(screenWrites(pane)).toEqual(['early', RIS, 'snapshot']); + + xterm.parse(); + await settle(); + + // 'early' sits before the reset (wiped by it, never repeated); 'late' lands after the snapshot. + expect(screenWrites(pane)).toEqual(['early', RIS, 'snapshot', 'late']); + expect(pane.terminal.clear).not.toHaveBeenCalled(); + expect(pane._liveQueue).toBeNull(); + expect(pane._bufferLoading).toBe(false); + }); + + it('drops a held frame that arrived before the response once the replay covers it', async () => { + // The response's arrival is the cutoff (the primary pane's `since`): a frame + // held with an earlier stamp is already in the capture. + const pane = makePane(); + const held = headersOnly(); + clock = 5; + fetchMock.mockResolvedValueOnce(held.response); + + pane._refreshBuffer(); + await settle(); // headers landed at clock 5 + clock = 4; // stamped before the cutoff (a stand-in for a frame the capture holds) + pane._onLiveOutput('in-the-capture'); + clock = 6; + pane._onLiveOutput('after-the-capture'); + held.release('snapshot'); + await settle(); + + expect(screenWrites(pane)).toEqual([RIS, 'snapshot', 'after-the-capture']); + }); + + it('a bounded window gives the body read the short budget; an unbounded capture keeps the long one', async () => { + const bounded = makePane('shell'); // a shell loads the `tail=` window + const unbounded = makePane(); // the split's Pane B: a TUI's whole history + const a = headersOnly(); + const b = headersOnly(); + fetchMock.mockResolvedValueOnce(a.response).mockResolvedValueOnce(b.response); + + bounded._refreshBuffer(); + await settle(); // headers landed + expect(deadlines.map((d) => d.ms)).toEqual([45_000, 10_000]); + expect(deadlines[0].cleared).toBe(true); + + unbounded._refreshBuffer(); + await settle(); + // No re-arm: a body of up to 32 MB keeps the request's own budget. + expect(deadlines.map((d) => d.ms)).toEqual([45_000, 10_000, 45_000]); + expect(deadlines[2].cleared).toBe(false); + + a.release('one'); + b.release('two'); + await settle(); + expect(deadlines.every((d) => d.cleared)).toBe(true); + }); + + it('a close mid-refresh whose fetch fails writes exactly one marker: nothing wiped it', async () => { + const pane = makePane(); + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise); + + pane._refreshBuffer(); + pane._onSocketClosed(); // owed: a load is running + expect(pane.terminal.write).not.toHaveBeenCalled(); + response.resolve({ json: async () => Promise.reject(new Error('body read failed')) } as never); + await settle(); + + const writes = screenWrites(pane); + expect(writes.filter(isMarker)).toHaveLength(1); + expect(writes).not.toContain(RIS); + }); +}); + 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 () => { const pane = makePane('shell'); @@ -659,15 +811,16 @@ describe('TerminalTile scroll-to-top history pull', () => { pane._maybeLoadMoreHistory(); pane._refreshBuffer(); - expect(pane.terminal.clear).not.toHaveBeenCalled(); + expect(pane.terminal.write).not.toHaveBeenCalled(); expect(pane._bufferRefreshPending).toBe(true); response.resolve(jsonResponse(rowsOf(30))); await settle(); - expect(pane.terminal.clear).toHaveBeenCalledTimes(1); + // The pull had nothing to replay; the refresh behind it reset once, then replayed. + expect(screenWrites(pane)).toEqual([RIS, 'refreshed']); + expect(pane.terminal.clear).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(pane.terminal.write).toHaveBeenCalledWith('refreshed'); }); // The server's `{t:'c'}` means "refresh after startup" (its one emitter is a @@ -945,10 +1098,9 @@ describe('TerminalTile scroll-to-top history pull', () => { it('back-to-back refreshes on a closed socket leave exactly one marker, at the end', async () => { // R1's finally runs the trailing refresh R2, so R1 leaves the owed marker to - // R2 instead of stamping it: in real xterm R1's write would still be queued - // when R2's synchronous clear() runs, and would land above R2's replay. The - // write mock records every stamp whatever clear() does, so counting marker - // writes pins that R1 never stamps (the async-parse test below shows why). + // R2 instead of stamping it: R2 settles it, below its own replay. The write + // mock records every stamp, so counting marker writes pins that R1 never + // stamps (the async-parse test below shows the screen). const pane = makePane('shell'); pane._wsClosed = true; const first = deferred>(); @@ -959,8 +1111,10 @@ describe('TerminalTile scroll-to-top history pull', () => { pane._refreshBuffer(); // coalesced into the trailing R2 first.resolve(jsonResponse('first')); await settle(); - // R1 settled its marker, then R2 cleared and is still fetching. - expect(pane.terminal.clear).toHaveBeenCalledTimes(2); + // R1 replayed and left the marker to R2, which is still fetching: it has + // reset nothing yet, so R1's replay is still on screen. + expect(screenWrites(pane)).toEqual([RIS, 'first']); + expect(fetchMock).toHaveBeenCalledTimes(2); second.resolve(jsonResponse('second')); await settle(); @@ -1047,8 +1201,8 @@ describe('TerminalTile scroll-to-top history pull', () => { }); it('a refresh queued behind a pull on a closed socket does not wipe the marker', async () => { - // The refresh's clear() runs after the pull's finally block has written the - // marker, so without a re-stamp the dead pane would look current again. + // The refresh's reset lands after the pull's finally block, so without a + // re-stamp the dead pane would look current again. const pane = makePane('shell'); const held = headersOnly(); fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('refreshed')); @@ -1062,7 +1216,9 @@ describe('TerminalTile scroll-to-top history pull', () => { await settle(); const writes = pane.terminal.write.mock.calls.map((c) => c[0]); - expect(pane.terminal.clear).toHaveBeenCalledTimes(1); + expect(resets(pane)).toBe(1); + expect(pane.terminal.clear).not.toHaveBeenCalled(); + expect(writes.indexOf(RIS)).toBe(writes.indexOf('refreshed') - 1); expect(writes).toContain('refreshed'); expect(isMarker(writes.at(-1))).toBe(true); expect(writes.lastIndexOf('refreshed')).toBeLessThan(writes.length - 1); @@ -1095,9 +1251,10 @@ describe('TerminalTile scroll-to-top history pull', () => { ])('with xterm parsing writes on a later tick, %s leave one marker on screen, last', async (_label, drive) => { // Real xterm queues write() and parses it on a later tick (WriteBuffer's // setTimeout), while clear() rewrites the buffer at once. The default fake - // applies writes synchronously and so cannot show a marker overtaken by a - // trailing refresh's clear(): parsed after it, that marker sat above the - // refresh's replay as a second, stale copy. + // applies writes synchronously and so cannot show ordering on the real + // screen: back when a trailing refresh began with a clear(), a marker parsed + // after it sat above the refresh's replay as a second, stale copy. A + // refresh never calls clear() now (pinned below); its reset is in-stream. const pane = makePane('shell'); const screen: string[] = []; const pending: Array<{ data: string; done?: () => void }> = []; @@ -1120,6 +1277,7 @@ describe('TerminalTile scroll-to-top history pull', () => { await drive(pane); for (let i = 0; i < 5; i++) await settle(); + expect(pane.terminal.clear).not.toHaveBeenCalled(); expect(pane._bufferLoading).toBe(false); expect(screen.filter(isMarker)).toHaveLength(1); expect(screen.at(-1)).toSatisfy(isMarker); diff --git a/test/tile-grid-load-queue.test.ts b/test/tile-grid-load-queue.test.ts index b0628b7d..53b60143 100644 --- a/test/tile-grid-load-queue.test.ts +++ b/test/tile-grid-load-queue.test.ts @@ -13,8 +13,9 @@ * for N tiles reconnecting together; * - the focused tile goes first, then reading order, and a history pull (the * user is waiting on it) jumps ahead of background refreshes; - * - `{t:'r'}` goes through the same queue, and a tile waiting its turn keeps its - * last frame (the clear happens at its turn); + * - `{t:'r'}` and `{t:'c'}` go through the same queue, and a tile keeps its + * last frame while it waits its turn and through its own capture's round + * trip (it is reset in-stream only once the capture is in hand); * - a destroyed tile's queued load is dropped, and destroying the tile whose * load is running aborts its fetch so the queue moves on; * - a load that never answers is cut off by its deadline; @@ -270,12 +271,14 @@ describe('refreshes', () => { await settle(); expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']); - // b has not been cleared: it shows its last frame until its load runs. - expect(b.terminal?.writes).not.toContain(''); + // b has not been reset: it shows its last frame until its load runs. + const before = [...(b.terminal?.writes ?? [])]; + expect(before).not.toContain(''); await drain('fresh'); expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']); - expect(b.terminal?.writes.slice(-2)).toEqual(['', 'fresh']); + // Its turn reset it in-stream, right before the replay, never with clear(). + expect(b.terminal?.writes).toEqual([...before, '\x1bc', 'fresh']); }); it("a server {t:'c'} (a Claude pane's first prompt) is a refresh through the same queue", async () => { @@ -296,6 +299,28 @@ describe('refreshes', () => { expect(b.terminal?.writes.at(-1)).toBe('banner'); }); + it("a tile keeps its last frame through its own capture's round trip, and one cut off leaves it as it was", async () => { + vi.useFakeTimers(); + const { tiles } = makeGrid(['a']); + await connectAll(tiles); + const [a] = tiles; + a.ws?.receive({ t: 'o', d: 'last frame' }); + a.ws?.receive({ t: 'r' }); + await settle(); + + // Its turn came and its capture is in flight: nothing reset yet. + expect(inFlight()).toBe(1); + expect(a.terminal?.writes.at(-1)).toBe('last frame'); + + // The full-capture budget runs out with no answer. + await vi.advanceTimersByTimeAsync(45_000); + await settle(); + expect(captures.at(-1)?.aborted).toBe(true); + expect(a.terminal?.writes.at(-1)).toBe('last frame'); + expect(a.terminal?.writes).not.toContain('\x1bc'); + expect(a.terminal?.writes).not.toContain(''); + }); + it('a history pull jumps ahead of background refreshes', async () => { const { tiles } = makeGrid(['a', 'b', 'sh'], { modes: { sh: 'shell' } }); await connectAll(tiles); @@ -326,10 +351,11 @@ describe('refreshes', () => { const markers = () => (b.terminal?.writes ?? []).filter((w) => w.includes('[disconnected')).length; expect(markers()).toBe(1); await drain('fresh'); - // Its turn cleared the screen, so the marker is written again below the replay: one on screen. + // Its replay reset the screen, so the marker is written again below the replay: one on screen. const writes = b.terminal?.writes ?? []; - expect(writes.slice(writes.lastIndexOf(''))).toEqual([ - '', + expect(writes).not.toContain(''); + expect(writes.slice(writes.lastIndexOf('\x1bc'))).toEqual([ + '\x1bc', 'fresh', expect.stringContaining('[disconnected'), ]);