fix(terminal): keep the byte-recording load for a shell in the alternate screen, report a lines cut only below the ceiling

Review follow-ups for loading a shell from tmux's rendered history.

tmux adds no history while a pane is in the alternate screen, and a
nested tmux client (remote SSH and Docker cases, or tmux run by hand)
holds its pane there, so a full capture of it is one screen. The
display-message query that reads #{history_size} now also reads
#{alternate_on} (PaneCaptureOptions.capturedAlternateOn), and a request
carrying `alt=recording` (only SHELL_LOAD_QUERY sends it) is answered
the old way on such a pane: the visible frame over the tailed byte
recording. Agents, scroll pulls and Load full history are unchanged.
alternate_on cannot tell a nested tmux from vim or less, so a local
shell with one of those open takes the same fallback while it is open.

A full capture without `lines=` was reported as a 'tail' cut whenever
the pane held more than the configured ceiling (possible on tmux before
3.7), offering a Load full history that could never return more. The
cut is now reported only for a client bound below the ceiling.

The response carries `historyLinesCut` when that bound left lines in
tmux, and the banner then says earlier output may be retained instead
of naming the bytes a later tail cut, which undercount what tmux holds.
A bounded grid tile's shell load sends its own `lines=` (via
SHELL_LOAD_BASE_QUERY). CLAUDE.md, docs/architecture-invariants.md and
docs/api-reference.md describe the shell load, the cut report and the
fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
timkjrandClaude Opus 5.5 committed 2026-10-10 23:29:19 -05:00
1 parent b620e42ffa
commit 063e6fc076
15 files changed
+226 -41

No files matched your search

+1 -1
View File
@@ -276,7 +276,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)
+5 -3
View File
@@ -543,15 +543,17 @@ 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. The capture runs synchronous tmux calls on the server; the
`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. 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=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
| `lines=<n>` | With `full=1` only: read at most `<n>` 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 the bound 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. |
| `lines=<n>` | With `full=1` only: read at most `<n>` 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)
+2 -2
View File
@@ -209,7 +209,7 @@ Further detail: the `<prefix>: <title>` 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 Line truncated
**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.capturedHistorySize`), 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 sLine truncated
**A capture reports the geometry it was taken at** (#435): a visible frame repaints each row at an absolute position, counting up to the pane's height and out to the pane's width, so a terminal smaller than that pane damages it two ways at once. Too short and every address past the browser's own height clamps onto the last line, overwriting the rows underneath (measured: against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew the survivors twice). Too narrow and each row is painted out to the pane's width, so the browser wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Nothing in the response used to say what geometry the frame was built for, so the client could not see either case. `PaneCaptureOptions.capturedGeometry` carries it out, and the terminal response publishes it as `captureCols`/`captureRows`. ⚠️ **Both fields are ABSENT unless a frame was really positioned**, and every consumer must test `Number.isFinite` rather than truthiness: `mux-visible` is necessary but not sufficient, because when the `display-message` cursor query fails `capturePaneBuffer` skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body `mux-visible`. A body that positioned nothing has no geometry to describe and nothing to repair, so a comparison that fires there buys a second capture, a reset plus chunked rewrite, a dropped and reopened WebSocket and a discarded xterm snapshot for no gain. ⚠️ **The comparison runs on `mux-visible` ONLY.** A full-history body is linear scrollback closed by a RELATIVE cursor move, which is relative precisely so the browser's row count need not match the pane's, and a byte-history body carries no row alignment at all, so a size mismatch damages neither and a replay repairs neither. That gate matters because the first select of every non-shell session per page takes the full-history path, where an ungated comparison would fire most often on the one response it cannot help, at the price of a second whole-scrollback capture. ⚠️ **The replay is capped at one attempt and latches per session when it cannot converge.** `resizeRetry` stops two competing fits trading replays forever; a pane already drawing at the size just requested is left alone, because a retry would capture the identical frame; and a pass that still does not converge joins `_geometryRetryUseless`, so the case `Session.resize` declines outright (a small viewport while a desktop viewport's size claim is live, where the retry re-sends the same declined resize and captures the same pane) costs one attempt per session per page load instead of one per select. ⚠️ **The clamp used to manufacture that equality, and no longer can** (#464). This paragraph previously explained it as the signature of a clamp: `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did not, so a terminal under 40 columns or 10 rows reported a pane permanently bigger than itself and would replay on every tab switch. That divergence is fixed at the source — `syncTerminalGeometry()` (terminal-ui.js) fits, floors and APPLIES in one step, so the browser terminal IS the size it reports. The only remaining reason the two can differ is a resize the server declined, which the server now reports back (`Session.ptyGeometry`, the `{"t":"zc"}` frame and the resize response) for the client to adopt by COLUMNS. The equality guard stays, for the plain case of a pane already at the requested size. ⚠️ **A retry pass must not re-arm `_fullHistoryLoaded`**: it did not consume the full-history pull, and re-arming it would spend a whole-scrollback capture on the next select. That branch is currently unreachable by construction, since reaching it needs `source === 'mux-visible'` while a `full=1` pass is answered `mux-full-history` or `history`; a static test over the source is the habit this repo uses for an invariant nothing can execute. Tests: `test/capture-geometry-retry.browser.test.ts` (eight cases, five of which fail against the merge base), `test/tmux-capture-full-history.test.ts`, `test/routes/session-routes.test.ts`.
@@ -823,7 +823,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, `SHELL_LOAD_QUERY` for a shell, as every shell load is; 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.
+7
View File
@@ -211,6 +211,13 @@ export interface PaneCaptureOptions {
* behind in the multiplexer, which the caller must report as recoverable.
*/
capturedHistorySize?: 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;
}
/**
+11 -4
View File
@@ -702,6 +702,8 @@ interface PaneCursorGeometry {
cursorY: number;
/** Lines of scrollback above the screen (`#{history_size}`), when tmux reported it. */
historySize?: number;
/** Whether the pane is in the alternate screen (`#{alternate_on}`), when tmux reported it. */
alternateOn?: boolean;
}
/**
@@ -709,8 +711,9 @@ interface PaneCursorGeometry {
*
* 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 field, the history depth, is only informational, so a bad one
* is dropped rather than discarding a good caret.
* 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;
@@ -720,7 +723,7 @@ 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, historySize] = raw.split(/\s+/).map((value) => parseInt(value, 10));
const [cursorX, cursorY, cols, rows, historySize, alternateOn] = raw.split(/\s+/).map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
@@ -735,6 +738,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
}
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
if (Number.isFinite(historySize) && historySize >= 0) geometry.historySize = historySize;
if (alternateOn === 0 || alternateOn === 1) geometry.alternateOn = alternateOn === 1;
return geometry;
}
@@ -3896,7 +3900,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 }
)
);
@@ -3911,6 +3915,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// How deep tmux's history really is, so a caller that bounded the capture
// with `historyLimitLines` can tell whether the bound cut anything.
if (opts && geometry?.historySize !== undefined) opts.capturedHistorySize = geometry.historySize;
// 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.
+2
View File
@@ -8880,6 +8880,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,
+14 -5
View File
@@ -123,9 +123,14 @@ const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more s
// 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.
// 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_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&lines=${SHELL_LOAD_HISTORY_LINES}`;
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
@@ -1885,7 +1890,8 @@ function formatHistoryBytes(bytes) {
* - exhausted: a full pull was refused as a downgrade, so this is all there is
*
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
* fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
* historyLinesCut?: boolean}} state
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
*/
function computeHistoryTruncationNotice(state = {}) {
@@ -1914,8 +1920,11 @@ function computeHistoryTruncationNotice(state = {}) {
}
// 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.
const remainder = dropped > 0 ? `${formatHistoryBytes(dropped)} more` : 'Earlier output';
// 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';
return {
visible: true,
message: `Showing the most recent ${shown} of this session. ${remainder} may still be retained.`,
+7 -2
View File
@@ -771,9 +771,14 @@
if (this._destroyed) return;
const shell = this.sessionMode === 'shell';
// A shell loads tmux's rendered history, bounded (SHELL_LOAD_QUERY,
// constants.js), never the raw byte recording.
// 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 && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
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;
+38 -8
View File
@@ -3080,10 +3080,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
@@ -3091,7 +3093,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
@@ -3109,17 +3111,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
@@ -3164,13 +3181,23 @@ export function registerSessionRoutes(
// 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 capturedHistorySize = captureOpts.capturedHistorySize;
if (
const historyLinesCut =
isFullCapture &&
captureOpts.historyLimitLines !== undefined &&
captureOpts.historyLimitLines < tmuxHistoryLimit &&
capturedHistorySize !== undefined &&
capturedHistorySize > captureOpts.historyLimitLines
) {
capturedHistorySize > captureOpts.historyLimitLines;
if (historyLinesCut) {
truncated = true;
truncationReason = 'tail';
}
@@ -3263,6 +3290,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,
+19
View File
@@ -90,6 +90,25 @@ describe('computeHistoryTruncationNotice (issue #258)', () => {
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.
+90 -5
View File
@@ -1172,14 +1172,99 @@ describe('session-routes', () => {
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 };
return { truncated: data.truncated, reason: data.truncationReason, linesCut: data.historyLinesCut };
};
expect(await lineCountFor(10000, 25000)).toEqual({ truncated: true, reason: 'tail' });
expect(await lineCountFor(10000, 10000)).toEqual({ truncated: false, reason: null });
expect(await lineCountFor(10000, 400)).toEqual({ truncated: false, reason: null });
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 });
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?: { capturedHistorySize?: number }) => {
if (opts) opts.capturedHistorySize = 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 () => {
+4 -2
View File
@@ -159,7 +159,9 @@ 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?full=1&tail=1048576&lines=10000');
expect(fetchMock).toHaveBeenCalledWith(
'/api/sessions/session-1/terminal?full=1&tail=1048576&alt=recording&lines=10000'
);
releaseFetch();
await Promise.all([first, duplicate]);
@@ -184,7 +186,7 @@ describe('terminal flush budget', () => {
await app._onSessionClearTerminal({ id: 'agent-1' });
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
'/api/sessions/shell-1/terminal?full=1&tail=1048576&lines=10000',
'/api/sessions/shell-1/terminal?full=1&tail=1048576&alt=recording&lines=10000',
'/api/sessions/agent-1/terminal',
]);
});
+4 -5
View File
@@ -25,7 +25,8 @@ 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_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&lines=10000`;
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;
@@ -110,6 +111,7 @@ 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.
@@ -276,10 +278,7 @@ describe('TerminalTile server-refresh single-flight', () => {
pane._refreshBuffer();
await settle();
expect(fetchMock).toHaveBeenCalledWith(
`/api/sessions/s1/terminal?full=1&tail=${1024 * 1024}&lines=10000`,
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 () => {
+2 -2
View File
@@ -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 of tmux's rendered history: the tile's lines for a TUI, the shell bound 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?full=1&tail=${TAIL}&lines=10000`,
`/api/sessions/sh/terminal?full=1&tail=${TAIL}&alt=recording${LINES}`,
]);
});
+20 -2
View File
@@ -190,8 +190,26 @@ describe('queryPaneCursor history depth', () => {
}
});
it('asks tmux for the history depth in the same display-message as the cursor', () => {
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,
historySize: 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}'");
expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'");
});
});