diff --git a/CLAUDE.md b/CLAUDE.md index 539f9b32..36bfcd40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -270,7 +270,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit) -**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) +**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → invariants: "Split-pane sessions" ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 848ae8f1..436821ec 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -767,7 +767,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 a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — 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 `SplitTerminalPane` 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 an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. 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'}`, tmux `send-keys -H` for a real 0x0a) 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 stays on xterm's own default paste, since Pane B has no image-paste trap to route it to. ⚠️ `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 input frames deliberately carry no `cid`/`seq` (`ws-routes.ts` supports that), matching the no-overlay/no-IME "deliberately plainer" list above, since it has no exactly-once delivery layer to key them against. Design: `docs/split-pane-sessions-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 a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — 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 `SplitTerminalPane` 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 an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. 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'}`, tmux `send-keys -H` for a real 0x0a) 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 stays on xterm's own default paste, since Pane B has no image-paste trap to route it to. ⚠️ `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 input frames deliberately carry no `cid`/`seq` (`ws-routes.ts` supports that), matching the no-overlay/no-IME "deliberately plainer" list above, since it has no exactly-once delivery layer to key them against. ⚠️ 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 mid-replay, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`) 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 fetch has a 10 s deadline because it holds the pane's live output while it runs. ⚠️ A replay's own `\x1bc` would otherwise wipe the "Pane B disconnected" marker `onclose` wrote 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) — `_pullHistory()` re-stamps the marker after the live-frame flush when the socket closed in either order (before the pull started, or mid-fetch), tracked via `_wsClosed` 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). Design: `docs/split-pane-sessions-plan.md`. ### Gesture control: the setting diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index 3c18dd81..fe031b4a 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -14,6 +14,9 @@ */ (function (global) { + // How long a scroll-to-top history pull may hold Pane B's live output. + const HISTORY_PULL_TIMEOUT_MS = 10000; + /** * Minimal chunked write for Pane B's own xterm instance — write() in * TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one @@ -68,10 +71,18 @@ this.fitAddon = null; this.ws = null; this._wsReady = false; + this._wsClosed = false; this._destroyed = false; // Single-flight state for _loadBuffer()/_refreshBuffer() below. this._bufferLoading = false; this._bufferRefreshPending = false; + // Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory(). + // `_liveQueue` is non-null exactly while a pull is replaying: live frames + // are held there with their arrival time instead of written under it. + this._historyPullAt = 0; + this._historyPullUseless = false; + this._liveQueue = null; + this._onWheel = null; } async connect() { @@ -95,6 +106,8 @@ this.terminal.open(this.mountEl); this.fitAddon.fit(); + this._installWheelListener(); + this.terminal.onData((data) => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ t: 'i', d: data })); @@ -254,9 +267,9 @@ try { const msg = JSON.parse(event.data); if (msg.t === 'o') { - this.terminal.write(msg.d); + this._onLiveOutput(msg.d); } else if (msg.t === 'c') { - this.terminal.clear(); + this._onLiveClear(); } else if (msg.t === 'r') { // Server-triggered refresh (SSE backpressure cleared, terminal // data was dropped). The primary pane routes this to @@ -282,7 +295,8 @@ // user's place in Pane B's scrollback for a transient blip. this.ws.onclose = () => { this._wsReady = false; - this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n'); + this._wsClosed = true; + this._writeDisconnectedMarker(); }; this.ws.onerror = () => { @@ -290,6 +304,12 @@ }; } + // Extracted so both onclose and a history-pull replay that lands on an + // already-closed socket can write it (see _pullHistory()'s finally block). + _writeDisconnectedMarker() { + this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n'); + } + // Fetches and writes the session's current scrollback. Used both by // connect() (initial load) and by the `{t:'r'}` server-refresh frame // (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is @@ -324,14 +344,165 @@ } catch { /* Best-effort — live output still arrives once the socket connects. */ } finally { - this._bufferLoading = false; + this._endBufferLoad(); } + } + + // Ends a single-flight load (initial, refresh or history pull): clears the + // flag, then runs the ONE trailing refresh that arrived while it was busy. + _endBufferLoad() { + this._bufferLoading = false; if (this._bufferRefreshPending && !this._destroyed) { this._bufferRefreshPending = false; this._refreshBuffer(); } } + // Live terminal output. Written straight through, except while a history + // pull is replaying: a capture is current only up to the instant tmux took + // it, so a frame arriving mid-replay is held with its arrival time and + // replayed behind the snapshot by _pullHistory() (the primary pane's + // _finishBufferLoad `since` rule), never written underneath it. + _onLiveOutput(data) { + if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data }); + else this.terminal?.write(data); + } + + // The server's `{t:'c'}` clear frame takes the same route as output, for the + // same reason: clearing straight away, mid-replay, would wipe the half-written + // snapshot and leave _pullHistory() measuring a buffer that is no longer the + // one it is restoring. Queued, it lands in order with the frames around it. + _onLiveClear() { + if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true }); + else this.terminal?.clear(); + } + + // Capture phase, because xterm's own wheel handler stopPropagation()s every + // event it consumes, so a bubbling listener here would never see the wheel + // while the pane still has scrollback to scroll. Passive: this only observes, + // xterm keeps doing the scrolling. + _installWheelListener() { + this._onWheel = (ev) => { + if (ev.deltaY < 0) this._maybeLoadMoreHistory(); + }; + this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true }); + } + + // Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of + // output (`cat` of a file longer than the screen) instead of scrolling it, + // so this pane's xterm ends up with about one screen of scrollback while + // tmux holds every line — and nothing here ever went back to ask, so the + // history was unreachable. The primary pane has the same pull + // (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its + // own. Shell only: a non-shell CLI's history is out of scope for this pull + // (its load already takes `full=1`; codex and Claude's inline renderer do + // grow tmux history, this just isn't how they recover it). The alternate- + // screen skip (nano, vim, less) only matters for a direct-PTY shell — under + // tmux the browser xterm never enters the alternate buffer. + _maybeLoadMoreHistory() { + if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return; + if (this._bufferLoading) return; + // Mirrors app.js _maybeRefetchFullHistory and this pane's own + // _sendResize(): a detached session's own window already owns its PTY + // size and scrollback, so Pane B has nothing of its own to reconcile. + if (this.detachedSessions?.has(this.sessionId)) return; + const active = this.terminal.buffer.active; + if (active.type !== 'normal' || active.viewportY !== 0) return; + // Momentum scrolling fires this dozens of times per flick, so cooldown + // rather than latch; a pull that could only have downgraded the pane + // waits far longer. + const cooldown = this._historyPullUseless ? 60000 : 4000; + const now = Date.now(); + if (now - this._historyPullAt < cooldown) return; + this._historyPullAt = now; + void this._pullHistory(); + } + + // Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE + // a tab switch loads, so a multi-megabyte capture never lands on xterm's + // main thread) and replays it under the reader's current place. Holds the + // single-flight flag across the fetch AND the replay, like _loadBuffer(). + async _pullHistory() { + this._bufferLoading = true; + this._liveQueue = []; + let replayed = false; + let capturedAt = 0; + try { + // A deadline, because live output is held for as long as this runs: a + // request that hangs would otherwise freeze the whole pane. Aborting + // lands in the catch below, which releases the flag and the queue. It + // covers the body read too, not just the headers. + const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, { + signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS), + }); + // The cutoff below is the response's arrival, the same `since` rule the + // primary pane uses (_finishBufferLoad). It is a client clock standing in + // for the instant tmux took the capture, which lies somewhere in the + // round trip, so a frame in that window can be lost or doubled. Bounded + // by one round trip and not closable without a server-side capture time. + capturedAt = performance.now(); + const payload = (await res.json())?.data; + const buffer = payload?.terminalBuffer; + const term = this.terminal; + if (!buffer || !term || this._destroyed) return; + const rowsBefore = term.buffer.active.length; + const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length; + // xterm keeps at most `scrollback + rows` rows while tmux keeps far more + // lines, so a window of short lines can carry more rows than this pane + // can ever hold, and `rowsIncoming <= rowsBefore` would never come true. + const scrollbackCap = term.options?.scrollback || 0; + const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows; + // Nothing to gain (this also covers a downgrade, which would delete + // history mid-scroll), and a reset+rewrite would jump the viewport. An + // untruncated window IS all of tmux's history and the next burst can add + // more, so keep the 4 s cooldown. A truncated window can never reach past + // what the pane shows, and every ask costs the server a capture-pane of + // the whole history (`tail` is cut after it): back off to 60 s, as the + // primary pane does (app.js _maybeRefetchFullHistory). A full pane backs + // off too, since no window can ever fit in it. + if (rowsIncoming <= rowsBefore || paneFull) { + if (payload.truncated || paneFull) this._historyPullUseless = true; + return; + } + this._historyPullUseless = false; + term.write('\x1bc'); + replayed = true; + await writeChunked(term, buffer, () => this._destroyed); + if (this._destroyed || !this.terminal) return; + // xterm parses asynchronously: an empty write's callback fires only + // after everything before it, so the row count below is the settled one. + await new Promise((resolve) => this.terminal.write('', resolve)); + if (this._destroyed || !this.terminal) return; + // The replay grew the buffer UPWARD, so what was row 0 is now `delta` + // rows down; land there and the recovered history sits above it. + const delta = this.terminal.buffer.active.length - rowsBefore; + if (delta > 0) this.terminal.scrollToLine(delta); + else this.terminal.scrollToTop(); + } catch { + /* Best-effort — live output keeps arriving whatever happens here. */ + } finally { + const queued = this._liveQueue ?? []; + this._liveQueue = null; + // After a replay, only frames that arrived after the capture are news; + // earlier ones are already in it. With no replay, every held frame is. + const cutoff = replayed ? capturedAt : 0; + for (const entry of queued) { + if (entry.at < cutoff) continue; + if (entry.clear) this.terminal?.clear(); + else this.terminal?.write(entry.data); + } + // A replay's own `\x1bc` wipes the disconnected marker onclose wrote, + // painting a fresh, current-looking history while onData keeps + // silently dropping every keystroke on the dead socket. Re-stamp it + // if the socket closed in either order (before the pull started, or + // while the fetch was in flight) — checked after the queue flush so + // it is the last thing on screen, matching what onclose would have + // left had the pull never run. + if (replayed && this._wsClosed) this._writeDisconnectedMarker(); + this._endBufferLoad(); + } + } + // The `{t:'r'}` server-refresh path: clear, then replay. Two refresh // frames in a row used to start two concurrent replays, each clearing // the terminal under the other's chunked write. A refresh that arrives @@ -381,6 +552,10 @@ destroy() { this._destroyed = true; + if (this._onWheel) { + this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true }); + this._onWheel = null; + } if (this.ws) { this.ws.onopen = null; this.ws.onmessage = null; diff --git a/test/split-pane-terminal-unit.test.ts b/test/split-pane-terminal-unit.test.ts index 8ce88461..90de98cc 100644 --- a/test/split-pane-terminal-unit.test.ts +++ b/test/split-pane-terminal-unit.test.ts @@ -11,17 +11,31 @@ // refresh arriving mid-replay is now coalesced into ONE trailing re-run rather // than dropped, because the in-flight fetch may predate the drop the new frame // reports and no further frame comes to correct stale content. +// +// The last block covers the scroll-to-top history pull: a burst of output leaves +// a shell pane's xterm with about one screen of scrollback while tmux holds every +// line, and Pane B (a separate xterm from the primary pane) never went back to +// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js. import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const TERMINAL_CHUNK_SIZE = 32 * 1024; +const TERMINAL_TAIL_SIZE = 1024 * 1024; +/** The pane's `performance.now()`, so frame arrival vs. capture time is set by hand, not raced. */ +let clock = 0; type FakeTerminal = { write: ReturnType; clear: ReturnType; dispose: ReturnType; + scrollToLine: ReturnType; + scrollToTop: ReturnType; + cols: number; + rows: number; + options: { scrollback: number }; + buffer: { active: { type: string; viewportY: number; length: number } }; }; type FakeSocket = { onopen: unknown; @@ -36,43 +50,79 @@ type PaneUnderTest = { _destroyed: boolean; _bufferLoading: boolean; _bufferRefreshPending: boolean; + _historyPullAt: number; + _historyPullUseless: boolean; + _liveQueue: unknown[] | null; + _onWheel: unknown; + _wsClosed: boolean; + detachedSessions: Set | undefined; destroy(): void; _loadBuffer(): Promise; _refreshBuffer(): void; + _maybeLoadMoreHistory(): void; + _pullHistory(): Promise; + _onLiveOutput(data: string): void; + _onLiveClear(): void; + _installWheelListener(): void; + _writeDisconnectedMarker(): void; }; const fetchMock = vi.fn(); /** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */ const rafQueue: Array<() => void> = []; +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8'); function loadSplitTerminalPane() { - const dir = resolve(import.meta.dirname, '../src/web/public'); - const src = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8'); const context = vm.createContext({ console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() }, - window: {}, + // The primary pane's row estimator, reduced to a line count: the pull only + // compares it with the pane's own row count. + window: { + app: { _estimateReplayRows: (text: string) => text.split('\n').length }, + AbortSignal: { timeout: (ms: number) => ({ timeoutMs: ms }) }, + }, + performance: { now: () => clock }, fetch: (...args: unknown[]) => fetchMock(...args), requestAnimationFrame: (fn: () => void) => rafQueue.push(fn), // The constants.js globals the module reads at call time. TERMINAL_CHUNK_SIZE, - TERMINAL_TAIL_SIZE: 1024 * 1024, + TERMINAL_TAIL_SIZE, }); // The module's tail patches CodemanApp.prototype; nothing on it runs here. - vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${src}`, context); + vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context); return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest }) .SplitTerminalPane; } const SplitTerminalPane = loadSplitTerminalPane(); -function makePane(mode = 'claude'): PaneUnderTest & { terminal: FakeTerminal } { - const pane = new SplitTerminalPane('s1', {}, { mode }); - pane.terminal = { write: vi.fn(), clear: vi.fn(), dispose: vi.fn() }; +function makePane( + mode = 'claude', + mount: unknown = {}, + opts: { detachedSessions?: Set } = {} +): PaneUnderTest & { terminal: FakeTerminal } { + const pane = new SplitTerminalPane('s1', mount, { mode, ...opts }); + pane.terminal = { + // xterm invokes a write's callback once everything before it is parsed. + write: vi.fn((_data: string, done?: () => void) => done?.()), + clear: vi.fn(), + dispose: vi.fn(), + scrollToLine: vi.fn(), + scrollToTop: vi.fn(), + cols: 80, + rows: 30, + // xterm keeps at most `scrollback + rows` rows; small here so a test can fill it. + options: { scrollback: 1000 }, + // A pane sitting at the top of a 40-row buffer on the normal screen. + buffer: { active: { type: 'normal', viewportY: 0, length: 40 } }, + }; return pane as PaneUnderTest & { terminal: FakeTerminal }; } -function jsonResponse(terminalBuffer: string) { - return { json: async () => ({ data: { terminalBuffer } }) }; +const rowsOf = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\n'); + +function jsonResponse(terminalBuffer: string, extra: Record = {}) { + return { json: async () => ({ data: { terminalBuffer, ...extra } }) }; } function deferred() { @@ -89,6 +139,7 @@ const settle = () => new Promise((r) => setTimeout(r, 0)); beforeEach(() => { fetchMock.mockReset(); rafQueue.length = 0; + clock = 0; }); describe('SplitTerminalPane.destroy()', () => { @@ -232,3 +283,444 @@ describe('SplitTerminalPane server-refresh single-flight', () => { expect(pane.terminal.write).toHaveBeenCalledWith('back'); }); }); + +describe('SplitTerminalPane 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'); + const term = pane.terminal; + // The replay grows the buffer once xterm has parsed it (the empty write's callback). + term.write.mockImplementation((data: string, done?: () => void) => { + if (data === '' && done) term.buffer.active.length = 140; + done?.(); + }); + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100))); + + pane._maybeLoadMoreHistory(); + await settle(); + + // With a deadline: live output is held for as long as the pull runs, so a + // request that never answers would freeze the pane. + expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, { + signal: { timeoutMs: 10_000 }, + }); + expect(term.write).toHaveBeenCalledWith('\x1bc'); + expect(term.write).toHaveBeenCalledWith(rowsOf(100)); + // What was row 0 is now 100 rows down (140 - 40): the reader keeps their + // place with the recovered history above it, instead of being dropped at the bottom. + expect(term.scrollToLine).toHaveBeenCalledWith(100); + expect(pane._bufferLoading).toBe(false); + expect(pane._liveQueue).toBeNull(); + }); + + it('does nothing away from the top, for other modes, or on the alternate screen', async () => { + const midScroll = makePane('shell'); + midScroll.terminal.buffer.active.viewportY = 12; + midScroll._maybeLoadMoreHistory(); + + // A repaint-mode agent CLI keeps no tmux history to recover. + makePane('claude')._maybeLoadMoreHistory(); + + // nano/vim/less own the wheel; their screen is not scrollback. + const fullScreenApp = makePane('shell'); + fullScreenApp.terminal.buffer.active.type = 'alternate'; + fullScreenApp._maybeLoadMoreHistory(); + + await settle(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('stands aside for a detached session, mirroring _sendResize()', async () => { + // A detached session's own window already owns its PTY size and + // scrollback (buildSplitPickerSessions() already refuses to open one). + const pane = makePane('shell', {}, { detachedSessions: new Set(['s1']) }); + + pane._maybeLoadMoreHistory(); + await settle(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('a flick fires once: overlapping triggers are dropped, then the cooldown holds', async () => { + const pane = makePane('shell'); + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise); + + pane._maybeLoadMoreHistory(); + const startedAt = pane._historyPullAt; + // The cooldown is cleared between triggers on purpose, so that only the + // in-flight guard can be what drops the overlapping ones. + pane._historyPullAt = 0; + pane._maybeLoadMoreHistory(); + pane._historyPullAt = 0; + pane._maybeLoadMoreHistory(); + expect(fetchMock).toHaveBeenCalledTimes(1); + pane._historyPullAt = startedAt; + + response.resolve(jsonResponse(rowsOf(100))); + await settle(); + expect(pane._bufferLoading).toBe(false); + + // Nothing in flight any more, so now it is the 4s cooldown alone. + pane._maybeLoadMoreHistory(); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Once the cooldown lapses a later scroll-to-top may pull again. + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100))); + pane._historyPullAt = Date.now() - 5000; + pane._maybeLoadMoreHistory(); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('a window the pane already holds in full is not rewritten, and is not latched as useless', async () => { + const pane = makePane('shell'); + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30))); + + pane._maybeLoadMoreHistory(); + await settle(); + + // A reset+rewrite here would jump the viewport for no new rows. + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc'); + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled(); + expect(pane.terminal.scrollToTop).not.toHaveBeenCalled(); + // The next burst can put more history in tmux than the pane has. + expect(pane._historyPullUseless).toBe(false); + expect(pane._bufferLoading).toBe(false); + }); + + it('refuses a downgrade, keeping the 4s cooldown when the window is all of tmux history', async () => { + const pane = makePane('shell'); + pane.terminal.buffer.active.length = 500; + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5))); + + pane._maybeLoadMoreHistory(); + await settle(); + + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc'); + // Untruncated: tmux has nothing older, but the next burst can add history. + expect(pane._historyPullUseless).toBe(false); + }); + + it('a truncated window that fits in the pane backs off for a minute', async () => { + // Every ask costs the server a capture-pane of the WHOLE history (`tail` is + // cut after the capture), and a window cut at the tail size can never reach + // anything older than what the pane already shows. + const pane = makePane('shell'); + pane.terminal.buffer.active.length = 500; + // Within a screen of what the pane holds, so the old downgrade guard never + // latched it: only the truncated-skip rule can back this off. + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(480), { truncated: true, truncationReason: 'tail' })); + + pane._maybeLoadMoreHistory(); + await settle(); + + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc'); + expect(pane._historyPullUseless).toBe(true); + + // Inside the 60s back-off, well past the normal 4s cooldown. + pane._historyPullAt = Date.now() - 10_000; + pane._maybeLoadMoreHistory(); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('a pane already at its scrollback cap skips the window and backs off for a minute', async () => { + // A 1 MiB window of short lines can carry more rows than xterm will ever hold + // (`scrollback + rows`), so `incoming <= rows held` never comes true and every + // scroll-to-top would reset and re-parse it. + const pane = makePane('shell'); + pane.terminal.buffer.active.length = 1030; + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5000))); + + pane._maybeLoadMoreHistory(); + await settle(); + + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc'); + expect(pane.terminal.write).not.toHaveBeenCalledWith(rowsOf(5000)); + expect(pane._historyPullUseless).toBe(true); + }); + + it('a successful replay clears the one-minute back-off', async () => { + const pane = makePane('shell'); + pane._historyPullUseless = true; + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100), { truncated: true, truncationReason: 'tail' })); + + void pane._pullHistory(); + await settle(); + + expect(pane.terminal.write).toHaveBeenCalledWith(rowsOf(100)); + expect(pane._historyPullUseless).toBe(false); + }); + + it('holds live output during the replay and replays only what arrived after the capture', async () => { + const pane = makePane('shell'); + const term = pane.terminal; + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise); + + pane._maybeLoadMoreHistory(); + expect(pane._liveQueue).toEqual([]); + + // Arrives before the response does: it is IN the capture already. + clock = 1; + pane._onLiveOutput('early'); + expect(term.write).not.toHaveBeenCalledWith('early'); + await settle(); + + // 200 rows (more than the pane holds, so it replays) of 400 columns each: + // three chunks, which leaves the replay mid-write once the fetch lands. + const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n'); + expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2); + clock = 2; // the response arrives: this is the cutoff + response.resolve(jsonResponse(bigReplay)); + await settle(); + expect(rafQueue).toHaveLength(1); + + // Arrives while the snapshot is still being written: must not land under it. + clock = 3; + pane._onLiveOutput('late'); + expect(term.write).not.toHaveBeenCalledWith('late'); + + rafQueue.shift()!(); + rafQueue.shift()!(); + await settle(); + + const written = term.write.mock.calls.map((call) => call[0]); + expect(written).not.toContain('early'); + expect(written.at(-1)).toBe('late'); + expect(pane._liveQueue).toBeNull(); + expect(pane._bufferLoading).toBe(false); + }); + + it('writes every held frame when the pull ends without replaying', async () => { + const pane = makePane('shell'); + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise); + + pane._maybeLoadMoreHistory(); + pane._onLiveOutput('held'); + await settle(); + response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay + await settle(); + + // Nothing replaced the terminal, so the frame is news even though it + // arrived before the response did. + expect(pane.terminal.write).toHaveBeenCalledWith('held'); + }); + + it('a failed fetch releases the flag and the queue, so live output flows again', async () => { + const pane = makePane('shell'); + fetchMock.mockRejectedValueOnce(new Error('offline')); + + pane._maybeLoadMoreHistory(); + pane._onLiveOutput('held'); + await settle(); + + expect(pane._bufferLoading).toBe(false); + expect(pane._liveQueue).toBeNull(); + expect(pane.terminal.write).toHaveBeenCalledWith('held'); + pane._onLiveOutput('after'); + expect(pane.terminal.write).toHaveBeenLastCalledWith('after'); + }); + + it('a refresh frame during the pull runs once behind it', async () => { + const pane = makePane('shell'); + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed')); + + pane._maybeLoadMoreHistory(); + pane._refreshBuffer(); + expect(pane.terminal.clear).not.toHaveBeenCalled(); + expect(pane._bufferRefreshPending).toBe(true); + + response.resolve(jsonResponse(rowsOf(30))); + await settle(); + + expect(pane.terminal.clear).toHaveBeenCalledTimes(1); + 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 () => { + const pane = makePane('shell'); + const term = pane.terminal; + const order: string[] = []; + term.write.mockImplementation((data: string, done?: () => void) => { + order.push(`write:${data}`); + done?.(); + }); + term.clear.mockImplementation(() => order.push('clear')); + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise); + + pane._maybeLoadMoreHistory(); + pane._onLiveOutput('before'); + pane._onLiveClear(); + pane._onLiveOutput('after'); + // Held: clearing now would wipe a half-written snapshot. + expect(order).toEqual([]); + + response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay + await settle(); + + expect(order).toEqual(['write:before', 'clear', 'write:after']); + expect(pane._liveQueue).toBeNull(); + + // With nothing in flight a clear frame applies straight away. + pane._onLiveClear(); + expect(order.at(-1)).toBe('clear'); + }); + + it('a clear that arrived before the capture is not replayed after it', async () => { + const pane = makePane('shell'); + const term = pane.terminal; + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise); + + pane._maybeLoadMoreHistory(); + clock = 1; + pane._onLiveClear(); // already reflected in the capture + clock = 2; + response.resolve(jsonResponse(rowsOf(100))); + await settle(); + + expect(term.write).toHaveBeenCalledWith('\x1bc'); + expect(term.clear).not.toHaveBeenCalled(); + }); + + it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => { + const pane = makePane('shell'); + const term = pane.terminal; + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise); + + pane._maybeLoadMoreHistory(); + pane._onLiveOutput('held'); + pane.destroy(); + response.resolve(jsonResponse(rowsOf(100))); + await settle(); + + expect(pane._bufferLoading).toBe(false); + expect(pane._liveQueue).toBeNull(); + expect(pane.terminal).toBeNull(); + expect(term.write).not.toHaveBeenCalledWith('\x1bc'); + expect(term.write).not.toHaveBeenCalledWith('held'); + }); + + it('a pull whose request is aborted (the deadline) frees the pane', async () => { + const pane = makePane('shell'); + fetchMock.mockRejectedValueOnce(new Error('The operation timed out')); + + pane._maybeLoadMoreHistory(); + pane._onLiveOutput('held'); + await settle(); + + expect(pane._bufferLoading).toBe(false); + expect(pane._liveQueue).toBeNull(); + expect(pane.terminal.write).toHaveBeenCalledWith('held'); + }); + + it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => { + const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() }; + const pane = makePane('shell', mount); + fetchMock.mockResolvedValue(jsonResponse(rowsOf(100))); + + pane._installWheelListener(); + + // Capture phase: xterm's own wheel handler stopPropagation()s the events it + // consumes, so a bubbling listener would never fire while the pane still has + // scrollback to scroll, and the pull would work only from the exact top row. + const [type, listener, options] = mount.addEventListener.mock.calls[0]; + expect(type).toBe('wheel'); + expect(options).toEqual({ capture: true, passive: true }); + + listener({ deltaY: 120 }); // wheel down + listener({ deltaY: 0 }); + await settle(); + expect(fetchMock).not.toHaveBeenCalled(); + + listener({ deltaY: -120 }); // wheel up, at the top + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('destroy() detaches exactly the wheel listener it registered', () => { + const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() }; + const pane = makePane('shell', mount); + pane._installWheelListener(); + const registered = mount.addEventListener.mock.calls[0][1]; + + pane.destroy(); + + expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', registered, { capture: true }); + expect(pane._onWheel).toBeNull(); + }); + + it('connect() installs the wheel listener (static guard)', () => { + // connect() needs a whole xterm to run, so its wiring is pinned by source + // rather than executed; the listener's behaviour is exercised above. + const connect = SOURCE.slice(SOURCE.indexOf('async connect()'), SOURCE.indexOf('async _loadBuffer()')); + expect(connect).toContain('this._installWheelListener();'); + expect(connect).toContain('this._onLiveClear();'); + expect(connect).not.toContain('this.terminal.clear();'); + }); + + it('re-stamps the disconnected marker after a replay if the socket closed before the pull started', async () => { + // onclose already wrote the marker once; a replay's own `\x1bc` would wipe + // it and paint a fresh, current-looking history while onData keeps + // silently dropping every keystroke on the dead socket. + const pane = makePane('shell'); + pane._wsClosed = true; + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100))); + + void pane._pullHistory(); + await settle(); + + const marker = expect.stringContaining('Pane B disconnected'); + const writes = pane.terminal.write.mock.calls.map((c) => c[0]); + expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/)); + expect(pane.terminal.write).toHaveBeenCalledWith(marker); + }); + + it('re-stamps the disconnected marker after a replay if the socket closes mid-fetch', async () => { + // The other order Ark0N's review called out: the close lands while the + // capture is in flight, so the HTTP pull still succeeds (a Codeman + // restart drops the WS while the tmux session, and so the pull, survives). + const pane = makePane('shell'); + const response = deferred>(); + fetchMock.mockReturnValueOnce(response.promise); + + const pull = pane._pullHistory(); + pane._wsClosed = true; // the close arrives mid-fetch, before the response + response.resolve(jsonResponse(rowsOf(100))); + await pull; + + expect(pane.terminal.write.mock.calls.at(-1)?.[0]).toEqual(expect.stringMatching(/Pane B disconnected/)); + }); + + it('does not re-stamp the marker when the socket is still open', async () => { + const pane = makePane('shell'); + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100))); + + void pane._pullHistory(); + await settle(); + + for (const call of pane.terminal.write.mock.calls) { + expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/)); + } + }); + + it('does not re-stamp the marker when the pull never replayed (skip/downgrade path)', async () => { + // Nothing erased the marker in this path, so re-stamping it would be a + // second, redundant write. + const pane = makePane('shell'); + pane._wsClosed = true; + fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30))); // held in full already: no replay + + void pane._pullHistory(); + await settle(); + + expect(pane.terminal.write).not.toHaveBeenCalled(); + }); +});