diff --git a/.changeset/codex-wheel-dead-zone.md b/.changeset/codex-wheel-dead-zone.md new file mode 100644 index 00000000..90e84050 --- /dev/null +++ b/.changeset/codex-wheel-dead-zone.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +A mouse wheel over a Codex pane (and any CLI paged with PageUp/PageDown) now moves the transcript on its first notch. Because the wheel is turned into whole-page keys when the CLI does not track the mouse, it used to send nothing until half a screen of travel had built up: 5 notches in Chrome, 7 on Firefox's 3-line notches, so a short scroll looked dead. The first notch now pays its page at once, as a trackpad flick already did, and the notches after it repay it, so the page rate is unchanged. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 0c7efb4b..e5e96bd1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -245,7 +245,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). ⚠️ **`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, the refresh path and the tab switch 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. ⚠️ Four 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), anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), and anything while a Codex selection menu is on the pane's screen (the "Implement this plan?" prompt, the /model, /permissions and /review pickers, a question, an approval, the trust prompt). All of them exist so a page key never lands in an open selector: Codex binds PageUp/PageDown to the SELECTION in its menus (codex-cli 0.164.0 measured: PageDown from option 1 lands on option 3), so a wheel turned into page keys walked the cursor through the options, one notch onto "Yes, implement this plan". The menu is read off the screen by `CodemanTerminalInput.isCodexSelectionMenu` (a selected numbered option above a key-hint footer that is the last thing drawn; an answer that quotes the hints above the composer is not a menu), not from an alert: the plan prompt is deliberately NOT a blocked dialog (`test/codex-blocked-detection.test.ts`), so nothing about blocked state or notifications changes. The primary pane logs the two dialog-like cases as `page-keys-dialog-open` and `page-keys-menu-open` so a wheel dead under a stale alert or an open menu leaves a `[scroll]` line. The alert check and the menu check are the two rules outside the helper, because they are per pane: the primary pane reads `tabAlerts` and its own terminal for `activeSessionId`, a tile its own `sessionId` and terminal (usually not the active ones; `_codexMenuOpen(target)`). ⚠️ 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, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, 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, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ 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`), or `PAGE_KEY_NOTCH_DEBT_MS` (3 s) for a notch-sized opener, 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) and slow notches pay the debt back instead of each pre-paying a page; after that much quiet the debt lapses and the next 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 debt, 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. ⚠️ Four 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), anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), and anything while a Codex selection menu is on the pane's screen (the "Implement this plan?" prompt, the /model, /permissions and /review pickers, a question, an approval, the trust prompt). All of them exist so a page key never lands in an open selector: Codex binds PageUp/PageDown to the SELECTION in its menus (codex-cli 0.164.0 measured: PageDown from option 1 lands on option 3), so a wheel turned into page keys walked the cursor through the options, one notch onto "Yes, implement this plan". The menu is read off the screen by `CodemanTerminalInput.isCodexSelectionMenu` (a selected numbered option above a key-hint footer that is the last thing drawn; an answer that quotes the hints above the composer is not a menu), not from an alert: the plan prompt is deliberately NOT a blocked dialog (`test/codex-blocked-detection.test.ts`), so nothing about blocked state or notifications changes. The primary pane logs the two dialog-like cases as `page-keys-dialog-open` and `page-keys-menu-open` so a wheel dead under a stale alert or an open menu leaves a `[scroll]` line. The alert check and the menu check are the two rules outside the helper, because they are per pane: the primary pane reads `tabAlerts` and its own terminal for `activeSessionId`, a tile its own `sessionId` and terminal (usually not the active ones; `_codexMenuOpen(target)`). ⚠️ 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] → forward-sgr|page-keys|page-keys-dialog-open|page-keys-menu-open|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/terminal-tile.js b/src/web/public/terminal-tile.js index c7b26e59..867ede2c 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -1173,7 +1173,7 @@ // pages the CLI's own transcript with PageUp/PageDown. Every gate is the // primary pane's own, asked for THIS pane (its terminal, its session, never // the active one), so the CLI rules stay in terminal-ui.js and this file - // names no CLI. The gesture rules (first-event page, notch accumulation, + // names no CLI. The gesture rules (first-event page, notch debt, // 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` diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 5ece9a7d..dd006652 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -98,10 +98,20 @@ const PAGE_KEY_GESTURE_GAP_MS = 150; // ...but not for sub-row jitter (0.1 row, ~2px)... const PAGE_KEY_MIN_START_ROWS = 0.1; - // ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px, - // 4 rows) accumulates toward half a screen as before; paging at once on it made - // slow notches, each its own gesture past the gap, page a full screen apiece. + // ...for any opening event under one page. A trackpad-sized opener (under this many rows) and a + // mouse-wheel notch (100px, 4 rows) both page at once; this size is what tells them apart for the debt + // (see PAGE_KEY_NOTCH_DEBT_MS). The signal is the opener's size, not the device: a touch contact whose + // first touchmove is this many rows or more is treated as a notch too, and so is a brisk trackpad + // flick that opens with a large event. Both are fine, and both get the longer debt. const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2; + // A mouse-wheel notch used to just accumulate toward half a screen, so a mouse showed nothing for the + // first 5 notches (7 on Firefox's 3-line notches) and a gesture of a few notches seemed dead. The first + // notch of a gesture now pre-pays its page like a trackpad flick does, and the notches that follow repay + // it: the same gesture sends the same pages, the first one sooner. The old objection was slow notches, + // each its own gesture past the gap, paging a full screen apiece, so a notch's debt outlives the pauses + // between notches for this long (a trackpad's lasts PAGE_KEY_DEBT_MS). It applies to any opener of + // PAGE_KEY_IMMEDIATE_MAX_ROWS rows or more, whatever device it came from. + const PAGE_KEY_NOTCH_DEBT_MS = 3000; // A pre-paid page's debt outlives the gesture gap for this long when the next // event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a // Firefox 1-line notch) pay it back instead of each pre-paying a page of its own. @@ -205,19 +215,22 @@ // (`ev.touchId`, set by the touch paths per touchstart and carried by that // contact's momentum; a wheel event has none, so wheel and touch never // share a gesture), or, for events outside any contact, after quiet: - // PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid - // page is still owed in the same direction. One contact is never split by - // time, so a slow drag (one line per 170 ms) is one gesture; - // - a new gesture's trackpad-sized opening event (at least - // PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE, + // PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS (PAGE_KEY_NOTCH_DEBT_MS + // when a notch-sized opener pre-paid it) while a pre-paid page is still owed + // in the same direction. One contact is never split by time, so a slow drag + // (one line per 170 ms) is one gesture; + // - a new gesture's opening event (at least PAGE_KEY_MIN_START_ROWS and + // under one page: a trackpad flick, or a mouse notch) pages AT ONCE, // pre-paid: the half screen it skipped is owed back by the rest of the // gesture, so the rate stays one page per half screen. A debt left by a // pre-paid page dies with its gesture; // - a direction change inside a gesture (a reversal within the gap, or // within the same contact) drops a pre-paid debt and accumulates, never // pre-paying, so a +/-3 px trackpad wobble cannot page back and forth; - // - everything else, a wheel notch included, accumulates through - // pageKeysForTravel, carried across notches. + // - a mouse-wheel notch (any opening event under one page, past the trackpad size) pre-pays its page + // the same way, and its debt lasts PAGE_KEY_NOTCH_DEBT_MS, so the notches that follow repay it + // however slowly they come instead of each paging a screen of its own; + // - everything else accumulates through pageKeysForTravel, carried across notches. // Returns { state, keys } with keys '' when nothing is due. function pageKeysForGesture(state, ev, lines, rows, now) { const prev = state || {}; @@ -239,7 +252,8 @@ gestureStart = false; // the same contact, however long it has been } else { const quiet = now - prev.lastAt; - gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS); + const debtMs = prev.notch ? PAGE_KEY_NOTCH_DEBT_MS : PAGE_KEY_DEBT_MS; + gestureStart = quiet > (prepaid && !reversed ? debtMs : PAGE_KEY_GESTURE_GAP_MS); } if (gestureStart || reversed) { // A pre-paid debt belongs to its gesture and its direction. @@ -248,20 +262,34 @@ } if (gestureStart) { const size = Math.abs(lines); - if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) { + // Plain travel left over from the gesture before (what was left once its debt was repaid) is + // not dropped by the new opener: it counts toward the page, so a gesture made of notches pages + // once per `perPage` rows of travel, as it did before the first notch paged at once. + const carried = pending * dir > 0 ? pending : 0; + if (size >= PAGE_KEY_MIN_START_ROWS && size < perPage && (carried + lines) * dir < perPage) { // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). return { - state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId }, + state: { + pending: carried + lines - dir * perPage, + lastAt: now, + dir, + prepaid: true, + notch: size >= PAGE_KEY_IMMEDIATE_MAX_ROWS, + touchId, + }, keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN, }; } } const step = pageKeysForTravel(pending, lines, rows); // A debt carries the sign opposite to its direction; once the travel reaches - // or crosses zero it is repaid, and what is left is plain travel (so the - // one-second debt lifetime no longer applies). + // or crosses zero it is repaid, and what is left is plain travel (so neither + // debt lifetime, PAGE_KEY_DEBT_MS or PAGE_KEY_NOTCH_DEBT_MS, applies any more). if (prepaid && step.pending * dir >= 0) prepaid = false; - return { state: { pending: step.pending, lastAt: now, dir, prepaid, touchId }, keys: step.keys }; + return { + state: { pending: step.pending, lastAt: now, dir, prepaid, notch: prepaid && !!prev.notch, touchId }, + keys: step.keys, + }; } const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; @@ -461,6 +489,7 @@ PAGE_KEY_MIN_START_ROWS, PAGE_KEY_IMMEDIATE_MAX_ROWS, PAGE_KEY_DEBT_MS, + PAGE_KEY_NOTCH_DEBT_MS, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, @@ -6081,17 +6110,19 @@ Object.assign(CodemanApp.prototype, { * 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 + * The FIRST event of a gesture (any opening event under one page: a trackpad + * flick, or a mouse notch) pages at once. Waiting for half a screen of * travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick * never sent a key at all. That page is pre-paid: the travel it skipped is * owed back by the rest of the gesture, so the rate stays one page per * `perPage` rows. A new touch contact (`ev.touchId`), a pause outside a contact - * (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is - * still owed), a reversal after the gap or a tab switch starts a new gesture; + * (PAGE_KEY_GESTURE_GAP_MS; while a same-direction page is still owed, + * PAGE_KEY_DEBT_MS, or PAGE_KEY_NOTCH_DEBT_MS when a notch-sized opener pre-paid + * it), a reversal after the gap or a tab switch starts a new gesture; * one contact is never split by time, and a quick reversal never pre-pays. A - * larger opening event is a wheel notch and accumulates as before, so slow - * notches still page once per `perPage` rows. + * mouse-wheel notch pages at once too, and its debt (PAGE_KEY_NOTCH_DEBT_MS) is repaid by the + * notches after it, so a mouse pages once per `perPage` rows of travel with the + * first page on the first notch, not the fifth. * * Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad * pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|; diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 73ce4819..fa82c75d 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -730,8 +730,10 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => { expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP]); }); - it('accumulates a notch-sized opening event, and carries plain travel across notches', () => { - // Five 4-row notches 400 ms apart: each is a new gesture, none pages alone. + it('pages a notch-sized opening event at once, and the notches after it repay it', () => { + // Five 4-row notches 400 ms apart: the first pays the page, the next four (16 rows of travel + // against the 14 owed on 36 rows, half a screen less the opener's 4) repay it, and none of them + // pages again. const steps = run([ [-4, 0], [-4, 400], @@ -739,7 +741,86 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => { [-4, 1200], [-4, 1600], ]); - expect(steps.map((s) => s.keys).join('')).toBe(UP); + expect(steps.map((s) => s.keys)).toEqual([UP, '', '', '', '']); + }); + + it('pages a mouse notch at once (4 rows Chrome, 3 rows Firefox) and repays it over the notches that follow', () => { + // Before this, the first 5 Chrome notches (7 Firefox) sent nothing at all: a gesture of a few notches + // looked dead. Each page now comes with the notch that opens it instead of the one that completes it: + // the rate is the same, one page per 19 rows of travel, a page ahead. + const notches = (lines: number, count: number) => + run( + Array.from({ length: count }, (_, i) => [lines, i * 400] as [number, number]), + 38 + ); + // Chrome: a page at notch 1, repaid by notches 2 to 5, and the next gesture opens at notch 6. + expect(notches(-4, 11).map((s) => s.keys)).toEqual([UP, '', '', '', '', UP, '', '', '', '', UP]); + // Firefox's 3-row notches repay it over notches 2 to 7, so the next page opens at notch 8. + expect(notches(-3, 8).map((s) => s.keys)).toEqual([UP, '', '', '', '', '', '', UP]); + }); + + it('keeps the leftover travel when a notch opens the next gesture, so the page rate matches plain travel', () => { + // 6 notches of 4 rows on a 10-row tile (5 rows a page): the page pre-paid by notch 1 is repaid + // by notch 2, the leftover opens notch 3's page, and so on. Dropping the leftover (the first cut + // of this rule) sent 3 pages for the same travel that earlier sent 4. + const steps = run( + Array.from({ length: 6 }, (_, i) => [-4, i * 400] as [number, number]), + 10 + ); + expect(steps.map((s) => s.keys)).toEqual([UP, '', UP, UP, UP, UP]); + // Over a long run the count follows the travel (a page per perPage rows, one ahead). + const long = run( + Array.from({ length: 200 }, (_, i) => [-4, i * 400] as [number, number]), + 38 + ); + const pages = long.filter((s) => s.keys).length; + expect(pages).toBeGreaterThanOrEqual(Math.floor((200 * 4) / 19)); + expect(pages).toBeLessThanOrEqual(Math.ceil((200 * 4) / 19) + 1); + }); + + it('treats a touch contact whose first move is 2 rows or more the same way (opener size, not device)', () => { + // [lines, ms, extra]: one contact (touchId 1), a 3-row opening touchmove and the next 16 ms later. + const steps = run( + [ + [-3, 0, { touchId: 1 }], + [-3, 16, { touchId: 1 }], + ], + 38 + ); + expect(steps.map((s) => s.keys)).toEqual([UP, '']); + }); + + it('keeps a notch debt across slow pauses (up to PAGE_KEY_NOTCH_DEBT_MS), then pages the next notch at once', () => { + const { PAGE_KEY_NOTCH_DEBT_MS } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + expect(PAGE_KEY_NOTCH_DEBT_MS).toBe(3000); + // Notches 1.2 s apart (past the old second of debt) still add up to one page, not one each. + const slow = run( + Array.from({ length: 5 }, (_, i) => [-4, i * 1200] as [number, number]), + 38 + ); + expect(slow.map((s) => s.keys)).toEqual([UP, '', '', '', '']); + // After more than that of quiet it is a new gesture: its first notch pages at once. + const resumed = run( + [ + [-4, 0], + [-4, 400], + [-4, 4_000], + ], + 38 + ); + expect(resumed.map((s) => s.keys)).toEqual([UP, '', UP]); + }); + + it('drops a notch debt when the direction reverses, so a notch back pages too', () => { + const steps = run( + [ + [-4, 0], + [-4, 400], + [4, 800], + ], + 38 + ); + expect(steps.map((s) => s.keys)).toEqual([UP, '', DOWN]); }); it('keeps a same-direction pre-paid debt for PAGE_KEY_DEBT_MS, then drops it with its gesture', () => { @@ -1092,11 +1173,12 @@ describe('consistent scroll gesture adapters', () => { expect(app._wheelTickGesture.pending).toBeCloseTo(-0.8); }); - it('preserves accumulated coarse Codex wheel fallback travel', () => { + it('pages a coarse Codex wheel notch at once and keeps the travel after it as repayment', () => { const { app, sent } = hollowApp({ mode: 'codex', rows: 43 }); app._maybePageCliTranscript({ deltaY: -100 }, -4); app._flushWheelSgrQueue(); - expect(sent).toEqual([]); + expect(sent).toHaveLength(1); + // Five more notches (20 rows, a page is 22) repay the page the first one took: no second page. for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4); app._flushWheelSgrQueue(); expect(sent).toHaveLength(1); diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 42cab6b8..fff39738 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -588,11 +588,11 @@ describe('page-key travel, cap and coalescing', () => { FakeFit.proposed = { cols: 80, rows: 36 }; }); - it('accumulates coarse sub-page travel before sending one page', async () => { + it('pages a coarse opening event at once, and the next one repays it instead of paging again', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); mount.fire('wheel', wheelLines(-10)); - expect(flushed(ws)).toEqual([]); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); mount.fire('wheel', wheelLines(-10)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); @@ -994,14 +994,14 @@ describe('rows the tile pushed above the screen itself are not history', () => { } expect(flushed(ws)).toEqual([]); - // Back on the live screen, only travel made there counts: two quarter-screen - // wheels are one PageUp, with nothing carried over from the wheels xterm had - // (the gate sits before the pending travel is touched). + // Back on the live screen, only travel made there counts: the first quarter-screen wheel pages at + // once and the second repays it, with nothing carried over from the wheels xterm had (the gate sits + // before the pending travel is touched). term.buffer.active.viewportY = baseY; const first = wheelLines(-6); mount.fire('wheel', first); expect(first.preventDefault).toHaveBeenCalled(); - expect(flushed(ws)).toEqual([]); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); mount.fire('wheel', wheelLines(-6)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); });