diff --git a/CLAUDE.md b/CLAUDE.md index 02704d0fe..2387875b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,7 +211,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **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) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f446f4a1a..2851aeb5d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -221,7 +221,7 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **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 for a remote SSH session, whose local pane hosts the remote tmux client and always reports 0 while `full=1` reads the remote history), and a reported 0 hides the notice in every state; a positive count replaces the byte gap in the message. ⚠️ Key the notice decision on `history_size`, never on `alternate_on`: a shell running `less` is in the alternate screen with its whole history still retained (measured, 99,972 rows) and `full=1` returns all of it. The one place `alternate_on` decides is a shell's AUTOMATIC load (`alt=recording`, see Full-scrollback replay): there the question is whether the capture holds the scrollback a nested tmux client keeps in its byte recording, not whether a pull can return more. The notice is also LAZY: it appears only after a wheel/touch gesture reaches the top of the browser buffer and the pull that gesture started has settled (`_maybeLoadMoreHistoryOnScroll`), goes away when a downward gesture is back at live output or the tab switches (`selectSession` nulls `_historyNoticeRevealedFor` before repainting), and its × is sticky per session until reload (`_historyNoticeDismissed`). Tests: `test/history-truncation-notice.test.ts`, `test/history-notice-reveal.test.ts`. -**A tab switch to a pane with no tmux scrollback takes `full=1`, not the 1 MiB tail** (`_paneHistoryLines` + `_notePaneHistory()` in app.js). Every terminal response (select, refresh, re-pull) records `paneHistoryLines` per session, and `selectSession` serves a non-shell switch from `full=1` whenever the last report was 0. For such a pane that capture IS the visible frame (0.9 to 5.6 KB on prod) and matches what a page load shows, while the tail was 1 MiB of old repaints. Measured 2026-10-10 with real prod payloads through the real client in chromium: median switch 1.26 to 1.66 s down to 0.10 to 0.19 s, parse ~1.1 to 1.5 s down to 2 to 3 ms; server time 136 to 1070 ms (tail) vs 89 to 330 ms (full). An empty local buffer is also what lets `_maybePageCliTranscript` send a gesture to the CLI's own transcript. ⚠️ Only a reported 0 counts: a response without the field FORGETS the entry (unknown is never empty), so an older server or a byte-history fallback keeps the tail; a pane that starts keeping history (claude switched to its inline view) is learned from the next response and goes back to the tail. ⚠️ Shells never take this path. ⚠️ Remote and Docker sessions never take this path: their local pane hosts a nested tmux client in the alternate screen, so it reports 0 while the byte tail carries the real scrolled-off output. ⚠️ The refresh path (`_onSessionNeedsRefresh`) keeps its downgrade guard unchanged: a server-triggered refresh must not wipe local rows the user may be reading. Tests: `test/pane-history-memory.test.ts`, `test/fullscreen-tab-switch-capture.browser.test.ts` (browser suite). +**A tab switch to a pane with no tmux scrollback takes `full=1`, not the 1 MiB tail** (`_paneHistoryLines` + `_notePaneHistory()` in app.js). Every terminal response (select, refresh, re-pull) records `paneHistoryLines` per session, and `selectSession` serves a non-shell switch from `full=1` whenever the last report was 0. For such a pane that capture IS the visible frame (0.9 to 5.6 KB on prod) and matches what a page load shows, while the tail was 1 MiB of old repaints. Measured 2026-10-10 with real prod payloads through the real client in chromium: median switch 1.26 to 1.66 s down to 0.10 to 0.19 s, parse ~1.1 to 1.5 s down to 2 to 3 ms; server time 136 to 1070 ms (tail) vs 89 to 330 ms (full). An empty local buffer is also what lets `_maybePageCliTranscript` send a gesture to the CLI's own transcript, and the same memory is what stops it: a buffer a tail replay left at `baseY === 0` while the pane reports scrollback is unloaded, not hollow, so the gesture scrolls locally and the scroll-to-top pull (`_maybeRefetchFullHistory`) fetches `full=1` (see the hollow-pane rule under wheel forwarding). That pull skips the 60 s useless-pull latch for such a buffer (the latch's premise, a browser holding more than tmux, is gone) and drops a completion whose select generation moved during the fetch, since the session-id check alone cannot see a switch that came back (A to B to A); the refresh path records its count only past its owner check for the same reason, and neither records an error envelope. ⚠️ Only a reported 0 counts: a response without the field FORGETS the entry (unknown is never empty), so an older server or a byte-history fallback keeps the tail; a pane that starts keeping history (claude switched to its inline view) is learned from the next response and goes back to the tail. ⚠️ Shells never take this path. ⚠️ Remote and Docker sessions never take this path: their local pane hosts a nested tmux client in the alternate screen, so it reports 0 while the byte tail carries the real scrolled-off output. ⚠️ The refresh path (`_onSessionNeedsRefresh`) keeps its downgrade guard unchanged: a server-triggered refresh must not wipe local rows the user may be reading. Tests: `test/pane-history-memory.test.ts`, `test/fullscreen-tab-switch-capture.browser.test.ts` (browser suite). **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 without `alt=recording` (every `useFullHistory` pass; only shell loads send it) 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`. @@ -243,7 +243,7 @@ Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Fi **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. -**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still pages the CLI transcript. Tracked fullscreen Codex 0.163.0 forwards SGR reports first, as measured above. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to tracked Claude/Codex fullscreen renderers as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull and in the refresh path alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still pages the CLI transcript. Tracked fullscreen Codex 0.163.0 forwards SGR reports first, as measured above. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to tracked Claude/Codex fullscreen renderers as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. diff --git a/src/web/public/app.js b/src/web/public/app.js index 033838544..04d496207 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3351,11 +3351,15 @@ class CodemanApp { headersReceivedAt = capture.headersAt; data = capture.json?.data ?? {}; } - this._notePaneHistory?.(sessionId, data); // Bail on a tab switch mid-fetch: writing here would paint this session's // history into the terminal the user is now looking at. The window is two - // fetches wide in the fallback case, so this guard is not optional. + // fetches wide in the fallback case, so this guard is not optional. The + // pane count is recorded only past it: a refresh that lost its owner is + // older than whatever the switch back read (it would have overwritten a + // fresh 0 with a stale count and held the paging fallback off a hollow + // pane), and an error envelope is not a capture at all. if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return false; + if (capture.json?.success !== false) this._notePaneHistory?.(sessionId, data); if (data.terminalBuffer) { // This refresh is SERVER-triggered, so a user quietly reading scrollback // did not ask for it and must not be dragged to the bottom by it (#259). @@ -9238,10 +9242,23 @@ class CodemanApp { // `force` is the user pressing "Load full history" (#258): they asked once, // explicitly, so the scroll-gesture cooldown does not apply. The downgrade // guard below still does — a forced pull must not destroy history either. - const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000; + // A refused pull latches 60 s because the browser then held MORE than tmux + // (the downgrade guard below). A tab switch that replayed a hollow tail has + // voided that premise: one screen in the browser while the pane holds + // scrollback, the state `_maybePageCliTranscript` hands to this pull instead + // of paging the CLI. Hold such a buffer to the normal cooldown only, or the + // gesture is dead for up to a minute. + const unloaded = + (this._paneHistoryLines?.get(sessionId) ?? 0) > 0 && (this.terminal?.buffer?.active?.baseY ?? 0) === 0; + const cooldown = !unloaded && this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000; if (!force && now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return; this._fullHistoryRepullAt.set(sessionId, now); this._fullHistoryRepullInFlight = true; + // The session-id check below cannot see a tab switch that came BACK during + // the fetch (A -> B -> A): the id matches again while `selectSession` is + // replaying its own capture, and the reset below would land in the middle + // of that write. The select generation moves on every switch, so it can. + const selectGen = this._selectGeneration; try { const requestStartedAt = performance.now(); const capture = await this._fetchTerminalCapture( @@ -9253,7 +9270,13 @@ class CodemanApp { const headersReceivedAt = capture.headersAt; const payload = capture.json?.data ?? {}; const bodyParsedAt = performance.now(); - this._notePaneHistory?.(sessionId, payload); + // A completion that outlived a tab switch is older than whatever that + // switch recorded about the pane (a switch back re-reads it): keep its + // count out of the memory, or it can overwrite a fresh count with a stale one. + // An error envelope (a JSON 5xx, `success: false`) is not a capture: + // recording its empty data would forget the count and hand the next + // gesture back to the paging fallback, the bug this pull exists to avoid. + if (selectGen === this._selectGeneration && capture.json?.success !== false) this._notePaneHistory?.(sessionId, payload); const buffer = payload.terminalBuffer; const timing = { trigger: force ? 'full-history-button' : 'full-history-scroll', @@ -9269,8 +9292,9 @@ class CodemanApp { refused: false, }; // Bail on a tab switch mid-fetch: writing here would paint another session's - // history into the terminal the user is now looking at. - if (!buffer || this.activeSessionId !== sessionId) return; + // history into the terminal the user is now looking at, or this session's + // over the load a switch back has started. + if (!buffer || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return; const windowRows = this._estimateReplayRows(buffer, this.terminal.cols); // A bounded window no longer than the browser's buffer buys nothing, and // resetting to rewrite it would jump the viewport on every scroll that @@ -9342,7 +9366,7 @@ class CodemanApp { this._bufferLoadFinishOpts(payload, headersReceivedAt) ); timing.resetAndParseMs = parsedAt - replayStartedAt; - if (!completed || this.activeSessionId !== sessionId) return; + if (!completed || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return; // Keep shell tab restores bounded too. A user-triggered full-history pull // may be tens of MB; caching it would replay that whole payload again on // the next tab switch before the normal 1MB tail fetch replaces it. @@ -9364,6 +9388,10 @@ class CodemanApp { // reached only from a scroll-up gesture, so being dragged down is the // exact opposite of what the user asked for. this._syncStickyScrollBaseline(); + // The prompt row moved with the rewrite: re-position the local-echo + // overlay there, as the refresh path does (a phone swipe reaches this + // pull with unsent text on the overlay). + this._localEchoOverlay?.rerender(); timing.totalMs = performance.now() - requestStartedAt; this._recordTerminalLoadTiming(timing); } catch { diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 1a0c70584..6d95d9401 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -1176,6 +1176,10 @@ // names no CLI. The gesture rules (first-event page, notch accumulation, // pinch and sideways-swipe consume) are the shared pure // CodemanTerminalInput.pageKeysForGesture, run on this tile's own state. + // The primary pane's unloaded-scrollback fall-through (`paneHistoryLines` + // > 0 with baseY 0, after a restart's tail replay) has no twin here: a tile + // loads tmux's rendered history (`full=1`), so it does not share that + // tail-select failure, and its non-shell wheel has no pull to fall through to. // Returns true when the wheel was consumed here. _maybePageCliTranscript(ev) { if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index c3778ffec..155fddedd 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5883,7 +5883,8 @@ Object.assign(CodemanApp.prototype, { // a fix. Scoping the setting away from those modes would be the other // option, but it would override an explicit user choice; instead the caller // falls through to _maybePageCliTranscript, so the gesture still pages the - // CLI's transcript and the setting keeps meaning exactly what it says. + // CLI's transcript (or loads unloaded tmux scrollback, see there) and the + // setting keeps meaning exactly what it says. if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode; if (mode && mode !== 'none') return false; @@ -6038,8 +6039,11 @@ Object.assign(CodemanApp.prototype, { * * Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability + * `baseY === 0`) plus a false forwarding gate, so a session with real local - * scrollback is never touched. Shift is excluded on purpose: it is the explicit - * "give me local scrollback" gesture and must keep that meaning. + * scrollback is never touched, and neither is one whose scrollback tmux still + * holds but the last replay did not carry (`paneHistoryLines` > 0): that one + * falls through to the local path, whose scroll-to-top pull loads it. Shift is + * excluded on purpose: it is the explicit "give me local scrollback" gesture + * and must keep that meaning. * * The FIRST event of a trackpad-sized gesture (opening under * PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of @@ -6083,6 +6087,22 @@ Object.assign(CodemanApp.prototype, { this._logScrollRouting('page-keys-dialog-open'); return true; } + // A hollow buffer whose PANE still holds tmux scrollback is unloaded, not + // hollow: after a server restart a tab switch replays a byte tail that is + // only the attach repaint (baseY 0), and paging from there moved an inline + // Claude's composer cursor instead of its transcript. The remembered + // `paneHistoryLines` (tmux `#{history_size}`) says a `full=1` pull can add + // it, so fall through to local scrolling, whose top-of-buffer pull fetches + // it (_maybeLoadMoreHistoryOnScroll -> _maybeRefetchFullHistory). 0 and + // unknown keep paging. Page keys already queued belong to the abandoned + // route and go with the gesture debt. Not logged as a decision of its own + // (it would alternate with the caller's line every frame until the pull + // lands): the caller's `local-scrollback` line carries `paneHistoryLines`. + if ((this._paneHistoryLines?.get(this.activeSessionId) ?? 0) > 0) { + this._pageKeyGesture = null; + this._wheelSgrQueue = ''; + return false; + } // Leftover travel and the gesture in progress belong to the tab they were made on. if (this._pageKeySession !== this.activeSessionId) { this._pageKeySession = this.activeSessionId; @@ -6124,16 +6144,20 @@ Object.assign(CodemanApp.prototype, { // the DECSETs), so the CLI's real tracking state is reported separately. const cliTracking = session?.cliMouseTracking === true; const baseY = this.terminal?.buffer?.active?.baseY ?? -1; + // What tmux still holds for this pane (the last capture's `paneHistoryLines`): + // `localScrollbackRows=0` beside a positive count is an UNLOADED buffer, + // which _maybePageCliTranscript hands to the local path instead of paging. + const paneHistory = this._paneHistoryLines?.get(sessionId); const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|` + - `${tracking}|${encoding}|${cliTracking}|${baseY > 0}`; + `${tracking}|${encoding}|${cliTracking}|${baseY > 0}|${(paneHistory ?? 0) > 0}`; if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map(); if (this._scrollRoutingLogged.get(sessionId) === signature) return; this._scrollRoutingLogged.set(sessionId, signature); console.log( `[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` + `localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, mouseEncoding=${encoding}, ` + - `cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY})` + `cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY}, paneHistoryLines=${paneHistory ?? 'unknown'})` ); }, diff --git a/test/fullscreen-tab-switch-capture.browser.test.ts b/test/fullscreen-tab-switch-capture.browser.test.ts index d0b52cd83..fc38fa49f 100644 --- a/test/fullscreen-tab-switch-capture.browser.test.ts +++ b/test/fullscreen-tab-switch-capture.browser.test.ts @@ -180,3 +180,101 @@ describe('tab switch to a pane that keeps no scrollback', () => { await context.close(); }, 90_000); }); + +describe('a tab switch whose tail replays no scrollback while the pane holds some', () => { + it('scrolls by pulling the full history instead of paging the CLI', async () => { + // After a server restart the byte recording is only the tmux client's attach + // repaint (home + clear + absolute redraws), so the tail a second select + // replays leaves baseY 0 while the response still reports tmux scrollback. + // The scroll used to page the CLI (PageUp into an inline Claude's composer); + // it now falls through to the local path, whose top-of-buffer pull fetches + // `full=1` and the gesture then scrolls real history. + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 }); + const page = await context.newPage(); + await openApp(page); + const inline = await createSession(page, 'inline-claude-after-restart'); + const other = await createSession(page, 'other'); + const log: string[] = []; + const history = Array.from({ length: 200 }, (_, i) => `history line ${i}\r\n`).join(''); + await page.route('**/api/sessions/*/terminal*', async (route) => { + const url = new URL(route.request().url()); + const id = url.pathname.split('/')[3]; + const full = url.searchParams.get('full') === '1'; + log.push(`${id}:${full ? 'full' : 'tail'}`); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + terminalBuffer: full ? `${history}frame of ${id}\r\n` : `\x1b[H\x1b[2Jframe of ${id}\r\n`, + status: 'idle', + fullSize: 4096, + retainedBytes: 4096, + truncated: false, + truncationReason: null, + source: full ? 'mux-full-history' : 'mux-visible', + paneHistoryLines: 200, + }, + }), + }); + }); + + await select(page, inline); + await select(page, other); + await select(page, inline); + expect(log).toEqual([`${inline}:full`, `${other}:full`, `${inline}:tail`]); + const baseYAfterTail = await page.evaluate( + () => (window as unknown as { app: { terminal: any } }).app.terminal.buffer.active.baseY + ); + expect(baseYAfterTail).toBe(0); + // The prerequisite the guard answers: by the old rule this pane IS hollow. + expect(await page.evaluate(() => (window as unknown as { app: any }).app._localScrollbackIsHollow())).toBe(true); + + // Record what the scroll sends to the CLI, then wheel up over the terminal. + await page.evaluate(() => { + const app = (window as unknown as { app: any }).app; + app.__sentToCli = []; + app._sendInputEphemeral = (_id: string, data: string) => app.__sentToCli.push(data); + }); + log.length = 0; + const box = await page.locator('#terminalContainer').boundingBox(); + if (!box) throw new Error('terminal container not laid out'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, -400); + await page.waitForFunction( + () => (window as unknown as { app: { terminal: any } }).app.terminal.buffer.active.baseY > 0, + null, + { timeout: 10_000 } + ); + expect(log).toEqual([`${inline}:full`]); + + // The next gesture scrolls the recovered history itself: older text comes + // into view, nothing further is fetched, and the CLI still receives nothing + // (the page-key queue flushes 40 ms after a gesture, so settle first). + await page.mouse.wheel(0, -400); + await page.waitForFunction( + () => { + const b = (window as unknown as { app: { terminal: any } }).app.terminal.buffer.active; + return b.viewportY < b.baseY; + }, + null, + { timeout: 10_000 } + ); + await page.waitForTimeout(200); + const view = await page.evaluate(() => { + const t = (window as unknown as { app: { terminal: any } }).app.terminal; + const b = t.buffer.active; + const lines: string[] = []; + for (let i = b.viewportY; i < b.viewportY + t.rows; i++) lines.push(b.getLine(i)?.translateToString(true) ?? ''); + return lines.join('\n'); + }); + expect(view).toContain('history line '); + expect(log).toEqual([`${inline}:full`]); + expect(await page.evaluate(() => (window as unknown as { app: any }).app.__sentToCli)).toEqual([]); + + await deleteSession(page, inline); + await deleteSession(page, other); + await context.close(); + }, 90_000); +}); diff --git a/test/pane-history-memory.test.ts b/test/pane-history-memory.test.ts index eea74cbbd..2b147427b 100644 --- a/test/pane-history-memory.test.ts +++ b/test/pane-history-memory.test.ts @@ -77,4 +77,18 @@ describe('every terminal response feeds it (static guard)', () => { expect(methodSource(APP, '_onSessionNeedsRefresh')).toContain('this._notePaneHistory?.(sessionId, data);'); expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain('this._notePaneHistory?.(sessionId, payload);'); }); + + it('the refresh records the pane only once it still owns the terminal, and neither it nor the pull records an error envelope', () => { + // A refresh that lost its owner is older than what the switch back read; an + // error envelope has no count, and forgetting on it would hand a scroll back + // to the paging fallback (see _maybePageCliTranscript). + const refresh = methodSource(APP, '_onSessionNeedsRefresh'); + const ownerCheck = refresh.indexOf('this._terminalRefreshOwner !== refreshOwner) return false;'); + expect(ownerCheck).toBeGreaterThan(-1); + expect(refresh.indexOf('this._notePaneHistory?.(sessionId, data);')).toBeGreaterThan(ownerCheck); + expect(refresh).toContain('capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);'); + expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain( + 'capture.json?.success !== false) this._notePaneHistory?.(sessionId, payload);' + ); + }); }); diff --git a/test/shell-scroll-history-pull.test.ts b/test/shell-scroll-history-pull.test.ts index 10b730aae..2b38c3f3f 100644 --- a/test/shell-scroll-history-pull.test.ts +++ b/test/shell-scroll-history-pull.test.ts @@ -24,6 +24,10 @@ * the real `_setHistoryTruncation` and the real `computeHistoryTruncationNotice` * to pin what the user is actually told. * + * The last block pins the pull's own tab-switch guard: a capture that lands + * after A -> B -> A is dropped by the select generation, since the session id + * alone matches again while the switch back is still replaying. + * * The method is extracted from app.js and run in a `vm` against stubs (no jsdom * on this box; see connection-indicator.test.ts), with the REAL row estimators * from terminal-ui.js, which decide both the downgrade and the no-gain skip. @@ -118,17 +122,19 @@ function makeApp( activeSessionId: 's1', sessions: new Map([['s1', { mode }]]), detachedSessions: new Set<string>(), + _selectGeneration: 7, _fullHistoryRepullInFlight: false, _isLoadingBuffer: false, _fullHistoryRepullAt: new Map<string, number>(), _fullHistoryRepullUseless: new Set<string>(), + _paneHistoryLines: new Map<string, number>(), terminalBufferCache: new Map<string, string>(), terminal: { cols: 80, rows: 30, // xterm's scrollback option; 0 leaves the browser-cap check out of a test. options: { scrollback }, - buffer: { active: { length: bufferRows } }, + buffer: { active: { length: bufferRows, baseY: 0 } }, scrollToLine: vi.fn(), scrollToTop: vi.fn(), }, @@ -144,6 +150,7 @@ function makeApp( }), _recordTerminalLoadTiming: vi.fn(), _logScrollRouting: vi.fn(), + _notePaneHistory: vi.fn(), // The real method behind a spy, so a test sees both what it was called with // and the banner state (`_historyTruncation`) it leaves behind. _historyTruncation: new Map<string, unknown>(), @@ -358,3 +365,77 @@ describe('_replayWouldShrinkBuffer takes rows the caller already estimated', () expect(ctx._estimateReplayRows).toHaveBeenCalledTimes(1); }); }); + +describe('a pull that outlives a tab switch never rewrites the terminal', () => { + it('drops the capture when the select generation moved during the fetch, even back to the same session', async () => { + // A -> B -> A while the request was out: the active id matches again, but + // selectSession is replaying its own capture, and a reset here would land + // in the middle of that write. + const { app } = makeApp('claude', { bufferRows: 40, capture: lines(300) }); + app._fetchTerminalCapture.mockImplementationOnce(async () => { + app._selectGeneration = 9; + return { + headersAt: performance.now(), + headers: { get: () => '' }, + json: { data: { terminalBuffer: lines(300), source: 'mux-full-history' } }, + }; + }); + await refetch.call(app); + expect(app._resetTerminalForReplay).not.toHaveBeenCalled(); + expect(app.chunkedTerminalWrite).not.toHaveBeenCalled(); + expect(app._fullHistoryRepullInFlight).toBe(false); + // Nor does the stale response overwrite what the switch back recorded about the pane. + expect(app._notePaneHistory).not.toHaveBeenCalled(); + }); + + it('replays when no switch happened, and records the pane', async () => { + const { app } = makeApp('claude', { bufferRows: 40, capture: lines(300) }); + await refetch.call(app); + expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1); + expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1); + expect(app._notePaneHistory).toHaveBeenCalledWith('s1', expect.objectContaining({ source: 'mux-full-history' })); + }); +}); + +describe('an unloaded buffer is not held to an older downgrade refusal', () => { + it('pulls on the normal cooldown when the buffer is one screen and the pane holds scrollback', async () => { + // A refused pull latches a 60 s cooldown because the browser then held MORE + // than tmux. A tab switch that replays a hollow tail voids that premise, and + // with paging suppressed for such a buffer the latch would leave the gesture + // dead for up to a minute. + const { app, urls } = makeApp('claude', { bufferRows: 30, capture: lines(300) }); + app._fullHistoryRepullUseless.add('s1'); + app._fullHistoryRepullAt.set('s1', Date.now() - 10_000); + app.terminal.buffer.active.baseY = 0; + app._paneHistoryLines.set('s1', 11_344); + await refetch.call(app); + expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']); + expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1); + }); + + it('keeps the latch while the buffer still holds the history the refusal was about', async () => { + const { app, urls } = makeApp('claude', { bufferRows: 300, capture: lines(300) }); + app._fullHistoryRepullUseless.add('s1'); + app._fullHistoryRepullAt.set('s1', Date.now() - 10_000); + app.terminal.buffer.active.baseY = 270; + app._paneHistoryLines.set('s1', 11_344); + await refetch.call(app); + expect(urls).toEqual([]); + }); + + it('records nothing from an error envelope, so the remembered count survives for the retry', async () => { + // A JSON 5xx parses like any response. Recording its empty data would + // forget the count and hand the next gesture back to the paging fallback. + const { app } = makeApp('claude', { bufferRows: 30, capture: lines(300) }); + app._paneHistoryLines.set('s1', 11_344); + app._fetchTerminalCapture.mockImplementationOnce(async () => ({ + headersAt: performance.now(), + headers: { get: () => '' }, + json: { success: false, error: 'tmux capture failed' }, + })); + await refetch.call(app); + expect(app._notePaneHistory).not.toHaveBeenCalled(); + expect(app._resetTerminalForReplay).not.toHaveBeenCalled(); + expect(app._fullHistoryRepullInFlight).toBe(false); + }); +}); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index a374b395a..73ce48198 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -394,6 +394,42 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); }); + it('falls through to local scrolling when tmux still holds the scrollback the replay left out', () => { + // After a server restart a tab switch replays a byte tail that is only the + // tmux client's attach repaint: baseY 0, while the same response reported + // tmux scrollback (`paneHistoryLines`). Paging there sent PageUp into an + // inline Claude's composer; the local path's scroll-to-top pull + // (_maybeRefetchFullHistory) is what loads that scrollback. + const { app, sent, logs } = hollowApp(); + app._pageKeyGesture = { stale: true }; + // A page key queued before the count was known must not reach the CLI either. + app._wheelSgrQueue = '\x1b[5~'; + app._paneHistoryLines = new Map([['sess-1', 64]]); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); + expect(app._maybePageCliTranscript({ shiftKey: false }, 18)).toBe(false); + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); + expect(app._pageKeyGesture).toBeNull(); + // No routing line of its own: the caller's `local-scrollback` line (with + // `localScrollbackRows=0`) is the record, and a second line per frame + // would alternate with it until the pull lands. + expect(logs).toEqual([]); + + // 0 is a pane that keeps no scrollback (fullscreen claude, codex, opencode): + // paged as before, and so is unknown (no capture yet, an older server). + app._paneHistoryLines.set('sess-1', 0); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + app._paneHistoryLines.delete('sess-1'); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + delete app._paneHistoryLines; + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + + // An open dialog still wins: nothing scrolls under it and no pull starts. + app._paneHistoryLines = new Map([['sess-1', 64]]); + app.tabAlerts = new Map([['sess-1', 'action']]); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + }); + it('pages a Codex transcript only while its local scrollback is empty', () => { // Codex without observed mouse tracking retains its PageUp/PageDown fallback. const { app, sent } = hollowApp();