diff --git a/CLAUDE.md b/CLAUDE.md index d58718b0..0ac17e42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,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**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) +**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, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) diff --git a/docs/api-reference.md b/docs/api-reference.md index da5efdd2..4c57022f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -543,20 +543,23 @@ client that opens many concurrent waits against one session will still hit the c What a session's terminal shows, for a client to replay: `data.terminalBuffer`, with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`, -`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's -geometry was read. `paneHistoryLines` (present when the body is a pane capture whose -geometry was read, like `captureCols`/`captureRows`) is the number of scrollback rows tmux holds above the visible frame, which is the -most a `full=1` request can add. `truncated` describes the byte stream instead: for a -pane with `paneHistoryLines: 0` (a fullscreen CLI in the alternate screen) the bytes a -`tail` cut dropped are earlier repaints that no request returns. The capture runs -synchronous tmux calls on the server; the `Server-Timing` header reports `capture`, -`prepare` and `total`. +`truncationReason`, `fullSize`, `historyLinesCut` (a `lines=` bound left older +lines in tmux, so the remainder is more than `fullSize - retainedBytes`), and +`captureCols`/`captureRows` when the pane's geometry was read. `paneHistoryLines` +(present when the body is a pane capture whose geometry was read, like +`captureCols`/`captureRows`) is the number of scrollback rows tmux holds above the +visible frame, which is the most a `full=1` request can add. `truncated` describes +the byte stream instead: for a pane with `paneHistoryLines: 0` (a fullscreen CLI in +the alternate screen) the bytes a `tail` cut dropped are earlier repaints that no +request returns. The capture runs synchronous tmux calls on the server; the +`Server-Timing` header reports `capture`, `prepare` and `total`. | Query | Meaning | |---|---| | `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. | | `tail=` | Keep the newest `` of the result (`truncationReason: 'tail'` when it cut). | -| `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | +| `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | +| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | ## The `codeman agent` CLI (client over these endpoints) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 76dfb7c3..3b9fe6aa 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -209,9 +209,9 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` ### Full-scrollback replay -**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell FULL history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. That gesture instead pulls a BOUNDED window of it (`?full=1&tail=<TERMINAL_TAIL_SIZE>`, so a capture beyond 1 MiB comes back `truncationReason: 'tail'` and the banner offers the rest), and skips the replay when the window holds no more rows than the browser already has. ⚠️ **That skip runs BEFORE the downgrade guard, and it never touches the banner state**: the guard reads "smaller than the browser" as "tmux has nothing more to give", true of an unbounded capture and false of a window cut at the tail size, so a bounded window that reached it marked the session `exhausted` and removed **Load full history** while tmux still held the rest; and re-labelling the banner from a skipped payload would call a terminal that holds all of a Load full history pull "the most recent 1 MiB". A skipped window that came back truncated puts the session on the 60 s cooldown (`_fullHistoryRepullUseless`), because it can never reach anything older than the browser shows and `tail` is applied AFTER the server's synchronous `capture-pane` of the whole history, so each ask costs every client an event-loop stall (about 0.3 s at 100k lines); an untruncated one keeps the 4 s cooldown, since it is all of tmux's history and the next burst can add to it. Declining the gesture outright was a dead end: after any burst a shell pane held about one screen of browser scrollback, and the button renders only once a replay was truncated, so a young shell tab had no way back to history tmux still held. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, and the marker callback supplies accurate parse timing. ⚠️ **How the load ENDS depends on where the payload came from**, and `_bufferLoadFinishOpts` (app.js) is the one place that decides it for all four fetch-and-write paths. A payload built from the server's accumulated byte history is current up to the response, so the events queued during the load already appear in it and stay DISCARDED; replaying them would duplicate output, most visibly Ink's cursor-up redraws. A pane capture (`mux-visible` or `mux-full-history`) is current only up to CAPTURE time, so `_finishBufferLoad` replays the queue from the response's own arrival timestamp (`since`) and the pre-capture events stay dropped. ⚠️ **A path that then restores a scroll position must re-take the sticky-scroll baseline** (`_syncStickyScrollBaseline`): the replay runs inside `chunkedTerminalWrite` before its promise resolves, with the terminal freshly reset, so `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` as true and the next `flushPendingWrites` would scroll to the bottom over the restore. ⚠️ The cutoff is a client-side timestamp and the server broadcasts on a batch timer (8ms WebSocket, 16-50ms SSE), so a batch pending when the capture ran arrives after the response and replays although the capture holds it — bounded by one batch interval, and closable only server side by flushing that batch before the capture. Tests for the three: `test/terminal-flush-budget.test.ts` pins which sources flush, `test/terminal-buffer-flush.test.ts` pins the `since` cutoff and the baseline re-take, and `test/capture-load-window.browser.test.ts` drives both against a live server. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The capture ENDS with a cursor move back to the pane's own caret position** (`formatCursorRestore`, from the same `display-message` query the visible-frame path uses). The linear replay otherwise leaves the caret wherever the last character landed — the bottom-most row carrying text, which for an agent CLI is the status line — so the caret sat on the composer's border instead of its input line and every cursor-relative update the CLI sent afterwards was measured from the wrong row, until its next full redraw silently repaired it (that self-repair is why the report read as "it fixes itself as soon as Claude writes a line"). ⚠️ **The move is RELATIVE — up `rows - 1 - cursor_y`, then `\r`, then right `cursor_x` — never `CUP`.** `\x1b[<row>;<col>H` numbers rows from the top of the browser's screen, so it lands correctly only while the browser's row count equals `pane_height`, and nothing guarantees that: `resizeWindow` issues its tmux resize fire-and-forget and returns immediately, so a capture can be taken before a requested resize has applied, and `_onSessionNeedsRefresh` sends no resize at all. Counting up from the last replayed row anchors to the content both ends share. Restoring the cursor makes ROW ALIGNMENT load-bearing on this path: **no transform that can DELETE A LINE may run over a full-history capture**, because every deletion shifts the frame out from under the restored position. Four had accumulated — trailing blank rows stripped by `\n+$`, `stripInkRedrawBloat`, the `CLAUDE_BANNER_PATTERN` trim that cuts everything above the banner, and `LEADING_WHITESPACE_PATTERN` — each correct for a byte stream of successive frames and each wrong for a single rendered frame. ⚠️ **Those skips key on `isFullCapture`, meaning a capture actually came back — never on `?full=1` alone.** When `captureActivePaneBuffer` returns null (ENOBUFS, a timeout, a vanished pane, or a session with no mux at all) the reply falls back to `session.terminalBuffer`, which IS a byte stream and must still be stripped; gating on the query flag returned it whole, and a direct-PTY session takes that path on every first selection rather than only during an outage. ⚠️ A capture holding nothing visible (`hasVisibleContent`) returns `''`, because the caller reads an empty capture as "unavailable" and keeps its byte history — retaining trailing blank rows made an all-blank pane non-empty, which would have replaced real history with a blank screen from the server side, where `_replayWouldShrinkBuffer` cannot see it. ⚠️ **"One line per screen row" holds only where no row was hard-wrapped**: `-J` joins a wrapped row into its logical line (measured: a 100-character line in a 40-column pane captures as 10 lines against a 12-row pane), and the counts reconcile only once the browser xterm re-wraps at the same width — the same assumption `_estimateReplayRows` already documents. Tests: `test/tmux-capture-full-history.test.ts` covers the cursor move, the trim pairing and `hasVisibleContent`; `test/routes/session-routes.test.ts` covers a surviving blank first row, an unstripped byte-history fallback, and an empty capture leaving history intact. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`, `test/terminal-flush-budget.test.ts`. +**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded window of tmux's RENDERED history on every selection, automatic drop recovery, clear and tile load (`SHELL_LOAD_QUERY` in constants.js: `?full=1&tail=1MiB&lines=10000&alt=recording`): a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session (`lines=` bounds the synchronous capture-pane, about 8 ms per 1000 lines). They never replay the raw byte recording ahead of the visible frame any more: it holds full-screen repaints (an ssh'd tmux, `less`, any TUI) drawn for the pane height of the moment, and replayed into a terminal of another height each repaint's last newline scrolls its top row into scrollback, so one line tmux holds once showed up dozens of times. Because `lines=` is shallower than xterm's scrollback, the route reports a cut: the capture's `display-message` query also reads `#{history_size}` (`PaneCaptureOptions.capturedHistoryLines`, the same field the route reports as `paneHistoryLines`), and a full capture whose CLIENT bound (`lines=` below the configured `tmuxHistoryLimit`) left lines in tmux comes back `truncated`, `truncationReason: 'tail'`. ⚠️ Only a client bound below the ceiling counts: a request without `lines=` is bounded by the ceiling itself, and on tmux before 3.7 a live pane can hold more than a lowered ceiling, which no request can read. ⚠️ **A pane in the alternate screen keeps no tmux history**, and a nested tmux client (remote SSH and Docker cases, or `tmux`/`ssh -t host tmux` run by hand) holds its pane there, so its full capture is one screen. The same query reads `#{alternate_on}` (`capturedAlternateOn`), and a request carrying `alt=recording` (only `SHELL_LOAD_QUERY`) is then answered the old way, the visible frame over the tailed byte recording, which still carries the scrollback; agents, scroll pulls and **Load full history** never send it. `#{alternate_on}` cannot tell a nested tmux from vim, `less` or `htop`, so a local shell with one of those open takes the same fallback until it exits (the pre-fix load, with its repaint duplicates). Shell FULL history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. That gesture instead pulls a BOUNDED window of it (`?full=1&tail=<TERMINAL_TAIL_SIZE>`, so a capture beyond 1 MiB comes back `truncationReason: 'tail'` and the banner offers the rest), and skips the replay when the window holds no more rows than the browser already has. ⚠️ **That skip runs BEFORE the downgrade guard, and it never touches the banner state**: the guard reads "smaller than the browser" as "tmux has nothing more to give", true of an unbounded capture and false of a window cut at the tail size, so a bounded window that reached it marked the session `exhausted` and removed **Load full history** while tmux still held the rest; and re-labelling the banner from a skipped payload would call a terminal that holds all of a Load full history pull "the most recent 1 MiB". A skipped window that came back truncated puts the session on the 60 s cooldown (`_fullHistoryRepullUseless`), because it can never reach anything older than the browser shows and `tail` is applied AFTER the server's synchronous `capture-pane` of the whole history, so each ask costs every client an event-loop stall (about 0.3 s at 100k lines); an untruncated one keeps the 4 s cooldown, since it is all of tmux's history and the next burst can add to it. Declining the gesture outright was a dead end: after any burst a shell pane held about one screen of browser scrollback, and the button renders only once a replay was truncated, so a young shell tab had no way back to history tmux still held. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, and the marker callback supplies accurate parse timing. ⚠️ **How the load ENDS depends on where the payload came from**, and `_bufferLoadFinishOpts` (app.js) is the one place that decides it for all four fetch-and-write paths. A payload built from the server's accumulated byte history is current up to the response, so the events queued during the load already appear in it and stay DISCARDED; replaying them would duplicate output, most visibly Ink's cursor-up redraws. A pane capture (`mux-visible` or `mux-full-history`) is current only up to CAPTURE time, so `_finishBufferLoad` replays the queue from the response's own arrival timestamp (`since`) and the pre-capture events stay dropped. ⚠️ **A path that then restores a scroll position must re-take the sticky-scroll baseline** (`_syncStickyScrollBaseline`): the replay runs inside `chunkedTerminalWrite` before its promise resolves, with the terminal freshly reset, so `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` as true and the next `flushPendingWrites` would scroll to the bottom over the restore. ⚠️ The cutoff is a client-side timestamp and the server broadcasts on a batch timer (8ms WebSocket, 16-50ms SSE), so a batch pending when the capture ran arrives after the response and replays although the capture holds it — bounded by one batch interval, and closable only server side by flushing that batch before the capture. Tests for the three: `test/terminal-flush-budget.test.ts` pins which sources flush, `test/terminal-buffer-flush.test.ts` pins the `since` cutoff and the baseline re-take, and `test/capture-load-window.browser.test.ts` drives both against a live server. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The capture ENDS with a cursor move back to the pane's own caret position** (`formatCursorRestore`, from the same `display-message` query the visible-frame path uses). The linear replay otherwise leaves the caret wherever the last character landed — the bottom-most row carrying text, which for an agent CLI is the status line — so the caret sat on the composer's border instead of its input line and every cursor-relative update the CLI sent afterwards was measured from the wrong row, until its next full redraw silently repaired it (that self-repair is why the report read as "it fixes itself as soon as Claude writes a line"). ⚠️ **The move is RELATIVE — up `rows - 1 - cursor_y`, then `\r`, then right `cursor_x` — never `CUP`.** `\x1b[<row>;<col>H` numbers rows from the top of the browser's screen, so it lands correctly only while the browser's row count equals `pane_height`, and nothing guarantees that: `resizeWindow` issues its tmux resize fire-and-forget and returns immediately, so a capture can be taken before a requested resize has applied, and `_onSessionNeedsRefresh` sends no resize at all. Counting up from the last replayed row anchors to the content both ends share. Restoring the cursor makes ROW ALIGNMENT load-bearing on this path: **no transform that can DELETE A LINE may run over a full-history capture**, because every deletion shifts the frame out from under the restored position. Four had accumulated — trailing blank rows stripped by `\n+$`, `stripInkRedrawBloat`, the `CLAUDE_BANNER_PATTERN` trim that cuts everything above the banner, and `LEADING_WHITESPACE_PATTERN` — each correct for a byte stream of successive frames and each wrong for a single rendered frame. ⚠️ **Those skips key on `isFullCapture`, meaning a capture actually came back — never on `?full=1` alone.** When `captureActivePaneBuffer` returns null (ENOBUFS, a timeout, a vanished pane, or a session with no mux at all) the reply falls back to `session.terminalBuffer`, which IS a byte stream and must still be stripped; gating on the query flag returned it whole, and a direct-PTY session takes that path on every first selection rather than only during an outage. ⚠️ A capture holding nothing visible (`hasVisibleContent`) returns `''`, because the caller reads an empty capture as "unavailable" and keeps its byte history — retaining trailing blank rows made an all-blank pane non-empty, which would have replaced real history with a blank screen from the server side, where `_replayWouldShrinkBuffer` cannot see it. ⚠️ **"One line per screen row" holds only where no row was hard-wrapped**: `-J` joins a wrapped row into its logical line (measured: a 100-character line in a 40-column pane captures as 10 lines against a 12-row pane), and the counts reconcile only once the browser xterm re-wraps at the same width — the same assumption `_estimateReplayRows` already documents. Tests: `test/tmux-capture-full-history.test.ts` covers the cursor move, the trim pairing and `hasVisibleContent`; `test/routes/session-routes.test.ts` covers a surviving blank first row, an unstripped byte-history fallback, and an empty capture leaving history intact. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`, `test/terminal-flush-budget.test.ts`. -**The partial-history notice is lazy and pane-gated** (`computeHistoryTruncationNotice()` in constants.js, `_renderHistoryTruncationBanner()` / `_setHistoryNoticeRevealed()` in app.js). `truncated` on a terminal response measures the server's BYTE stream, which is not what `full=1` can return: for a pane with no tmux scrollback (fullscreen claude starts in the alternate screen and never writes to the main grid, so `#{history_size}` is 0) the bytes a tail cut drops are old repaint frames. Measured on prod 2026-10-10, every claude pane: the tab-switch tail cut a 1.2 to 6.2 MB stream to 1 MiB, the local scrollback that tail replays into is repeats of the same frames (one pane: 450 non-blank rows, 44 unique, one row 33 times), and `full=1` returned 1 to 5 KB, so **Load full history** could only end in the downgrade refusal while the banner promised "4.8 MB more". The capture therefore reads `#{history_size}` in the SAME `display-message` as the cursor (no extra tmux call), the route returns it as `paneHistoryLines` (absent when the body is not a capture), and a reported 0 hides the notice in every state; a positive count replaces the byte gap in the message. ⚠️ Key the decision on `history_size`, never on `alternate_on`: a shell running `less` is in the alternate screen with its whole history still retained (measured, 99,972 rows) and `full=1` returns all of it. The notice is also LAZY: it appears only after a wheel/touch gesture reaches the top of the browser buffer and the pull that gesture started has settled (`_maybeLoadMoreHistoryOnScroll`), goes away when a downward gesture is back at live output or the tab switches (`selectSession` nulls `_historyNoticeRevealedFor` before repainting), and its × is sticky per session until reload (`_historyNoticeDismissed`). Tests: `test/history-truncation-notice.test.ts`, `test/history-notice-reveal.test.ts`. +**The partial-history notice is lazy and pane-gated** (`computeHistoryTruncationNotice()` in constants.js, `_renderHistoryTruncationBanner()` / `_setHistoryNoticeRevealed()` in app.js). `truncated` on a terminal response measures the server's BYTE stream, which is not what `full=1` can return: for a pane with no tmux scrollback (fullscreen claude starts in the alternate screen and never writes to the main grid, so `#{history_size}` is 0) the bytes a tail cut drops are old repaint frames. Measured on prod 2026-10-10, every claude pane: the tab-switch tail cut a 1.2 to 6.2 MB stream to 1 MiB, the local scrollback that tail replays into is repeats of the same frames (one pane: 450 non-blank rows, 44 unique, one row 33 times), and `full=1` returned 1 to 5 KB, so **Load full history** could only end in the downgrade refusal while the banner promised "4.8 MB more". The capture therefore reads `#{history_size}` in the SAME `display-message` as the cursor (no extra tmux call), the route returns it as `paneHistoryLines` (absent when the body is not a capture), and a reported 0 hides the notice in every state; a positive count replaces the byte gap in the message. ⚠️ Key the notice decision on `history_size`, never on `alternate_on`: a shell running `less` is in the alternate screen with its whole history still retained (measured, 99,972 rows) and `full=1` returns all of it. The one place `alternate_on` decides is a shell's AUTOMATIC load (`alt=recording`, see Full-scrollback replay): there the question is whether the capture holds the scrollback a nested tmux client keeps in its byte recording, not whether a pull can return more. The notice is also LAZY: it appears only after a wheel/touch gesture reaches the top of the browser buffer and the pull that gesture started has settled (`_maybeLoadMoreHistoryOnScroll`), goes away when a downward gesture is back at live output or the tab switches (`selectSession` nulls `_historyNoticeRevealedFor` before repainting), and its × is sticky per session until reload (`_historyNoticeDismissed`). Tests: `test/history-truncation-notice.test.ts`, `test/history-notice-reveal.test.ts`. **A tab switch to a pane with no tmux scrollback takes `full=1`, not the 1 MiB tail** (`_paneHistoryLines` + `_notePaneHistory()` in app.js). Every terminal response (select, refresh, re-pull) records `paneHistoryLines` per session, and `selectSession` serves a non-shell switch from `full=1` whenever the last report was 0. For such a pane that capture IS the visible frame (0.9 to 5.6 KB on prod) and matches what a page load shows, while the tail was 1 MiB of old repaints. Measured 2026-10-10 with real prod payloads through the real client in chromium: median switch 1.26 to 1.66 s down to 0.10 to 0.19 s, parse ~1.1 to 1.5 s down to 2 to 3 ms; server time 136 to 1070 ms (tail) vs 89 to 330 ms (full). An empty local buffer is also what lets `_maybePageCliTranscript` send a gesture to the CLI's own transcript. ⚠️ Only a reported 0 counts: a response without the field FORGETS the entry (unknown is never empty), so an older server or a byte-history fallback keeps the tail; a pane that starts keeping history (claude switched to its inline view) is learned from the next response and goes back to the tail. ⚠️ Shells never take this path. ⚠️ Remote and Docker sessions never take this path: their local pane hosts a nested tmux client in the alternate screen, so it reports 0 while the byte tail carries the real scrolled-off output. ⚠️ The refresh path (`_onSessionNeedsRefresh`) keeps its downgrade guard unchanged: a server-triggered refresh must not wipe local rows the user may be reading. Tests: `test/pane-history-memory.test.ts`, `test/fullscreen-tab-switch-capture.browser.test.ts` (browser suite). @@ -829,7 +829,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t ⚠️ **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-<id>`, `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, 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=<TILE_SCROLLBACK + rows>`, 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. +⚠️ **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, `SHELL_LOAD_BASE_QUERY` for a shell, the shell load without its fixed `lines=`; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell load, a shell history pull) also sends `lines=<TILE_SCROLLBACK + rows>`, 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 for a TUI and `SHELL_LOAD_QUERY`'s `lines=10000` for a shell), 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 it tears the tile down (like `wasActive`) and focuses it with `auto` before the delete request goes out; 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/src/mux-interface.ts b/src/mux-interface.ts index 4baceac8..d5aa5869 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -210,9 +210,18 @@ export interface PaneCaptureOptions { * same query as the geometry. 0 means a full-history capture can return * nothing beyond the visible frame: a pane in the alternate screen (a * fullscreen CLI that keeps its transcript itself) never accumulates any. - * Absent when the pane could not be queried. + * A full-history capture bounded by `historyLimitLines` below this depth + * left older lines behind in the multiplexer, which the caller must report + * as recoverable. Absent when the pane could not be queried. */ capturedHistoryLines?: number; + /** + * Filled in by the implementation with whether the pane was in the alternate + * screen at capture time, when it can tell. tmux keeps no history for such a + * pane (a nested tmux client puts it there), so a full-history capture of it + * holds one screen plus whatever the pane held before. + */ + capturedAlternateOn?: boolean; } /** diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 97d015ba..d035bfb7 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -706,13 +706,18 @@ interface PaneCursorGeometry { * Optional and validated on its own, so a bad value never voids the caret. */ historyLines?: number; + /** Whether the pane is in the alternate screen (`#{alternate_on}`), when tmux reported it. */ + alternateOn?: boolean; } /** * Read the pane's cursor and size, or null when tmux cannot say. * - * Every field is validated together: a caller that gets a value back can place - * a caret with it, and one that gets null must not try. + * The four geometry fields are validated together: a caller that gets a value + * back can place a caret with it, and one that gets null must not try. The + * optional fifth and sixth fields, the history depth and the alternate-screen + * flag, are only informational, so a bad one is dropped rather than + * discarding a good caret. */ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null { let raw: string; @@ -722,7 +727,9 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null { console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr); return null; } - const [cursorX, cursorY, cols, rows, historyLines] = raw.split(/\s+/).map((value) => parseInt(value, 10)); + const [cursorX, cursorY, cols, rows, historyLines, alternateOn] = raw + .split(/\s+/) + .map((value) => parseInt(value, 10)); if ( !Number.isFinite(cursorX) || !Number.isFinite(cursorY) || @@ -737,6 +744,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null { } const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY }; if (Number.isFinite(historyLines) && historyLines >= 0) geometry.historyLines = historyLines; + if (alternateOn === 0 || alternateOn === 1) geometry.alternateOn = alternateOn === 1; return geometry; } @@ -3898,7 +3906,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // to keep when a move follows to put the caret back above them. const geometry = queryPaneCursor(() => execSync( - `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`, + `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } ) ); @@ -3912,8 +3920,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows }; // Same query, no extra tmux call: how much scrollback a full-history pull // could return. A pane in the alternate screen (fullscreen claude) holds - // none, and the partial-history notice must not promise it. + // none, and the partial-history notice must not promise it. A caller that + // bounded the capture with `historyLimitLines` also reads it to tell + // whether the bound cut anything. if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines; + // A pane in the alternate screen keeps no history (a nested tmux client + // holds it there), so its capture is one screen however deep it reads. + if (opts && geometry?.alternateOn !== undefined) opts.capturedAlternateOn = geometry.alternateOn; if (fullHistory) { // Without geometry there is no cursor move, so fall back to the old trim. diff --git a/src/web/public/app.js b/src/web/public/app.js index 99e174e8..901dc1b7 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3209,15 +3209,16 @@ class CodemanApp { this._terminalRefreshOwner = refreshOwner; try { // A shell can retain a multi-megabyte/100k-line tmux history. Automatic - // recovery stays bounded just like normal shell selection; only the - // explicit "Load full history" action is allowed to pay for a full replay. + // recovery stays bounded just like normal shell selection (tmux's rendered + // history, SHELL_LOAD_QUERY); only the explicit "Load full history" action + // is allowed to pay for a full replay. // TUI modes still recover the whole picture, with the downgrade guard for // repaint-mode panes whose tmux capture can be smaller than xterm's buffer. const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell'; let capture = await this._fetchTerminalCapture( useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` - : `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`, + : `/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`, { full: useFullHistory } ); let headersReceivedAt = capture.headersAt; @@ -3315,8 +3316,13 @@ class CodemanApp { try { // No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling, // so it needs the full-history budget. Defaulting to the tail budget - // gave the largest payload the smallest deadline. - const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true }); + // gave the largest payload the smallest deadline. A shell reloads tmux's + // rendered history instead (SHELL_LOAD_QUERY), never the byte recording. + const shellSession = this.sessions.get(data.id)?.mode === 'shell'; + const capture = await this._fetchTerminalCapture( + shellSession ? `/api/sessions/${data.id}/terminal?${SHELL_LOAD_QUERY}` : `/api/sessions/${data.id}/terminal`, + { full: !shellSession } + ); const headersReceivedAt = capture.headersAt; const termData = capture.json?.data ?? {}; @@ -8882,6 +8888,8 @@ class CodemanApp { source: payload.source ?? null, fullSize: payload.fullSize ?? 0, retainedBytes: payload.retainedBytes ?? 0, + // tmux holds lines beyond a `lines=` window, so fullSize undercounts. + historyLinesCut: !!payload.historyLinesCut, // Set once a full-history pull has been refused as a downgrade: the // browser holds more than the server can return, so there is no more. exhausted: !!payload.exhausted, @@ -9378,8 +9386,10 @@ class CodemanApp { // TUI sessions still get one canonical full replay per page (COD-47/#205). // A shell can retain hundreds of thousands of plain scrollback lines, so // automatically replaying all of them makes tab selection scale with the - // entire session. Load its bounded 1MB tail first; the existing truncation - // banner action fetches ?full=1 when the user explicitly asks for it. + // entire session. Load a bounded window of tmux's RENDERED history first + // (SHELL_LOAD_QUERY: never the raw byte recording, whose repaints replay + // wrong at another pane height); the existing truncation banner action + // fetches ?full=1 when the user explicitly asks for it. // // A TUI pane whose last capture reported NO tmux scrollback (fullscreen // claude: it lives in the alternate screen and keeps its transcript itself) @@ -9396,17 +9406,21 @@ class CodemanApp { // switch. // A remote (ssh) or Docker session's local pane hosts a nested tmux client in the // alternate screen, so it reports 0 while its byte tail is real scrollback. + const shellSession = session?.mode === 'shell'; const paneKeepsNoHistory = !session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0; - const useFullHistory = - session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId)); + const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId)); if (useFullHistory) this._fullHistoryLoaded.add(sessionId); const fetchStartedAt = performance.now(); const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`; let capture; try { capture = await this._fetchTerminalCapture( - useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : tailUrl, + useFullHistory + ? `/api/sessions/${sessionId}/terminal?full=1` + : shellSession + ? `/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}` + : tailUrl, { full: useFullHistory } ); } catch (err) { diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 242b8cad..5695f463 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -116,6 +116,21 @@ const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / bro const THROTTLE_DELAY_MS = 100; // General UI throttle delay const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch) +// A shell's automatic loads (tab select, drop recovery, a tile's load) read +// tmux's RENDERED history, never the raw byte recording: that recording holds +// full-screen repaints (an ssh'd tmux, less, a TUI) drawn for the pane size of +// the moment, and replayed into a terminal of another height each repaint +// scrolls its top row into scrollback, so one line shows up dozens of times. +// `lines=` bounds the synchronous capture-pane (~8 ms per 1000 lines measured, +// so ~0.1 s here); the server reports a cut as 'tail', and scroll-to-top pulls +// the older lines as before. `alt=recording`: a pane in the alternate screen (a +// nested tmux, as in remote SSH and Docker cases, but also vim or less while +// open) gains no tmux history, so the server loads it the old way, visible +// frame over the tailed byte recording. +// A grid tile appends its own `lines=` to SHELL_LOAD_BASE_QUERY. +const SHELL_LOAD_HISTORY_LINES = 10000; +const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`; +const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=${SHELL_LOAD_HISTORY_LINES}`; const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer @@ -1888,7 +1903,7 @@ function formatHistoryBytes(bytes) { * * @param {{truncated?: boolean, reason?: string|null, source?: string|null, * fullSize?: number, retainedBytes?: number, exhausted?: boolean, - * paneHistoryLines?: number|null}} state + * paneHistoryLines?: number|null, historyLinesCut?: boolean}} state * @returns {{visible: boolean, message: string, canLoadMore: boolean}} */ function computeHistoryTruncationNotice(state = {}) { @@ -1922,10 +1937,17 @@ function computeHistoryTruncationNotice(state = {}) { } // Name what a pull can actually return when the server said: the byte gap // counts repaints and redraw bloat, and overstates it many times over. + // Without that count, a `lines=`-bounded capture is cut by tmux before any + // bytes are counted, so its fullSize is the window itself and the remainder's + // size is unknown: say that more exists without inventing an amount. That + // holds even when `tail` also cut bytes off the window (`dropped`), since tmux + // still holds more beyond it (`historyLinesCut`, set by the server). + const remainder = + dropped > 0 && !state.historyLinesCut ? `${formatHistoryBytes(dropped)} more` : 'Earlier output'; const more = historyLines !== null ? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.` - : `${formatHistoryBytes(dropped)} more may still be retained.`; + : `${remainder} may still be retained.`; return { visible: true, message: `Showing the most recent ${shown} of this session. ${more}`, diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 5ac18139..1729a50e 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -48,7 +48,7 @@ * Android tablet, or a large foldable unfolded in landscape, reaches. * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js - * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) + * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE) * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick) * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js @@ -770,8 +770,15 @@ try { if (this._destroyed) return; 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 shell loads tmux's rendered history, bounded (SHELL_LOAD_QUERY, + // constants.js), never the raw byte recording; a grid tile bounds it + // by its own scrollback instead. + let query = shell ? SHELL_LOAD_QUERY : 'full=1'; + if (this.boundedLoad) { + query = shell + ? `${SHELL_LOAD_BASE_QUERY}${this._historyLinesQuery()}` + : `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`; + } this._loadAbort = controller; armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000); let payload; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9af58007..2b7f2921 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3088,10 +3088,12 @@ export function registerSessionRoutes( // Query params: // tail=<bytes> - Only return last N bytes (faster initial load) // full=1 - Explicitly request the entire tmux scrollback (COD-47) + // alt=recording - With full=1: if the pane is in the alternate screen, load + // the visible frame over the byte recording instead app.get('/api/sessions/:id/terminal', async (req, reply) => { const routeStartedAt = performance.now(); const { id } = req.params as { id: string }; - const query = req.query as { tail?: string; full?: string; lines?: string }; + const query = req.query as { tail?: string; full?: string; lines?: string; alt?: string }; const session = findSessionOrFail(ctx, id, req); // `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE @@ -3099,7 +3101,7 @@ export function registerSessionRoutes( // Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy // no-param callers — keep the fast visible-frame capture. const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; - const isFullReload = query.full === '1' || query.full === 'true'; + let isFullReload = query.full === '1' || query.full === 'true'; const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig(); // Prepend the live tmux pane buffer so tab-switch replay shows the current @@ -3117,17 +3119,32 @@ export function registerSessionRoutes( // for. See PaneCaptureOptions.capturedGeometry. // `lines` bounds only the history a FULL capture reads; the visible-frame // path reads no history and is untouched by it. - const captureOpts: PaneCaptureOptions = isFullReload + let captureOpts: PaneCaptureOptions = isFullReload ? { fullHistory: true, historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit), maxCaptureBytes: terminalBufferMaxBytes, } : {}; - const liveMuxBuffer = + const capture = (opts: PaneCaptureOptions): string | null => muxName && typeof ctx.mux.captureActivePaneBuffer === 'function' - ? ctx.mux.captureActivePaneBuffer(muxName, captureOpts) + ? ctx.mux.captureActivePaneBuffer(muxName, opts) : null; + let liveMuxBuffer = capture(captureOpts); + // tmux adds no history while a pane is in the alternate screen, and a + // nested tmux client (a remote SSH or Docker case, or `tmux` run by hand + // in a shell) holds its pane there for good, so the full capture above is + // one screen plus whatever came before. The byte recording still carries + // the scrollback, so a caller that opts in (a shell's automatic load, + // `alt=recording`) gets the old visible-frame load over it, tailed, rather + // than losing everything above the screen. `alternate_on` cannot tell a + // nested tmux from vim or less, so those take this path too while open: + // the load the shell had before tmux-rendered loads existed. + if (isFullReload && query.alt === 'recording' && captureOpts.capturedAlternateOn === true) { + isFullReload = false; + captureOpts = {}; + liveMuxBuffer = capture(captureOpts); + } const captureFinishedAt = performance.now(); const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0; const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer @@ -3168,6 +3185,31 @@ export function registerSessionRoutes( let truncationReason: 'capped' | 'tail' | null = null; let cleanBuffer: string; + // A `lines=` bound shallower than tmux's history left the older lines in + // tmux: an intentional partial read, so 'tail', and a later pull with a + // deeper (or no) bound recovers them. Without this a bounded window looks + // like the whole history to the client. The byte cap below still wins. + // Only a bound the CLIENT set and below the configured ceiling counts: + // without `lines=` the bound is the ceiling itself, and on tmux before 3.7 + // a live pane can hold more than a lowered ceiling, which no request can + // read, so calling that a recoverable cut would offer a button that can + // never return more. + // `historyLinesCut` tells the client this happened, apart from any byte + // cut below: `fullSize` counts only what tmux returned, so the bytes a + // later `tail` cut are not all that is left, and the client must not name + // them as the remainder. + const capturedHistoryLines = captureOpts.capturedHistoryLines; + const historyLinesCut = + isFullCapture && + captureOpts.historyLimitLines !== undefined && + captureOpts.historyLimitLines < tmuxHistoryLimit && + capturedHistoryLines !== undefined && + capturedHistoryLines > captureOpts.historyLimitLines; + if (historyLinesCut) { + truncated = true; + truncationReason = 'tail'; + } + // Cap the payload EARLY — before the regex normalization passes below run // over it. A full-history tmux capture can be tens of MB of scrollback; // normalizing all of it would stall the event loop only to discard most @@ -3256,6 +3298,9 @@ export function registerSessionRoutes( fullSize, truncated, truncationReason, + // The `lines=` bound left older lines in tmux (see above): the remainder + // is more than `fullSize - retainedBytes`, by an amount unknown here. + historyLinesCut, // `retainedBytes` is what this response actually carries; `fullSize` is // what existed before the cut. The gap is what the indicator reports. retainedBytes: cleanBuffer.length, diff --git a/test/history-truncation-notice.test.ts b/test/history-truncation-notice.test.ts index ae478b3c..25245468 100644 --- a/test/history-truncation-notice.test.ts +++ b/test/history-truncation-notice.test.ts @@ -80,6 +80,40 @@ describe('computeHistoryTruncationNotice (issue #258)', () => { expect(notice.message).toContain('more may still be retained'); }); + it('names no amount when tmux cut the window by lines before any bytes were counted', () => { + // A shell's `lines=`-bounded load: the server knows tmux holds more, but the + // window IS the whole payload, so fullSize equals what was kept. + const notice = computeHistoryTruncationNotice({ + truncated: true, + reason: 'tail', + source: 'mux-full-history', + fullSize: 800 * 1024, + retainedBytes: 800 * 1024, + }); + expect(notice.canLoadMore).toBe(true); + expect(notice.message).toContain('Earlier output may still be retained'); + expect(notice.message).not.toMatch(/\d+ (B|KB) more/); + }); + + it('names no amount when tail also cut bytes off a window tmux cut by lines', () => { + // The window was over 1 MiB: `dropped` counts only the bytes tail cut off + // it, while tmux still holds more beyond the window, so that number would + // understate what is left. + const state = { + truncated: true, + reason: 'tail', + source: 'mux-full-history', + fullSize: 3 * 1024 * 1024, + retainedBytes: 1024 * 1024, + }; + const windowOnly = computeHistoryTruncationNotice({ ...state, historyLinesCut: true }); + expect(windowOnly.canLoadMore).toBe(true); + expect(windowOnly.message).toContain('Earlier output may still be retained'); + expect(windowOnly.message).not.toMatch(/\d+(\.\d+)? (B|KB|MB) more/); + // Without a lines cut the byte remainder IS all that is left, and is named. + expect(computeHistoryTruncationNotice(state).message).toContain('2.0 MB more may still be retained'); + }); + it('promises nothing more once the FULL capture itself hit the ceiling', () => { // This is the case the old boolean could not express: a full-history pull // that was still capped means tmux has already given everything it has. @@ -191,10 +225,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => { // A shell never takes the full capture on a tab switch. A TUI takes it on its // first select per page, and on every select while its pane keeps no tmux // scrollback (behaviour pinned in fullscreen-tab-switch-capture.browser.test.ts). + expect(app).toContain("const shellSession = session?.mode === 'shell';"); expect(app).toContain( - "session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId))" + 'const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));' ); expect(app).toContain('!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;'); + // The shell's bounded load is tmux's rendered history, never the byte recording. + expect(app).toContain('`/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`'); expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'"); expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`'); // Every terminal capture now goes through _fetchTerminalCapture, which adds diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index a39cbe11..5ce9c841 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1201,6 +1201,137 @@ describe('session-routes', () => { expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, {}); }); + // A shell's tab-select load reads a `lines=` window SMALLER than xterm's + // scrollback, so a cut there is real: the client must hear that tmux holds + // more, or it treats the window as the whole history and scroll-to-top + // stops offering the rest. tmux reports its history depth on the capture. + it('reports a `lines=` cut of tmux history as a recoverable tail truncation', async () => { + harness.ctx._session.mode = 'shell'; + const lineCountFor = async (lines: number, historySize: number | undefined) => { + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn( + (_name: string, opts?: { capturedHistoryLines?: number }) => { + if (opts && historySize !== undefined) opts.capturedHistoryLines = historySize; + return 'captured rows\r\nlast row'; + } + ); + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${1024 * 1024}&lines=${lines}`, + }); + const data = JSON.parse(res.body).data; + return { truncated: data.truncated, reason: data.truncationReason, linesCut: data.historyLinesCut }; + }; + + expect(await lineCountFor(10000, 25000)).toEqual({ truncated: true, reason: 'tail', linesCut: true }); + expect(await lineCountFor(10000, 10000)).toEqual({ truncated: false, reason: null, linesCut: false }); + expect(await lineCountFor(10000, 400)).toEqual({ truncated: false, reason: null, linesCut: false }); + // No depth reported (an older tmux, a failed query): nothing is claimed. + expect(await lineCountFor(10000, undefined)).toEqual({ truncated: false, reason: null, linesCut: false }); + }); + + // Without `lines=` the bound is the configured ceiling. On tmux before 3.7 a + // live pane keeps history deeper than a lowered ceiling, which no request + // can read, so reporting it as a recoverable cut would offer Load full + // history on a pull that can never return more. + it('reports no cut for a full capture without `lines=`, nor for `lines=` at the ceiling', async () => { + harness.ctx._session.mode = 'shell'; + const limit = (await harness.ctx.getTerminalHistoryConfig()).tmuxHistoryLimit; + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn( + (_name: string, opts?: { capturedHistoryLines?: number }) => { + if (opts) opts.capturedHistoryLines = limit * 5; + return 'captured rows\r\nlast row'; + } + ); + for (const query of ['full=1', `full=1&tail=${1024 * 1024}`, `full=1&lines=${limit}`]) { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}`, + }); + const data = JSON.parse(res.body).data; + expect({ truncated: data.truncated, reason: data.truncationReason }, query).toEqual({ + truncated: false, + reason: null, + }); + } + }); + + // A nested tmux (remote SSH and Docker cases, or `tmux` run by hand in a + // shell) holds the pane in the alternate screen, where tmux keeps no + // history: the full capture is one screen. `alt=recording` (a shell's + // automatic load) then gets the visible frame over the byte recording, + // which still carries the scrollback. + describe('alt=recording on a pane in the alternate screen', () => { + const install = (alternateOn: boolean | undefined) => { + const spy = vi.fn((_name: string, opts?: { fullHistory?: boolean; capturedAlternateOn?: boolean }) => { + if (opts?.fullHistory) { + if (alternateOn !== undefined) opts.capturedAlternateOn = alternateOn; + return 'one screen of full capture'; + } + return 'visible frame'; + }); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = spy; + return spy; + }; + const load = async (query: string) => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}`, + }); + return JSON.parse(res.body).data; + }; + + beforeEach(() => { + harness.ctx._session.mode = 'shell'; + harness.ctx._session.terminalBuffer = 'recorded scrollback\r\n'; + }); + + it('falls back to the visible frame over the tailed byte recording', async () => { + const spy = install(true); + const data = await load(`full=1&tail=${1024 * 1024}&alt=recording&lines=10000`); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1][1]).toEqual({}); + expect(data.source).toBe('mux-visible'); + expect(data.terminalBuffer).toContain('recorded scrollback'); + expect(data.terminalBuffer).toContain('visible frame'); + expect(data.terminalBuffer).not.toContain('one screen of full capture'); + }); + + it('keeps the full capture when the pane is in the normal screen or tmux did not say', async () => { + for (const alternateOn of [false, undefined]) { + const spy = install(alternateOn); + const data = await load(`full=1&tail=${1024 * 1024}&alt=recording&lines=10000`); + expect(spy, String(alternateOn)).toHaveBeenCalledTimes(1); + expect(data.source).toBe('mux-full-history'); + expect(data.terminalBuffer).toContain('one screen of full capture'); + } + }); + + it('keeps the full capture without the opt-in (agents, Load full history, scroll pulls)', async () => { + const spy = install(true); + const data = await load(`full=1&tail=${1024 * 1024}&lines=10000`); + expect(spy).toHaveBeenCalledTimes(1); + expect(data.source).toBe('mux-full-history'); + }); + }); + + it('lets the byte cap outrank a `lines=` cut: the oldest bytes of the window are gone for good', async () => { + harness.ctx._session.mode = 'shell'; + const { terminalBufferMaxBytes } = await harness.ctx.getTerminalHistoryConfig(); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn( + (_name: string, opts?: { capturedHistoryLines?: number }) => { + if (opts) opts.capturedHistoryLines = 25000; + return `${'x'.repeat(100)}\r\n`.repeat(Math.ceil(terminalBufferMaxBytes / 102) + 10); + } + ); + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&lines=10000`, + }); + const data = JSON.parse(res.body).data; + expect(data.truncated).toBe(true); + expect(data.truncationReason).toBe('capped'); + }); + it('a capture bounded by `lines=` is still a full capture: rows kept, cursor restore last', async () => { // Only how much history tmux reads changes. The row-preserving skips key // on isFullCapture, and the relative cursor move must still end it. diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts index e5120ca6..dc29213f 100644 --- a/test/terminal-flush-budget.test.ts +++ b/test/terminal-flush-budget.test.ts @@ -159,13 +159,38 @@ describe('terminal flush budget', () => { const first = app._onSessionNeedsRefresh({ id: 'session-1' }); const duplicate = app._onSessionNeedsRefresh({ id: 'session-1' }); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledWith('/api/sessions/session-1/terminal?tail=1048576'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/sessions/session-1/terminal?full=1&tail=1048576&alt=recording&lines=10000' + ); releaseFetch(); await Promise.all([first, duplicate]); expect(app._terminalRefreshOwner).toBe(null); }); + it("reloads a cleared shell from tmux's rendered history and an agent from the byte recording", async () => { + const { CodemanApp, fetchMock } = loadAppHarness(); + const app = Object.create(CodemanApp.prototype) as any; + app.sessions = new Map([ + ['shell-1', { mode: 'shell' }], + ['agent-1', { mode: 'claude' }], + ]); + app._isLoadingBuffer = false; + app._resetTerminalForReplay = vi.fn(); + app.sendResize = vi.fn(); + fetchMock.mockResolvedValue({ json: async () => ({ data: { terminalBuffer: '' } }) }); + + app.activeSessionId = 'shell-1'; + await app._onSessionClearTerminal({ id: 'shell-1' }); + app.activeSessionId = 'agent-1'; + await app._onSessionClearTerminal({ id: 'agent-1' }); + + expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ + '/api/sessions/shell-1/terminal?full=1&tail=1048576&alt=recording&lines=10000', + '/api/sessions/agent-1/terminal', + ]); + }); + it('drains a large final batch without waiting for unrelated terminal output', () => { const { app, writes } = loadTerminalUiHarness('codex'); const scheduled: Array<() => void> = []; diff --git a/test/terminal-tile-unit.test.ts b/test/terminal-tile-unit.test.ts index e24f6680..eee0b059 100644 --- a/test/terminal-tile-unit.test.ts +++ b/test/terminal-tile-unit.test.ts @@ -24,6 +24,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const TERMINAL_CHUNK_SIZE = 32 * 1024; const TERMINAL_TAIL_SIZE = 1024 * 1024; +// constants.js: a shell loads tmux's rendered history, bounded, never the raw byte recording. +const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`; +const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=10000`; /** The pane's `performance.now()`, so frame arrival vs. capture time is set by hand, not raced. */ let clock = 0; @@ -108,6 +111,8 @@ function loadTerminalTile() { // The constants.js globals the module reads at call time. TERMINAL_CHUNK_SIZE, TERMINAL_TAIL_SIZE, + SHELL_LOAD_BASE_QUERY, + SHELL_LOAD_QUERY, }); // The module's tail patches CodemanApp.prototype; nothing on it runs here. vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context); @@ -266,14 +271,14 @@ describe('TerminalTile server-refresh single-flight', () => { expect(pane._bufferLoading).toBe(false); }); - it('a shell pane asks for the bounded tail, matching connect()', async () => { + it("a shell pane asks for a bounded window of tmux's rendered history, matching connect()", async () => { const pane = makePane('shell'); fetchMock.mockResolvedValueOnce(jsonResponse('tail')); pane._refreshBuffer(); await settle(); - expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`, expect.anything()); + expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?${SHELL_LOAD_QUERY}`, expect.anything()); }); it('refreshes arriving mid-fetch neither reset nor fetch again, and run ONCE after the replay lands', async () => { @@ -865,10 +870,7 @@ describe('TerminalTile scroll-to-top history pull', () => { // 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(fetchMock).toHaveBeenLastCalledWith(`/api/sessions/s1/terminal?${SHELL_LOAD_QUERY}`, expect.anything()); expect(order.at(-1)).toBe('write:after startup'); expect(pane._liveQueue).toBeNull(); expect(pane._bufferLoading).toBe(false); diff --git a/test/tile-grid-load-queue.test.ts b/test/tile-grid-load-queue.test.ts index 53b60143..8ab74599 100644 --- a/test/tile-grid-load-queue.test.ts +++ b/test/tile-grid-load-queue.test.ts @@ -216,14 +216,14 @@ describe('initial loads', () => { expect(states.filter(([id]) => id === 'b').map(([, s]) => s)).toEqual(['queued', 'running', 'idle']); }); - it('loads a bounded window: full=1&tail= for a TUI, tail= for a shell', async () => { + it("loads a bounded window of tmux's rendered history, bounded by the tile's own lines for a TUI and a shell", async () => { const { tiles } = makeGrid(['tui', 'sh'], { modes: { sh: 'shell' } }); const connecting = tiles.map((t) => t.connect()); await drain(); await Promise.all(connecting); expect(captures.map((c) => c.url)).toEqual([ `/api/sessions/tui/terminal?full=1&tail=${TAIL}${LINES}`, - `/api/sessions/sh/terminal?tail=${TAIL}`, + `/api/sessions/sh/terminal?full=1&tail=${TAIL}&alt=recording${LINES}`, ]); }); diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index ab7be919..b93601f0 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -160,7 +160,9 @@ describe('the capture reports how much scrollback tmux holds (#{history_size})', it('reads it from the one cursor query the capture already makes', () => { // No extra tmux call: every capture is a synchronous exec on the server. - expect(methodBody).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'"); + expect(methodBody).toContain( + "'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'" + ); expect(methodBody.match(/display-message/g)).toHaveLength(1); expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines'); }); @@ -213,3 +215,45 @@ describe('why a capture has to report its height', () => { expect(addressed.filter((row) => row > 30)).toHaveLength(20); }); }); + +describe('queryPaneCursor history depth', () => { + it('reads tmux history_size as an optional fifth field', () => { + expect(queryPaneCursor(() => '4 22 133 24 25000\n')).toEqual({ + cols: 133, + rows: 24, + cursorX: 4, + cursorY: 22, + historyLines: 25000, + }); + }); + + it('keeps the geometry and omits the depth when the fifth field is missing or invalid', () => { + for (const raw of ['4 22 133 24', '4 22 133 24 abc', '4 22 133 24 -1']) { + const geometry = queryPaneCursor(() => raw); + expect(geometry, raw).toEqual({ cols: 133, rows: 24, cursorX: 4, cursorY: 22 }); + } + }); + + it('reads tmux alternate_on as an optional sixth field', () => { + expect(queryPaneCursor(() => '4 22 133 24 29 1\n')).toEqual({ + cols: 133, + rows: 24, + cursorX: 4, + cursorY: 22, + historyLines: 29, + alternateOn: true, + }); + expect(queryPaneCursor(() => '4 22 133 24 29 0')?.alternateOn).toBe(false); + for (const raw of ['4 22 133 24 29', '4 22 133 24 29 2', '4 22 133 24 29 x']) { + expect( + queryPaneCursor(() => raw), + raw + ).not.toHaveProperty('alternateOn'); + } + }); + + it('asks tmux for the history depth and alternate screen in the same display-message as the cursor', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); + expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'"); + }); +});