diff --git a/.changeset/history-notice-lazy.md b/.changeset/history-notice-lazy.md new file mode 100644 index 00000000..6776036a --- /dev/null +++ b/.changeset/history-notice-lazy.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +The "Showing the most recent 1.0 MB of this session" notice no longer appears on every tab switch. It shows up only when you scroll to the top of a terminal, leaves when you scroll back down, and stays closed for that tab once you dismiss it. Sessions with nothing more to load (fullscreen Claude, whose history lives in Claude itself) never show it, and when there is more, it states the scrollback line count instead of an inflated byte figure. `GET /api/v1/sessions/:id/terminal` reports the new `paneHistoryLines` field. diff --git a/docs/api-reference.md b/docs/api-reference.md index 47c22e0b..74cdd7e3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -457,8 +457,13 @@ 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 -`Server-Timing` header reports `capture`, `prepare` and `total`. +geometry was read. `paneHistoryLines` (present whenever the body is a pane capture) +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 | |---|---| diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 003aa171..5f32bf76 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -211,6 +211,8 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **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`. +**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`. + **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`. ⚠️ **A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery must verify itself** (`_scheduleDroppedOutputRecovery`, app.js): a hole in a TUI byte stream is a desynced cursor, which is muffled text (#464). It was a fire-and-forget 2s timer that nulled its own handle and then called `_onSessionNeedsRefresh()` — whose early returns (a buffer load in flight, a refresh already owning the session) are MOST likely to be true during exactly the burst that caused the drop, so the recovery was lost silently and the bytes were never replayed. `_onSessionNeedsRefresh` now returns whether it actually repainted, and the scheduler re-arms while it has not, bounded by `DROP_RECOVERY_MAX_ATTEMPTS` because the early returns it retries past are transient contention. A refresh that died at the capture fetch DEADLINE (it returns `'deadline'`) is a stalled link, not contention, and is NOT retried: each retry would be another `?full=1` capture waiting out a deadline of up to two minutes. The same debounce still collapses a burst into one attempt, and the `TERMINAL DROP` crash-trail line sits behind it, since one line per dropped frame evicted the whole 50-entry trail in under a second. Tests: `test/dropped-output-recovery.test.ts`, whose retry case and no-retry case only pin the fix as a pair. diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 1d7a3023..287cf2ce 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -212,7 +212,10 @@ Worth knowing: - **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open. Shell sessions open from a bounded recent tail so a large transcript cannot stall tab switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux - history; press **Load full history** to pull the rest explicitly. Automatic output + history; press **Load full history** to pull the rest explicitly. That notice only + appears once you scroll to the top, leaves when you scroll back down, and stays away + for that tab once you close it. Sessions whose CLI keeps its own history (fullscreen + Claude) never show it, since there is nothing more to load. Automatic output recovery stays within the bounded browser buffer. - **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 56214633..4baceac8 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -204,6 +204,15 @@ export interface PaneCaptureOptions { * rendering it needs the real height to know the frame fits. */ capturedGeometry?: { cols: number; rows: number }; + /** + * Filled in by the implementation with the number of rows the pane holds in + * scrollback ABOVE the visible frame (tmux `#{history_size}`), read in the + * 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. + */ + capturedHistoryLines?: number; } /** diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d7974a75..97d015ba 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -700,6 +700,12 @@ interface PaneCursorGeometry { rows: number; cursorX: number; cursorY: number; + /** + * `#{history_size}`: rows tmux holds ABOVE the visible frame, i.e. what a + * full-history capture can add. Absent when the query did not return it. + * Optional and validated on its own, so a bad value never voids the caret. + */ + historyLines?: number; } /** @@ -716,7 +722,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] = raw.split(/\s+/).map((value) => parseInt(value, 10)); + const [cursorX, cursorY, cols, rows, historyLines] = raw.split(/\s+/).map((value) => parseInt(value, 10)); if ( !Number.isFinite(cursorX) || !Number.isFinite(cursorY) || @@ -729,7 +735,9 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null { ) { return null; } - return { cols, rows, cursorX, cursorY }; + const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY }; + if (Number.isFinite(historyLines) && historyLines >= 0) geometry.historyLines = historyLines; + return geometry; } /** SGR attributes, which is all `capture-pane -e` emits. */ @@ -3890,7 +3898,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}'`, + `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } ) ); @@ -3902,6 +3910,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // geometry is reported there for diagnosis rather than for repair. Only // the caller can see both sizes, so hand it this one. 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. + if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines; 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 b03bc49d..da3ef1a8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -8689,6 +8689,9 @@ class CodemanApp { // 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, + // tmux scrollback above the frame (null = not reported). 0 is a pane with + // nothing a pull could add, which hides the notice outright. + paneHistoryLines: payload.paneHistoryLines ?? null, }); if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner(); } @@ -8696,9 +8699,27 @@ class CodemanApp { /** Drop banner state for a session that is going away. */ _clearHistoryTruncation(sessionId) { this._historyTruncation?.delete(sessionId); + this._historyNoticeDismissed?.delete(sessionId); if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner(); } + /** + * Bring the partial-history notice up for `sessionId`, or retire it (null). + * + * The notice is LAZY: a tail replay is truncated on nearly every tab switch, + * and a bar over the top rows on every switch described history the user had + * not reached for. It now waits for the scroll gesture that reaches the top of + * the browser's buffer (the moment the missing part matters, and the same + * gesture that already re-pulls history), and goes away again once the user + * scrolls back down to live output. A tab switch retires it (selectSession). + */ + _setHistoryNoticeRevealed(sessionId) { + const next = sessionId || null; + if ((this._historyNoticeRevealedFor ?? null) === next) return; + this._historyNoticeRevealedFor = next; + this._renderHistoryTruncationBanner(); + } + /** * Paint the partial-history banner for the active session. * @@ -8708,13 +8729,22 @@ class CodemanApp { * - recoverable → offer to load the rest * - exhausted → say so plainly, offer nothing * - at the limit → the full capture ITSELF hit the byte ceiling + * + * Shown only while revealed (`_setHistoryNoticeRevealed`: the user scrolled + * to the top of this tab's buffer) and never again for a session whose notice + * the user dismissed on this page. */ _renderHistoryTruncationBanner() { const bar = document.getElementById('historyTruncationBar'); if (!bar) return; - const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null; + const sessionId = this.activeSessionId; + const state = sessionId ? this._historyTruncation?.get(sessionId) : null; const notice = computeHistoryTruncationNotice(state || {}); - if (!notice.visible) { + if ( + !notice.visible || + this._historyNoticeRevealedFor !== sessionId || + this._historyNoticeDismissed?.has(sessionId) + ) { bar.hidden = true; return; } @@ -8747,6 +8777,9 @@ class CodemanApp { dismiss.setAttribute('aria-label', 'Dismiss history notice'); dismiss.textContent = '×'; dismiss.onclick = () => { + // Sticky for this session until the page reloads: a dismissed notice used + // to come straight back on the next tab switch. + (this._historyNoticeDismissed ||= new Set()).add(sessionId); bar.hidden = true; }; bar.appendChild(dismiss); @@ -8860,7 +8893,9 @@ class CodemanApp { this._activateFileBrowserSession?.(sessionId); // Repaint the partial-history banner for the tab being switched TO. The // replay paths refresh it when their fetch lands; without this the previous - // session's notice stays on screen until then (#258). + // session's notice stays on screen until then (#258). The switch lands at + // live output, so the notice waits for a scroll to the top again. + this._historyNoticeRevealedFor = null; this._renderHistoryTruncationBanner(); try { localStorage.setItem('codeman-active-session', sessionId); } catch {} // Narrow SSE filter to the active session — server stops streaming diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 30cd2649..272475ca 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1744,7 +1744,10 @@ function escapeHtml(text) { function formatHistoryBytes(bytes) { const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0; if (n < 1024) return 'less than 1 KB'; - if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; + // Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is + // just under 1 MiB and used to print as "1024 KB". + const kb = Math.round(n / 1024); + if (kb < 1024) return `${kb} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } @@ -1758,12 +1761,27 @@ function formatHistoryBytes(bytes) { * - atCeiling: the FULL capture itself hit the byte ceiling * - exhausted: a full pull was refused as a downgrade, so this is all there is * + * ⚠️ `truncated` measures the server's BYTE stream, not history a pull can + * return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the + * pane keeps no scrollback at all (a fullscreen CLI in the alternate screen, + * whose transcript lives in the CLI and is scrolled there), so the bytes a tail + * cut dropped are old repaint frames that `full=1` cannot bring back. Measured + * on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame, + * and the button only ever ended in the downgrade refusal. Nothing to offer, + * nothing to say. Absent means unknown and keeps the byte-based behaviour. + * * @param {{truncated?: boolean, reason?: string|null, source?: string|null, - * fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state + * fullSize?: number, retainedBytes?: number, exhausted?: boolean, + * paneHistoryLines?: number|null}} state * @returns {{visible: boolean, message: string, canLoadMore: boolean}} */ function computeHistoryTruncationNotice(state = {}) { if (!state.truncated) return { visible: false, message: '', canLoadMore: false }; + const historyLines = + typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines) + ? Math.max(0, state.paneHistoryLines) + : null; + if (historyLines === 0) return { visible: false, message: '', canLoadMore: false }; const retained = Math.max(0, state.retainedBytes || 0); const dropped = Math.max(0, (state.fullSize || 0) - retained); @@ -1786,9 +1804,15 @@ function computeHistoryTruncationNotice(state = {}) { canLoadMore: false, }; } + // 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. + 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.`; return { visible: true, - message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`, + message: `Showing the most recent ${shown} of this session. ${more}`, canLoadMore: true, }; } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d95960b2..b3bb24ed 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -3627,10 +3627,24 @@ Object.assign(CodemanApp.prototype, { * the unbounded path. Must be called AFTER scrollLines(), since the check is on * the resulting position, and it is deliberately not folded into * _noteTerminalUserScroll for exactly that reason. + * + * It is also what brings up the partial-history notice, and what retires it + * once a downward scroll is back at live output (_setHistoryNoticeRevealed). + * The reveal waits for the pull this gesture started, so the notice describes + * what the pull left rather than flashing the state it is about to replace. */ _maybeLoadMoreHistoryOnScroll(lines) { - if (lines >= 0) return; - if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.(); + if (lines > 0) { + if (this.isTerminalAtBottom()) this._setHistoryNoticeRevealed?.(null); + return; + } + if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) return; + const sessionId = this.activeSessionId; + Promise.resolve(this._maybeRefetchFullHistory?.()) + .catch(() => {}) + .then(() => { + if (sessionId && this.activeSessionId === sessionId) this._setHistoryNoticeRevealed?.(sessionId); + }); }, /** diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 8affbeb0..831ea1f6 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3225,6 +3225,12 @@ export function registerSessionRoutes( // damage that does not exist. captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined, captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined, + // Rows tmux holds above the visible frame, which is the most a `full=1` + // pull can add. `truncated` measures the BYTE stream, and for a pane that + // keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes + // a tail cut drops are old repaints that no request can bring back, so a + // client must not offer to load them. Absent when the pane was not read. + paneHistoryLines: hasLiveMuxBuffer ? captureOpts.capturedHistoryLines : undefined, }; }); diff --git a/test/history-notice-reveal.test.ts b/test/history-notice-reveal.test.ts new file mode 100644 index 00000000..c543a84b --- /dev/null +++ b/test/history-notice-reveal.test.ts @@ -0,0 +1,278 @@ +/** + * @fileoverview The partial-history notice waits until the user reaches for history. + * + * Every tab switch after the first replays a 1 MiB TAIL of the session's byte + * stream, which is truncated for any session that has run for a while, so the + * notice ("Showing the most recent 1.0 MB of this session. 4.8 MB more may still + * be retained.") covered the top rows on nearly every switch. Its × only lasted + * until the next switch. Now: + * - it appears only once a scroll gesture reaches the top of the browser's + * buffer, after the history pull that gesture starts has settled; + * - a scroll back down to live output retires it, and so does a tab switch; + * - a dismissal sticks for that session until the page reloads. + * + * Runs the REAL methods (app.js banner + state, terminal-ui.js scroll hook, + * constants.js notice decision) in a `vm` against a stub DOM, the same way + * shell-scroll-history-pull.test.ts does (no jsdom on this box). + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const APP = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); + +function methodSource(source: string, method: string): string { + const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm')); + expect(start, `${method} not found`).toBeGreaterThan(-1); + const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1)); + return next ? source.slice(start, start + 1 + next.index) : source.slice(start); +} + +interface FakeEl { + tagName: string; + hidden: boolean; + className: string; + type: string; + disabled: boolean; + children: FakeEl[]; + attrs: Record<string, string>; + onclick: null | (() => void); + textContent: string; + appendChild(child: FakeEl): void; + setAttribute(name: string, value: string): void; +} + +function fakeEl(tagName: string): FakeEl { + let text = ''; + const el: FakeEl = { + tagName, + hidden: false, + className: '', + type: '', + disabled: false, + children: [], + attrs: {}, + onclick: null, + get textContent() { + return text + el.children.map((c) => c.textContent).join(''); + }, + set textContent(value: string) { + text = value; + el.children = []; + }, + appendChild(child) { + el.children.push(child); + }, + setAttribute(name, value) { + el.attrs[name] = value; + }, + }; + return el; +} + +/** Real terminal-ui.js mixin, for `_maybeLoadMoreHistoryOnScroll` / `isTerminalAtBottom`. */ +function loadTerminalMixin(): Record<string, (...args: unknown[]) => unknown> { + const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8'); + const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> }; + const context = vm.createContext({ + console, + performance, + setTimeout, + clearTimeout, + setInterval: vi.fn(), + clearInterval: vi.fn(), + requestAnimationFrame: vi.fn(), + CodemanApp: FakeCodemanApp, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + document: { addEventListener: vi.fn() }, + }); + vm.runInContext(source, context); + return FakeCodemanApp.prototype; +} + +function makeApp() { + const bar = fakeEl('div'); + bar.hidden = true; + const document = { + getElementById: (id: string) => (id === 'historyTruncationBar' ? bar : null), + createElement: (tag: string) => fakeEl(tag), + }; + const methods = [ + '_setHistoryTruncation', + '_clearHistoryTruncation', + '_setHistoryNoticeRevealed', + '_renderHistoryTruncationBanner', + ] + .map((m) => methodSource(APP, m)) + .join(',\n'); + const context = vm.createContext({ document, console, window: {}, navigator: { userAgent: 'test' } }); + const appMethods = vm.runInContext( + `${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')} + ;({ ${methods} })`, + context, + { filename: 'app-methods.js' } + ) as Record<string, (...args: unknown[]) => unknown>; + const mixin = loadTerminalMixin(); + + const buffer = { viewportY: 500, baseY: 500 }; + let resolvePull: (() => void) | null = null; + const app = { + activeSessionId: 's1' as string | null, + terminal: { buffer: { active: buffer } }, + // Each gesture's pull is held open until the test settles it. + _maybeRefetchFullHistory: vi.fn( + () => + new Promise<void>((res) => { + resolvePull = res; + }) + ), + ...appMethods, + _maybeLoadMoreHistoryOnScroll: mixin._maybeLoadMoreHistoryOnScroll, + isTerminalAtBottom: mixin.isTerminalAtBottom, + } as Record<string, any>; + + const settle = async () => { + resolvePull?.(); + resolvePull = null; + for (let i = 0; i < 5; i++) await Promise.resolve(); + }; + const scrollTo = async (viewportY: number) => { + const lines = viewportY - buffer.viewportY; + buffer.viewportY = viewportY; + app._maybeLoadMoreHistoryOnScroll(lines); + await settle(); + }; + return { app, bar, buffer, scrollTo, settle }; +} + +// What a tab switch's tail replay reports for a session with real scrollback. +const TAIL = { + truncated: true, + truncationReason: 'tail', + source: 'mux-visible', + fullSize: 5 * 1024 * 1024, + retainedBytes: 1024 * 1024, + paneHistoryLines: 40000, +}; + +const loadButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-load'); +const dismissButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-dismiss'); + +describe('partial-history notice: lazy reveal', () => { + it('stays hidden after a truncated tab-switch replay', () => { + const { app, bar } = makeApp(); + app._setHistoryTruncation('s1', TAIL); + expect(bar.hidden).toBe(true); + }); + + it('appears once a scroll reaches the top, after the pull that gesture started', async () => { + const { app, bar, buffer, settle } = makeApp(); + app._setHistoryTruncation('s1', TAIL); + + buffer.viewportY = 0; + app._maybeLoadMoreHistoryOnScroll(-500); + expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1); + // Still pulling: no notice describing the state the pull is about to replace. + expect(bar.hidden).toBe(true); + + await settle(); + expect(bar.hidden).toBe(false); + expect(bar.textContent).toContain('40,000 lines of scrollback are retained.'); + expect(loadButton(bar)?.textContent).toBe('Load full history'); + }); + + it('shows what the pull left: nothing, when the pull brought everything back', async () => { + const { app, bar, buffer } = makeApp(); + app._setHistoryTruncation('s1', TAIL); + app._maybeRefetchFullHistory.mockImplementation(async () => { + app._setHistoryTruncation('s1', { truncated: false, source: 'mux-full-history', paneHistoryLines: 40000 }); + }); + buffer.viewportY = 0; + app._maybeLoadMoreHistoryOnScroll(-500); + for (let i = 0; i < 5; i++) await Promise.resolve(); + expect(app._historyNoticeRevealedFor).toBe('s1'); + expect(bar.hidden).toBe(true); + }); + + it('does not appear on the way up, only at the top', async () => { + const { app, bar, scrollTo } = makeApp(); + app._setHistoryTruncation('s1', TAIL); + await scrollTo(200); + expect(app._maybeRefetchFullHistory).not.toHaveBeenCalled(); + expect(bar.hidden).toBe(true); + }); + + it('goes away once the user scrolls back down to live output', async () => { + const { app, bar, scrollTo } = makeApp(); + app._setHistoryTruncation('s1', TAIL); + await scrollTo(0); + expect(bar.hidden).toBe(false); + await scrollTo(300); // still reading history + expect(bar.hidden).toBe(false); + await scrollTo(500); // back at the bottom + expect(bar.hidden).toBe(true); + }); + + it('never appears for a pane with no scrollback (fullscreen CLI), even at the top', async () => { + const { app, bar, scrollTo } = makeApp(); + app._setHistoryTruncation('s1', { ...TAIL, paneHistoryLines: 0 }); + await scrollTo(0); + expect(app._historyNoticeRevealedFor).toBe('s1'); + expect(bar.hidden).toBe(true); + }); + + it('is not revealed for a tab the user switched to while the pull ran', async () => { + const { app, bar, buffer, settle } = makeApp(); + app._setHistoryTruncation('s1', TAIL); + app._setHistoryTruncation('s2', TAIL); + buffer.viewportY = 0; + app._maybeLoadMoreHistoryOnScroll(-500); + app.activeSessionId = 's2'; + await settle(); + expect(app._historyNoticeRevealedFor ?? null).toBe(null); + expect(bar.hidden).toBe(true); + }); + + it('keeps a dismissal for that session, but only that session', async () => { + const { app, bar, scrollTo } = makeApp(); + app._setHistoryTruncation('s1', TAIL); + await scrollTo(0); + dismissButton(bar)!.onclick!(); + expect(bar.hidden).toBe(true); + + // A new replay and another trip to the top do not bring it back. + await scrollTo(500); + app._setHistoryTruncation('s1', TAIL); + await scrollTo(0); + expect(bar.hidden).toBe(true); + + // Another session still gets its notice. + app.activeSessionId = 's2'; + app._setHistoryTruncation('s2', TAIL); + await scrollTo(500); + await scrollTo(0); + expect(bar.hidden).toBe(false); + }); + + it('forgets the dismissal with the session', async () => { + const { app, bar, scrollTo } = makeApp(); + app._setHistoryTruncation('s1', TAIL); + await scrollTo(0); + dismissButton(bar)!.onclick!(); + app._clearHistoryTruncation('s1'); + expect(app._historyNoticeDismissed.has('s1')).toBe(false); + }); +}); + +describe('partial-history notice: a tab switch retires it (static guard)', () => { + it('selectSession clears the reveal before repainting the banner', () => { + const body = methodSource(APP, 'selectSession'); + const reset = body.indexOf('this._historyNoticeRevealedFor = null;'); + const render = body.indexOf('this._renderHistoryTruncationBanner();'); + expect(reset).toBeGreaterThan(-1); + expect(render).toBeGreaterThan(reset); + }); +}); diff --git a/test/history-truncation-notice.test.ts b/test/history-truncation-notice.test.ts index 50eb5735..62e391f5 100644 --- a/test/history-truncation-notice.test.ts +++ b/test/history-truncation-notice.test.ts @@ -45,6 +45,11 @@ describe('formatHistoryBytes', () => { expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB'); }); + it('never prints "1024 KB" for a tail cut back to a line boundary just under 1 MiB', () => { + expect(formatHistoryBytes(1048351)).toBe('1.0 MB'); + expect(formatHistoryBytes(1023 * 1024)).toBe('1023 KB'); + }); + it('survives junk input rather than printing NaN into the UI', () => { expect(formatHistoryBytes(-5)).toBe('less than 1 KB'); expect(formatHistoryBytes(NaN as unknown as number)).toBe('less than 1 KB'); @@ -114,6 +119,65 @@ describe('computeHistoryTruncationNotice (issue #258)', () => { }); }); +describe('computeHistoryTruncationNotice: what a pull can really return (paneHistoryLines)', () => { + const { computeHistoryTruncationNotice } = loadHelpers(); + // The tab-switch tail of a fullscreen claude pane, as measured on prod: the + // server cut a 5.8 MB byte stream to 1 MB, and tmux held 0 scrollback rows. + const fullscreenTail = { + truncated: true, + reason: 'tail', + source: 'mux-visible', + fullSize: 6158853, + retainedBytes: 1048351, + }; + + it('says nothing for a pane that keeps no scrollback, however much the byte stream lost', () => { + // The dropped bytes were old repaints of one frame, and `full=1` returns + // only the visible frame for such a pane, so the button could only ever end + // in the downgrade refusal. That is the banner that showed on every switch. + const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0 }); + expect(notice).toEqual({ visible: false, message: '', canLoadMore: false }); + }); + + it('stays silent for such a pane in the exhausted and at-ceiling states too', () => { + expect(computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0, exhausted: true }).visible).toBe( + false + ); + expect( + computeHistoryTruncationNotice({ + ...fullscreenTail, + source: 'mux-full-history', + reason: 'capped', + paneHistoryLines: 0, + }).visible + ).toBe(false); + }); + + it('names the scrollback lines a pull can load instead of the byte gap', () => { + const notice = computeHistoryTruncationNotice({ ...fullscreenTail, source: 'history', paneHistoryLines: 48210 }); + expect(notice.visible).toBe(true); + expect(notice.canLoadMore).toBe(true); + expect(notice.message).toBe( + 'Showing the most recent 1.0 MB of this session. 48,210 lines of scrollback are retained.' + ); + expect(notice.message).not.toContain('4.9 MB'); + }); + + it('uses the singular for one line', () => { + const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 1 }); + expect(notice.message).toContain('1 line of scrollback is retained.'); + }); + + it('keeps the byte wording when the server did not report the pane (older server, byte-history fallback)', () => { + for (const paneHistoryLines of [undefined, null, NaN]) { + const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines }); + expect(notice.visible).toBe(true); + expect(notice.canLoadMore).toBe(true); + expect(notice.message).toContain('more may still be retained'); + } + }); +}); + describe('the in-terminal truncation line is gone (static guard)', () => { it('no longer writes the notice into terminal output', () => { const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index d982a815..d8d3697f 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -946,6 +946,53 @@ describe('session-routes', () => { expect(body.data.captureRows).toBeUndefined(); }); + it('reports how many scrollback rows tmux holds for the captured pane', async () => { + // `truncated` measures the byte stream; this is what a `full=1` pull can + // return. 0 (a fullscreen CLI in the alternate screen) is the case where + // the client must not offer to load the rest. + harness.ctx._session.terminalBuffer = 'x'.repeat(4096); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn( + ( + _name: string, + opts?: { capturedGeometry?: { cols: number; rows: number }; capturedHistoryLines?: number } + ) => { + if (opts) { + opts.capturedGeometry = { cols: 100, rows: 50 }; + opts.capturedHistoryLines = 0; + } + return 'visible frame'; + } + ); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=1024`, + }); + + const body = JSON.parse(res.body); + expect(body.data.truncated).toBe(true); + expect(body.data.paneHistoryLines).toBe(0); + }); + + it('omits paneHistoryLines when the body is the byte history, not a capture', async () => { + harness.ctx._session.terminalBuffer = 'byte history only'; + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn( + (_name: string, opts?: { capturedHistoryLines?: number }) => { + if (opts) opts.capturedHistoryLines = 12; + return null; + } + ); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal`, + }); + + const body = JSON.parse(res.body); + expect(body.data.source).toBe('history'); + expect(body.data.paneHistoryLines).toBeUndefined(); + }); + // ── COD-47: full tmux scrollback replay on full page reload ── it('full reload (?full=1) requests full tmux history and replays boundary markers', async () => { // A realistic scrollback-length capture: ~5000 lines, well past one screen. diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index 4460f75a..ab7be919 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent } from '../src/tmux-manager.js'; +import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js'; describe('tmux full-history pane capture (COD-47)', () => { const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); @@ -153,6 +153,48 @@ describe('the geometry a capture reports back', () => { }); }); +describe('the capture reports how much scrollback tmux holds (#{history_size})', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); + const methodStart = source.indexOf('capturePaneBuffer(muxName: string'); + const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart)); + + 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.match(/display-message/g)).toHaveLength(1); + expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines'); + }); + + it('parses the fifth field as historyLines', () => { + expect(queryPaneCursor(() => '3 5 120 40 812\n')).toEqual({ + cursorX: 3, + cursorY: 5, + cols: 120, + rows: 40, + historyLines: 812, + }); + // A pane in the alternate screen (fullscreen claude) holds none. + expect(queryPaneCursor(() => '0 31 187 32 0')?.historyLines).toBe(0); + }); + + it('never lets a missing or bad history field void the caret', () => { + for (const raw of ['3 5 120 40', '3 5 120 40 ', '3 5 120 40 x', '3 5 120 40 -1']) { + const geometry = queryPaneCursor(() => raw); + expect(geometry).toEqual({ cursorX: 3, cursorY: 5, cols: 120, rows: 40 }); + expect(geometry && 'historyLines' in geometry).toBe(false); + } + }); + + it('still returns null when the geometry itself is bad', () => { + expect(queryPaneCursor(() => 'x 5 120 40 812')).toBeNull(); + expect( + queryPaneCursor(() => { + throw new Error('no pane'); + }) + ).toBeNull(); + }); +}); + describe('why a capture has to report its height', () => { it('a snapshot addresses rows the receiving terminal may not have', () => { // formatPaneSnapshot positions every row absolutely. A terminal shorter