diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 0b3a5396..e73e49a8 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 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. ⚠️ Live output is flow-controlled per pane (`_writeLive`), because the server applies no backpressure and a flood a tile cannot parse as fast (a shell tile running `cat` on a huge log) used to pile up in xterm's own queue until its WriteBuffer threw past 50M code units and onmessage's catch lost the frames silently: unparsed output is counted through each write's callback, and held output by the queue, against `TerminalTile.LIVE_BACKLOG_BUDGET` (4 MiB; deliberately NOT the primary pane's 128 KB, which caps its own rAF-paced queues, while xterm itself paces a tile and a tight cap would blank-and-reload it on ordinary bursts). Past it a frame is dropped, the tile stops writing onto the hole, and one refresh, debounced and bounded by the primary pane's own rule (`CodemanDroppedOutput`: 2 s, at most `DROP_RECOVERY_MAX_ATTEMPTS`, never retried after a deadline abort), recaptures the screen; the flag clears only once a capture taken after the last dropped frame has replayed. A write xterm throws on is the same drop. Past the bound the flag is released (the tile is never left frozen), a reconnect starts the accounting over (an epoch makes callbacks from before it count nothing) and drops a pending recovery, since its own refresh replaces the screen, and `destroy()` cancels 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'}`; a forced fit (Redraw: Ctrl+Shift+R, the header button, through `restoreTerminalSize`) carries the primary pane's `f` flag, without which `Session.resize` skips a size equal to the one it last applied, and `fit()` returns whether a frame went out, so Redraw reports success only for a resize it sent (a detached session or a socket that is down gets a warning instead); 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 @@ -809,7 +809,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`. -⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `tail=` for a shell; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell history pull) also sends `lines=`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh clears at its turn, not when asked, so a waiting tile keeps its last frame. +⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, server `{t:'c'}` refresh, dropped-output recovery refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `tail=` for a shell; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell history pull) also sends `lines=`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh fetches at its turn, not when asked, and resets the screen with the queued in-stream `\x1bc` only once its capture is in hand, right before the replay (never xterm's `clear()` before the fetch), so a tile keeps its last frame through its wait and its own round trip; a failed, aborted or empty fetch writes nothing and resets nothing, and the tile keeps its last frame and every held live frame. ⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE its await (like `wasActive`; the delete broadcast may already have removed the tile) and focuses it with `auto`; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index c568f20f..d91f89ca 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -1,8 +1,8 @@ # Tile Grid: Design Spec -**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`; PR 2 (the grid) implemented on `feat/tile-grid`, both local only. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays. +**Status**: Merged for the 1.40.0 release as #560 (the TerminalTile foundation) and #561 (the grid). Where the "As built" section below differs from this spec, As built is authoritative. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays. **Author**: Claude (planning session with the maintainer), 2026-10-06 -**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktrees `claudeman-tiles`, `claudeman-tilegrid`) +**Branches**: developed as PR 1 `feat/terminal-tile` and PR 2 `feat/tile-grid` stacked on it, both merged **Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred. ## As built: where PR 2 differs from this spec @@ -30,8 +30,11 @@ or settled a question the spec left open. The invariants as built are in - **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom (window too small for the minimum tile) follows focus instead. - **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane - B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps - its last frame. + B too), and a refresh fetches at its turn in the queue: the tile keeps its last frame + through its wait and its own round trip, and is reset with the queued in-stream `\x1bc` + only once the capture is in hand, right before the replay (never xterm's `clear()` before + the fetch). A failed, aborted or empty fetch writes nothing and resets nothing: the tile + keeps its last frame and every held live frame. - **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile. - **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the first build's 12px glyphs read as tiny next to the name), the size of the app header's own @@ -408,7 +411,8 @@ against the live list without rebuilding tiles that are still alive. ### Gating - Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the - settings PUT, NOT in `SettingsUpdateSchema`), default OFF. Independent of + settings PUT, NOT in `SettingsUpdateSchema`), default ON on desktop and OFF on + handhelds (specified OFF; superseded, see "As built"). Independent of `showSplitButton`, which is unchanged; a desk can show both buttons. - Hidden below 1180 px by both a JS width check with a `matchMedia` listener and a CSS `@media (max-width: 1179px)` backstop, exactly like the split button. diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 378c929f..222ebc65 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 @@ -158,7 +159,19 @@ this._historyPullAt = 0; this._historyPullUseless = false; this._liveQueue = null; + this._liveQueueBytes = 0; this._markerOwed = false; + // Live-output flow control (_writeLive, TerminalTile.LIVE_BACKLOG_BUDGET): + // code units written into this xterm and not yet parsed (each write's + // callback counts its own back down, unless a reset bumped `_liveEpoch` + // since), whether output was dropped and not yet recovered, when the last + // frame was dropped, and the debounced, bounded recovery refresh. + this._liveInFlight = 0; + this._liveEpoch = 0; + this._liveDropped = false; + this._liveDropAt = 0; + this._dropRecoveryTimer = null; + this._dropRecoveryAttempt = 0; this._onWheel = null; // `{ ws, lastRecvAt }`, registered with the app's input-socket map while // this pane's socket is open, so the exactly-once input queue delivers this @@ -506,7 +519,12 @@ this._lastSentDims = null; this._registerInputSocket(); this._sendResize(); - if (reconnected) this._refreshBuffer(); + if (reconnected) { + // The gap already cost output, and the refresh below replaces the + // screen, so live-output accounting starts over with it. + this._resetLiveFlow(); + this._refreshBuffer(); + } } // Opens a replacement socket now instead of waiting out the backoff (for an @@ -666,10 +684,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 +702,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 +719,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 +744,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._openLiveQueue(); + 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 +809,15 @@ } catch { /* Best-effort: live output still arrives once the socket connects. */ } finally { + clearTimeout(abortTimer); + this._loadAbort = null; this._loadRunning = false; + // Before the flush: a refresh that recovered dropped output lets its + // held frames through. + if (refresh) this._settleDropRecovery({ replayed, capturedAt, timedOut: !!controller?.signal?.aborted }); + // 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,29 +861,168 @@ } } - // 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. + // Held frames count against the same budget as unparsed ones: a pull or a + // refresh holds output for up to its body budget, and a flood meanwhile + // must not grow the queue without bound either. _onLiveOutput(data) { - if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data }); - else this.terminal?.write(data); + if (!data) return; + if (this._liveQueue) { + if (this._liveQueueBytes + data.length > TerminalTile.LIVE_BACKLOG_BUDGET) { + this._noteLiveDrop(); + return; + } + this._liveQueueBytes += data.length; + this._liveQueue.push({ at: performance.now(), data }); + return; + } + this._writeLive(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. + _openLiveQueue() { + this._liveQueue = []; + this._liveQueueBytes = 0; + } + + // 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. Through _writeLive(), so they are counted (and + // a write that throws cannot skip the load's marker and trailing refresh). + _flushLiveQueue(cutoff) { + const queued = this._liveQueue ?? []; + this._liveQueue = null; + this._liveQueueBytes = 0; + for (const entry of queued) { + if (entry.at < cutoff) continue; + this._writeLive(entry.data); + } + } + + // Writes one live frame into this xterm, under flow control. The server + // applies no backpressure (16 KB / 8 ms batches, never a bufferedAmount + // check), so a flood a tile cannot parse as fast as it arrives (a shell + // tile running `cat` on a huge log, `yes`) used to pile up in xterm's own + // write queue without bound, on a main thread six tiles share, until + // xterm's WriteBuffer throws past 50M code units and the frames were + // silently lost in onmessage's catch. The primary pane caps its queues + // and drops then recaptures (_onSessionTerminal, app.js); this is the + // tile's equivalent. Past TerminalTile.LIVE_BACKLOG_BUDGET unparsed, a + // frame is dropped and the tile stops writing until a refresh recaptures + // the screen (_scheduleDropRecovery): every byte after a hole is written + // onto a screen out of step with the PTY, which that refresh replaces + // anyway. A write that throws is the same drop, never a malformed frame. + _writeLive(data) { + const terminal = this.terminal; + if (!terminal || this._destroyed || !data) return; + if (this._liveDropped) { + this._noteLiveDrop(); + return; + } + const n = data.length; + if (this._liveInFlight + n > TerminalTile.LIVE_BACKLOG_BUDGET) { + this._noteLiveDrop(); + return; + } + const epoch = this._liveEpoch; + this._liveInFlight += n; + try { + terminal.write(data, () => { + if (epoch === this._liveEpoch) this._liveInFlight -= n; + }); + } catch { + if (epoch === this._liveEpoch) this._liveInFlight -= n; + this._noteLiveDrop(); + } + } + + // A live frame was dropped. Marks the tile out of step and arms ONE + // recovery; later drops only move the stamp the recovery has to beat. + _noteLiveDrop() { + this._liveDropAt = performance.now(); + if (this._liveDropped) return; + this._liveDropped = true; + this._scheduleDropRecovery(); + } + + // The primary pane's dropped-output recovery (_scheduleDroppedOutputRecovery, + // app.js), aimed at this tile: debounced by DROP_RECOVERY_DELAY_MS so a + // sustained flood collapses into one attempt, and run as an ordinary + // refresh, which is single-flight, bounded (`lines=`/`tail=`) and waits its + // turn in the grid's load queue. _settleDropRecovery() decides what the + // refresh it starts achieved. + _scheduleDropRecovery() { + if (this._dropRecoveryTimer || this._destroyed) return; + const delay = global.CodemanDroppedOutput?.DROP_RECOVERY_DELAY_MS ?? 2000; + this._dropRecoveryTimer = setTimeout(() => { + this._dropRecoveryTimer = null; + if (this._destroyed || !this._liveDropped) return; + this._dropRecoveryAttempt++; + this._refreshBuffer(); + }, delay); + } + + // A refresh finished while output was marked dropped. Recovered when its + // replay's capture was taken after the last dropped frame (the response's + // arrival, the cutoff every load uses): output flows again. Otherwise one + // more attempt, bounded by the primary pane's rule + // (shouldRetryDroppedOutputRecovery: DROP_RECOVERY_MAX_ATTEMPTS, and never + // after a capture cut off at its deadline, a stalled link); past that the + // flag is released so the tile is never left frozen, and it writes on, out + // of step, as every tile did before this existed. + _settleDropRecovery({ replayed, capturedAt, timedOut }) { + if (!this._liveDropped || this._destroyed) return; + if (replayed && capturedAt >= this._liveDropAt) { + this._liveDropped = false; + this._dropRecoveryAttempt = 0; + return; + } + // Another try is already on its way: the debounce, or a trailing refresh. + if (this._dropRecoveryTimer || this._bufferRefreshPending) return; + const retry = + global.CodemanDroppedOutput?.shouldRetryDroppedOutputRecovery?.({ + repainted: false, + timedOut, + attempt: Math.max(0, this._dropRecoveryAttempt - 1), + stillActive: true, + }) === true; + if (retry) { + this._scheduleDropRecovery(); + return; + } + this._liveDropped = false; + this._dropRecoveryAttempt = 0; + } + + // Starts live-output accounting over (a reconnect, destroy): write callbacks + // still pending from before carry the old epoch and count nothing. + _resetLiveFlow() { + this._liveEpoch++; + this._liveInFlight = 0; + this._liveDropped = false; + this._dropRecoveryAttempt = 0; + clearTimeout(this._dropRecoveryTimer); + this._dropRecoveryTimer = null; + } + + // 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 +1131,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); @@ -1044,7 +1244,7 @@ // Opened only now: a frame from before the response is either replaced by // the capture or written unchanged, so holding it for the round trip // would buy nothing and freeze the pane for as long as the fetch took. - this._liveQueue = []; + this._openLiveQueue(); const payload = (await res.json())?.data; clearTimeout(abortTimer); const buffer = payload?.terminalBuffer; @@ -1096,16 +1296,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; - if (entry.clear) this._clearTerminal(); - else 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 +1321,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; @@ -1156,23 +1350,26 @@ // Reflow to the container and tell the PTY, as one step: the xterm and the // PTY must never disagree about size (#464), and a font change is a size // change too, so the font setters call this rather than localFit(). - // `force` resends an unchanged size. + // `force` resends an unchanged size and asks the server to apply it anyway + // (Redraw, restoreTerminalSize). Returns whether a resize went out. fit({ force = false } = {}) { this.localFit(); - this._sendResize({ force }); + return this._sendResize({ force }); } + // Returns true only once a `{t:'z'}` frame was sent, false at every early + // exit, so Redraw can say when nothing reached the PTY. _sendResize({ force = false } = {}) { - if (!this._wsReady || !this.fitAddon || !this.terminal) return; + if (!this._wsReady || !this.fitAddon || !this.terminal) return false; // One PTY cannot hold two sizes (mirrors sendResize's own // detachedElsewhere yield in terminal-ui.js): the session got detached // to its own window AFTER this pane was opened, so its own window now // owns the PTY's size and this pane must stand aside. - if (this.detachedSessions?.has(this.sessionId)) return; + if (this.detachedSessions?.has(this.sessionId)) return false; // A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and // fit() then leaves the xterm alone: there is no size worth reporting. const dims = this.fitAddon.proposeDimensions(); - if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return; + if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return false; // Report what the xterm actually holds, so the PTY gets exactly the size // the pane renders at. Unclamped, unlike the primary pane's 40x10 floor: // a floor would misreport the split's Pane B at its divider's reachable @@ -1182,9 +1379,20 @@ const cols = this.terminal.cols; const rows = this.terminal.rows; const last = this._lastSentDims; - if (!force && last && last.cols === cols && last.rows === rows) return; + if (!force && last && last.cols === cols && last.rows === rows) return false; + // `f` is the primary pane's forced resize (sendResize, terminal-ui.js): + // Session.resize (session.ts) otherwise skips a size equal to the one it + // last applied, so without it a forced resend reached the server and did + // nothing there (no tmux resize-window, no PTY resize). + const msg = { t: 'z', c: cols, r: rows, v: 'desktop' }; + if (force) msg.f = true; + try { + this.ws.send(JSON.stringify(msg)); + } catch { + return false; // nothing went out, so nothing is recorded as sent + } this._lastSentDims = { cols, rows }; - this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' })); + return true; } // The session's PTY is new: a tile can connect before its session has a @@ -1244,6 +1452,9 @@ this.mountEl?.removeEventListener('click', this._onClick); this._onClick = null; } + // A disposed xterm never runs its write callbacks, and a pending + // recovery would refresh a pane nobody can see. + this._resetLiveFlow(); // Page keys still waiting for their flush go nowhere: the pane is gone. clearTimeout(this._scrollFlushTimer); this._scrollFlushTimer = null; @@ -1269,6 +1480,15 @@ } } + // Code units of live output a pane lets sit unparsed in its xterm (or held + // behind a replay) before it drops a frame and recaptures (_writeLive). Not + // the primary pane's 128 KB: that caps its own rAF-paced queues, about two + // frames of them, while here xterm itself is the pacer, a burst normally + // parses within a frame or two, and a tight cap would trip on ordinary + // bursts and blank-and-reload the tile over and over. A few MB keeps a flood + // far below xterm's 50M code-unit throw and bounds each tile's memory. + TerminalTile.LIVE_BACKLOG_BUDGET = 4 * 1024 * 1024; + // The marker a pane writes when its socket drops: a transient drop says it is // reconnecting; a permanent stop says why, keyed by close code. All start // with `[disconnected` so a reader (and a test) can tell any of them apart diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 48d134bc..95727d04 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -4664,11 +4664,24 @@ Object.assign(CodemanApp.prototype, { */ async restoreTerminalSize() { // A second pane owns its own geometry: refit it and force its PTY to the - // size it renders at (TerminalTile.fit), whatever another device set. + // size it renders at (TerminalTile.fit sends the same forced `f` resize + // sendResize sends below), whatever another device set. fit() says whether + // the resize went out; when it did not, say why rather than report a size + // that was never sent, as the primary branch does below. const pane = this._focusedPane(); if (!pane.isPrimary) { - pane.tile.fit({ force: true }); - this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success'); + const sent = pane.tile.fit({ force: true }); + if (sent !== false) { + this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success'); + } else if (this.detachedSessions?.has(pane.sessionId)) { + // Its own window owns the PTY's size (TerminalTile._sendResize yields). + this.showToast('This session is sized by its own window', 'warning'); + } else if (!pane.tile._wsReady) { + // The tile announces its size again as soon as its socket reopens. + this.showToast('Terminal not connected: its size is sent when it reconnects', 'warning'); + } else { + this.showToast('Could not determine terminal size', 'error'); + } return; } if (!this.activeSessionId) { diff --git a/test/focused-pane-shortcuts.test.ts b/test/focused-pane-shortcuts.test.ts index 25d50042..caccfb9a 100644 --- a/test/focused-pane-shortcuts.test.ts +++ b/test/focused-pane-shortcuts.test.ts @@ -127,6 +127,36 @@ describe('terminal shortcuts follow the focused pane', () => { expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success'); }); + // TerminalTile.fit() returns whether its resize went out. When it did not, + // Redraw used to report a size that was never sent. + it.each([ + [ + 'popped out to its own window', + { detached: true, wsReady: true }, + 'This session is sized by its own window', + 'warning', + ], + [ + 'whose socket is down', + { detached: false, wsReady: false }, + 'Terminal not connected: its size is sent when it reconnects', + 'warning', + ], + ['that could not measure itself', { detached: false, wsReady: true }, 'Could not determine terminal size', 'error'], + ])('Ctrl+Shift+R on a second pane %s reports no success', async (_label, state, message, level) => { + const app = loadApp(); + app.detachedSessions = new Set(state.detached ? ['session-b'] : []); + const tile = paneB({ fit: vi.fn(() => false), _wsReady: state.wsReady }); + app._noteFocusedTile(tile); + + await app.restoreTerminalSize(); + + expect(tile.fit).toHaveBeenCalledWith({ force: true }); + expect(app.showToast).toHaveBeenCalledTimes(1); + expect(app.showToast).toHaveBeenCalledWith(message, level); + expect(app.sendResize).not.toHaveBeenCalled(); + }); + it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => { const app = loadApp(); diff --git a/test/mocks/terminal-tile-fakes.ts b/test/mocks/terminal-tile-fakes.ts index f68428a0..2b70614a 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. @@ -148,17 +149,36 @@ export class FakeTerminal { } registerLinkProvider() {} writes: string[] = []; - /** Set by a test: write callbacks never run, as on a disposed xterm. */ + /** + * Set by a test: write callbacks do not run, as while xterm is still parsing + * (or never, on a disposed xterm). They wait in `heldParses` until `parse()`. + */ holdParse = false; + heldParses: Array<() => void> = []; + /** xterm catches up: runs every write callback held so far, in order. */ + parse() { + for (const cb of this.heldParses.splice(0)) cb(); + } + /** Set by a test: the next write of exactly this data throws, as xterm's WriteBuffer does past 50M. */ + throwOnWrite: string | null = null; write(data: string, cb?: () => void) { + if (this.throwOnWrite !== null && data === this.throwOnWrite) { + this.throwOnWrite = null; + throw new Error('write data discarded, use flow control to avoid losing data'); + } // An empty write puts nothing on screen; the replay queues one only to hear // (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?.(); + if (!cb) return; + if (this.holdParse) this.heldParses.push(cb); + else cb(); } clear() { this.writes.push(''); diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index a3873bf1..a670228c 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -118,7 +118,7 @@ type Tile = { connect(): Promise; destroy(): void; reconnectNow(): void; - fit(opts?: { force?: boolean }): void; + fit(opts?: { force?: boolean }): boolean; detachedSessions?: Set; ws: FakeSocket | null; _reconnectAttempts: number; @@ -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); }); @@ -387,6 +389,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'], @@ -463,15 +514,80 @@ describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', () const { tile, ws } = await connectTile(makeApp()); ws.open(); - tile.fit(); + expect(tile.fit()).toBe(false); // unchanged: nothing sent expect(resizeFrames(ws)).toHaveLength(1); FakeFit.proposed = { cols: 100, rows: 30 }; - tile.fit(); + expect(tile.fit()).toBe(true); expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' }); - tile.fit({ force: true }); + expect(tile.fit({ force: true })).toBe(true); expect(resizeFrames(ws)).toHaveLength(3); + // The primary pane's forced resize flag: without it Session.resize skips a + // size equal to the one it last applied, and the forced resend did nothing. + expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop', f: true }); + }); + + it('force on a closed socket sends nothing and says so', async () => { + const { tile, ws } = await connectTile(makeApp()); + ws.open(); + ws.drop(1006); + + expect(tile.fit({ force: true })).toBe(false); + expect(resizeFrames(ws)).toHaveLength(1); // only the open's own announcement + }); + + describe('Redraw (Ctrl+Shift+R, the header button) on a focused tile', () => { + type RedrawApp = App & { + restoreTerminalSize(): Promise; + _noteFocusedTile(tile: unknown): void; + detachedSessions?: Set; + }; + + it('forces the resize through to the server and reports the size it sent', async () => { + const app = makeApp() as RedrawApp; + const { tile, ws } = await connectTile(app); + ws.open(); + app._noteFocusedTile(tile); + + await app.restoreTerminalSize(); + + expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 80, r: 24, v: 'desktop', f: true }); + expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 80x24', 'success'); + }); + + it('with the socket down, sends nothing and does not report a success', async () => { + const app = makeApp() as RedrawApp; + const { tile, ws } = await connectTile(app); + ws.open(); + app._noteFocusedTile(tile); + ws.drop(1006); + + await app.restoreTerminalSize(); + + expect(resizeFrames(ws)).toHaveLength(1); + expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success'); + expect(app.showToast).toHaveBeenCalledWith( + 'Terminal not connected: its size is sent when it reconnects', + 'warning' + ); + }); + + it('for a session popped out to its own window, says that window sizes it', async () => { + const app = makeApp() as RedrawApp; + const detached = new Set(); + app.detachedSessions = detached; + const { tile, ws } = await connectTile(app, { detachedSessions: detached }); + ws.open(); + app._noteFocusedTile(tile); + detached.add('s-tile'); // popped out after the tile opened + + await app.restoreTerminalSize(); + + expect(resizeFrames(ws)).toHaveLength(1); + expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success'); + expect(app.showToast).toHaveBeenCalledWith('This session is sized by its own window', 'warning'); + }); }); it('re-announces an unchanged size on a reconnected socket', async () => { @@ -547,7 +663,7 @@ describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', () ws.open(); FakeFit.proposed = { cols: 120, rows: 40 }; - tile.fit(); + expect(tile.fit()).toBe(false); expect(resizeFrames(ws)).toEqual([]); }); @@ -686,6 +802,273 @@ describe('TerminalTile claims the keyboard for the app-level shortcuts', () => { }); }); +describe('TerminalTile live-output flow control: a flood cannot pile up in xterm', () => { + // The server applies no backpressure, and a tile used to write every live + // frame straight into xterm: a flood it could not parse as fast piled up in + // xterm's own queue without bound, until xterm's WriteBuffer threw past 50M + // code units and onmessage's catch silently lost the frames. Now unparsed + // (and held) output is counted per tile; past the budget a frame is dropped, + // the tile stops writing onto the hole, and one debounced, bounded refresh + // recaptures the screen (the primary pane's _scheduleDroppedOutputRecovery). + const TileStatics = TerminalTile as unknown as { LIVE_BACKLOG_BUDGET: number }; + const defaultBudget = TileStatics.LIVE_BACKLOG_BUDGET; + afterEach(() => { + TileStatics.LIVE_BACKLOG_BUDGET = defaultBudget; + delete windowStub.AbortController; + }); + const out = (ws: FakeSocket, d: string) => ws.receive({ t: 'o', d }); + const inFlight = (tile: Tile) => (tile as unknown as { _liveInFlight: number })._liveInFlight; + function serve(terminalBuffer: string) { + fetchMock.mockClear(); + fetchMock.mockImplementation(async () => ({ + ok: true, + status: 200, + json: async () => ({ data: { terminalBuffer } }), + })); + } + + it("the budget is a few MB, not the primary pane's 128 KB: a 1 MiB burst xterm has not parsed yet is still written", async () => { + expect(defaultBudget).toBeGreaterThanOrEqual(2 * 1024 * 1024); + expect(defaultBudget).toBeLessThanOrEqual(16 * 1024 * 1024); + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + serve(''); + term.holdParse = true; + + out(ws, 'z'.repeat(1024 * 1024)); + + expect(term.writes.at(-1)).toHaveLength(1024 * 1024); + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('counts output until xterm has parsed it, so parsed bytes no longer hold the budget', async () => { + TileStatics.LIVE_BACKLOG_BUDGET = 100; + const { tile, ws, term } = await connectTile(makeApp()); + ws.open(); + term.holdParse = true; + + out(ws, 'a'.repeat(40)); + out(ws, 'b'.repeat(40)); + expect(inFlight(tile)).toBe(80); + term.parse(); + expect(inFlight(tile)).toBe(0); + out(ws, 'c'.repeat(90)); + + expect(term.writes.slice(-3)).toEqual(['a'.repeat(40), 'b'.repeat(40), 'c'.repeat(90)]); + }); + + it('stops writing past the budget, and ONE debounced refresh recaptures the screen', async () => { + TileStatics.LIVE_BACKLOG_BUDGET = 100; + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + serve('recovered screen'); + term.holdParse = true; // xterm falls behind + + out(ws, 'a'.repeat(60)); + out(ws, 'b'.repeat(60)); // 120 unparsed: past the budget, dropped + out(ws, 'c'); + term.parse(); // xterm catches up, but the stream already has a hole: + out(ws, 'd'); // nothing more is written onto it + expect(term.writes.filter((w) => /^[abcd]/.test(w))).toEqual(['a'.repeat(60)]); + expect(fetchMock).not.toHaveBeenCalled(); + + term.holdParse = false; + await vi.advanceTimersByTimeAsync(1999); + expect(fetchMock).not.toHaveBeenCalled(); // debounced, like the primary pane's + await vi.advanceTimersByTimeAsync(1); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1'); + expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']); + + out(ws, 'live again'); + expect(term.writes.at(-1)).toBe('live again'); + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchMock).toHaveBeenCalledTimes(1); // one recovery for the whole burst + }); + + it("a write xterm refuses (its 50M throw) is a drop that schedules the same recovery, never a swallowed 'malformed frame'", async () => { + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + serve('recovered screen'); + term.throwOnWrite = 'boom'; + + out(ws, 'boom'); + out(ws, 'after the hole'); + expect(term.writes).not.toContain('after the hole'); + + await vi.advanceTimersByTimeAsync(2000); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']); + }); + + it('frames held behind a replay count against the budget too, and a hole there is recovered by another refresh', async () => { + TileStatics.LIVE_BACKLOG_BUDGET = 100; + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + fetchMock.mockClear(); + let answer!: (body: string) => void; + fetchMock.mockImplementationOnce(async () => ({ + ok: true, + status: 200, + json: () => + new Promise((resolveBody) => { + answer = (terminalBuffer) => resolveBody({ data: { terminalBuffer } }); + }), + })); + fetchMock.mockImplementation(async () => ({ + ok: true, + status: 200, + json: async () => ({ data: { terminalBuffer: 'second capture' } }), + })); + + ws.receive({ t: 'r' }); + await settle(); // the headers landed: from here frames are held + out(ws, 'x'.repeat(60)); + out(ws, 'y'.repeat(60)); // the held queue would pass the budget: dropped + answer('first capture'); + await settle(); + + // The capture predates the hole, so nothing after it is written onto it. + expect(term.writes.slice(-2)).toEqual(['\x1bc', 'first capture']); + expect(term.writes).not.toContain('y'.repeat(60)); + await vi.advanceTimersByTimeAsync(2000); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(term.writes.slice(-2)).toEqual(['\x1bc', 'second capture']); + out(ws, 'live again'); + expect(term.writes.at(-1)).toBe('live again'); + }); + + it('a recovery that keeps failing is retried a bounded number of times, then lets live output through again', async () => { + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + fetchMock.mockClear(); + fetchMock.mockImplementation(async () => { + throw new Error('offline'); + }); + term.throwOnWrite = 'boom'; + + out(ws, 'boom'); + out(ws, 'held back'); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(2000); + await settle(); + } + + const { DROP_RECOVERY_MAX_ATTEMPTS } = windowStub.CodemanDroppedOutput as { DROP_RECOVERY_MAX_ATTEMPTS: number }; + expect(fetchMock).toHaveBeenCalledTimes(DROP_RECOVERY_MAX_ATTEMPTS); + expect(term.writes).not.toContain('held back'); + out(ws, 'flowing'); + expect(term.writes.at(-1)).toBe('flowing'); // never left frozen + }); + + it('a recovery cut off at its deadline is not retried (a stalled link), and lets live output through again', async () => { + windowStub.AbortController = AbortController; + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + fetchMock.mockClear(); + fetchMock.mockImplementation( + (_url: string, init?: { signal?: AbortSignal }) => + new Promise((_resolveFetch, rejectFetch) => { + init?.signal?.addEventListener('abort', () => rejectFetch(new Error('aborted'))); + }) + ); + term.throwOnWrite = 'boom'; + + out(ws, 'boom'); + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(45_000); // the full-capture budget runs out + await settle(); + + out(ws, 'flowing'); + expect(term.writes.at(-1)).toBe('flowing'); + await vi.advanceTimersByTimeAsync(20_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('a reconnect starts the count over, and callbacks from before it count nothing', async () => { + TileStatics.LIVE_BACKLOG_BUDGET = 100; + vi.useFakeTimers(); + const { tile, ws, term } = await connectTile(makeApp()); + ws.open(); + term.holdParse = true; + out(ws, 'a'.repeat(60)); + expect(inFlight(tile)).toBe(60); + + ws.drop(1006); + await vi.advanceTimersByTimeAsync(300); + FakeSocket.instances.at(-1)!.open(); // reconnected: its refresh replaces the screen + expect(inFlight(tile)).toBe(0); + term.parse(); // the old write's callback lands after the reset + expect(inFlight(tile)).toBe(0); + }); + + it('a reconnect drops a pending recovery: its own refresh replaces the screen', async () => { + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + serve(''); + term.throwOnWrite = 'boom'; + out(ws, 'boom'); + + ws.drop(1006); + await vi.advanceTimersByTimeAsync(300); + const ws2 = FakeSocket.instances.at(-1)!; + ws2.open(); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); // the reconnect's refresh + out(ws2, 'flowing'); + expect(term.writes.at(-1)).toBe('flowing'); + await vi.advanceTimersByTimeAsync(5000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('destroy() cancels a pending recovery', async () => { + vi.useFakeTimers(); + const { tile, ws, term } = await connectTile(makeApp()); + ws.open(); + fetchMock.mockClear(); + term.throwOnWrite = 'boom'; + out(ws, 'boom'); + const flow = tile as unknown as { _dropRecoveryTimer: unknown; _liveDropped: boolean }; + expect(flow._dropRecoveryTimer).not.toBeNull(); // the drop armed one recovery + + tile.destroy(); + // Cleared by destroy() itself, read before any timer runs: the timer's own + // callback nulls the field and its destroyed guard skips the fetch, so the + // fetch check below alone cannot tell a cleared timer from a leaked one. + expect(flow._dropRecoveryTimer).toBeNull(); + expect(flow._liveDropped).toBe(false); + await vi.advanceTimersByTimeAsync(5000); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('destroy() starts the count over: a write callback xterm still owed counts nothing', async () => { + TileStatics.LIVE_BACKLOG_BUDGET = 100; + const { tile, ws, term } = await connectTile(makeApp()); + ws.open(); + term.holdParse = true; + out(ws, 'a'.repeat(60)); + expect(inFlight(tile)).toBe(60); + + tile.destroy(); + expect(inFlight(tile)).toBe(0); + term.parse(); // a callback from before destroy() carries the old epoch + expect(inFlight(tile)).toBe(0); + }); +}); + describe('the server coming back kicks Pane B', () => { it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => { // handleInit needs a whole app to run, so the wiring is pinned by source; 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 8eb28d48..e24f6680 100644 --- a/test/terminal-tile-unit.test.ts +++ b/test/terminal-tile-unit.test.ts @@ -185,6 +185,10 @@ const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[ */ const screenWrites = (pane: { terminal: FakeTerminal }) => pane.terminal.write.mock.calls.map((call) => call[0]).filter((data) => data !== ''); +// Live frames are written with a callback (the tile counts them until xterm +// has parsed them, _writeLive), so they are matched on the data argument +// alone, never with toHaveBeenCalledWith(data): that would also miss, and a +// `.not` on it would then pass for nothing. /** * Holds xterm's write callbacks, as a real xterm still parsing a replay does: @@ -203,6 +207,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 +244,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 +276,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 +321,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 +334,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 +366,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 +428,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'); @@ -586,7 +742,7 @@ describe('TerminalTile scroll-to-top history pull', () => { // keeps painting during the round trip), and the replay then replaces it. clock = 1; pane._onLiveOutput('early'); - expect(term.write).toHaveBeenCalledWith('early'); + expect(screenWrites(pane)).toContain('early'); await settle(); // 200 rows (more than the pane holds, so it replays) of 400 columns each: @@ -596,12 +752,14 @@ describe('TerminalTile scroll-to-top history pull', () => { clock = 2; // the response arrives: this is the cutoff response.resolve(jsonResponse(bigReplay)); await settle(); - expect(xterm.held).toHaveLength(1); + // Two parses pending: 'early' (a live write, counted until xterm parses it) + // and the replay's end marker. + expect(xterm.held).toHaveLength(2); // Arrives while the snapshot is still being parsed: must not land under it. clock = 3; pane._onLiveOutput('late'); - expect(term.write).not.toHaveBeenCalledWith('late'); + expect(screenWrites(pane)).not.toContain('late'); // The replay parsed, then the pull's own settle write before it scrolls. xterm.parse(); @@ -626,12 +784,12 @@ describe('TerminalTile scroll-to-top history pull', () => { pane._maybeLoadMoreHistory(); await settle(); pane._onLiveOutput('held'); - expect(pane.terminal.write).not.toHaveBeenCalledWith('held'); + expect(screenWrites(pane)).not.toContain('held'); held.release(rowsOf(30)); // nothing to gain: no replay await settle(); // Nothing replaced the terminal, so the held frame is news. - expect(pane.terminal.write).toHaveBeenCalledWith('held'); + expect(screenWrites(pane)).toContain('held'); }); it('a failed fetch releases the flag and the queue, so live output flows again', async () => { @@ -647,9 +805,9 @@ describe('TerminalTile scroll-to-top history pull', () => { expect(pane._bufferLoading).toBe(false); expect(pane._liveQueue).toBeNull(); - expect(pane.terminal.write).toHaveBeenCalledWith('held'); + expect(screenWrites(pane)).toContain('held'); pane._onLiveOutput('after'); - expect(pane.terminal.write).toHaveBeenLastCalledWith('after'); + expect(screenWrites(pane).at(-1)).toBe('after'); }); it('a refresh frame during the pull runs once behind it', async () => { @@ -659,64 +817,97 @@ 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'); }); - 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 () => { @@ -736,7 +927,7 @@ describe('TerminalTile scroll-to-top history pull', () => { expect(pane._liveQueue).toBeNull(); expect(pane.terminal).toBeNull(); expect(term.write).not.toHaveBeenCalledWith('\x1bc'); - expect(term.write).not.toHaveBeenCalledWith('held'); + expect(term.write.mock.calls.map((call) => call[0])).not.toContain('held'); }); it('a pull whose request is aborted (the deadline) frees the pane', async () => { @@ -752,7 +943,7 @@ describe('TerminalTile scroll-to-top history pull', () => { expect(pane._bufferLoading).toBe(false); expect(pane._liveQueue).toBeNull(); - expect(pane.terminal.write).toHaveBeenCalledWith('held'); + expect(screenWrites(pane)).toContain('held'); }); it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => { @@ -913,10 +1104,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>(); @@ -927,8 +1117,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(); @@ -1015,8 +1207,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')); @@ -1030,7 +1222,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); @@ -1063,9 +1257,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 }> = []; @@ -1088,6 +1283,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 69ff3ae0..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,54 @@ 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 () => { + 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 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 () => { @@ -308,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'), ]);