From 8cb3441dd9f8d7f50877a80697fe162a01bbd839 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 18 Oct 2026 11:23:05 +0400 Subject: [PATCH] fix(terminal): address scrolling review and preserve upstream speed policies Fix transport-timer regression coverage, zero-delta tile handling and capability documentation. Record isolated Codex 0.163.0 transcript and approval evidence; defer event/batch rate and coarse paging policy changes. Refs: https://github.com/johoja12/Codeman/issues/7 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 8 ++- src/config/cli-registry/schema.ts | 2 +- src/config/cli-registry/stock.ts | 4 +- src/config/cli-registry/types.ts | 8 +-- src/web/public/terminal-tile.js | 9 +-- src/web/public/terminal-ui.js | 88 ++++++----------------- test/cli-registry-schema.test.ts | 9 +++ test/terminal-scroll-routing.test.ts | 103 ++++++++++++++++++--------- test/terminal-tile-scroll.test.ts | 38 ++++++---- test/terminal-touch-tap.test.ts | 26 +++---- 11 files changed, 158 insertions(+), 139 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index adb38400..51fa95e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,7 +204,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 gets 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 to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `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 gets 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 or tracked Codex**. 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. ⚠️ `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) **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 1836d1bf..fe8f5167 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,11 +225,15 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` ⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. -**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. +**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187 or tracked Codex** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'mouse-tracking'`; Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. + +Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codex has no Claude hook alert, so a `tabAlerts === 'action'` test cannot detect this prompt and is not a native-wheel safety gate. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. + +Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. Fractional deltas accumulate within one recent direction, rather than rounding every tiny event up. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. **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 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). 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). `_wheelScrollLines()` 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 is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. 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 Claude's fullscreen renderer 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). ⚠️ 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/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 8bdcc281..82d7a614 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -295,7 +295,7 @@ const capabilitiesSchema = z altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']), echo: echoSchema, wheelForward: z - .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) + .object({ mode: z.enum(['never', 'version-gated', 'mouse-tracking']), minVersion: z.string().max(20).optional() }) .strict(), // Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for // the CLI to page its own transcript. Distinct from wheelForward (SGR reports). diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 3075e36f..dc1b1c21 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -733,8 +733,8 @@ const CODEX: CliEntry = { transcript: 'codex-rollout', altScreen: 'strip-full', echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' }, - wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward - // ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local, + wheelForward: { mode: 'mouse-tracking' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking + // Without observed mouse tracking, retain PageUp/PageDown fallback. Measured on 0.157.1 (local, // alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp // moved the visible transcript while SGR wheel reports and local scrolling did nothing. // This is the page-key fallback for a hollow pane, not wheel forwarding. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 51eab76e..dcbc4897 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -513,8 +513,8 @@ export interface CliCapabilities { /** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */ predictProfile?: string; }; - /** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */ - wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string }; + /** Wheel forwarding policy. Every enabled policy also requires observed CLI mouse tracking. */ + wheelForward: { mode: 'never' | 'version-gated' | 'mouse-tracking'; minVersion?: string }; /** * The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over * a pane with no local history can be turned into those keys instead of doing nothing @@ -523,8 +523,8 @@ export interface CliCapabilities { * * ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends * plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`). - * A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its - * `wheelForward` stays 'never') yet pages on PageUp/PageDown. + * A CLI can ignore one and honour the other: Codex without observed mouse tracking + * uses this fallback, while fullscreen Codex 0.163.0 accepts SGR wheel reports. * * Absent means no paging, so a CLI nobody has measured keeps plain local scrolling. */ diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 8002e44a..33d3a903 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -51,7 +51,7 @@ * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelTicksForGesture/coalesceScrollBytes/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts) * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -1101,7 +1101,8 @@ // SGR coordinates address the live screen, so a report from a scrolled-up // viewport would hit-test another row: snap home first (_forwardScrollToApp). if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.(); - const lines = input.wheelDeltaNativeLines(ev, this.terminal.rows); + const lines = input.wheelDeltaLines(ev, this.terminal.rows); + if (!lines) return true; if (this._wheelTickGesture?.dir && this._wheelTickGesture.dir !== Math.sign(lines)) this._scrollBytes = ''; const step = input.wheelTicksForGesture(this._wheelTickGesture, lines, performance.now()); this._wheelTickGesture = step.state; @@ -1215,8 +1216,8 @@ } // Coalesces the scroll bytes (SGR wheel reports and page keys alike) into - // one send per 40 ms, bounded by report count so a fling cannot build a backlog - // that keeps scrolling after it stops. A narrow + // one send per 40 ms after the leading native report, preserving upstream + // report density and cancelling only unsent movement on reversal. A narrow // twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue // (terminal-ui.js; keep the two in step), which flushes to the active // session only. Sent ephemeral (no seq, never persisted) to THIS pane's diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 3e78470e..632c1a12 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -98,6 +98,10 @@ 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. + const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2; // 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. @@ -117,25 +121,7 @@ : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) } - // A wheel event is at most one native tick: a common mouse notch is - // deltaY=100px (or Firefox's 3 lines), not four/three separate notches. - // Small touchpad events retain their fractional distance and accumulate in - // wheelTicksForGesture. Touch drags bypass this wheel-only normalization. - function wheelDeltaNativeLines(ev, rows) { - return Math.max(-1, Math.min(1, wheelDeltaLines(ev, rows))); - } - - // The same travel rounded to whole lines for the SGR wheel reports: a pure - // horizontal swipe is 0 (nothing to send), and anything else moves at least - // one line, so the small pixel deltas of a precision touchpad still scroll. - // The body of the primary pane's _wheelScrollLines. - function wheelDeltaWholeLines(ev, rows) { - const lines = wheelDeltaLines(ev, rows); - if (!lines) return 0; - return Math.round(lines) || (lines > 0 ? 1 : -1); - } - - // Ticks one gesture batch may report: Claude applies its own scroll-speed + // Ticks one input event may report: Claude applies its own scroll-speed // multiplier and acceleration on top, so a bigger batch only overshoots. const SGR_WHEEL_MAX_TICKS = 5; @@ -167,17 +153,16 @@ }; } - // Only unsent scroll input can be cancelled. Keep at most five SGR reports - // or three page keys per flush, and replace an obsolete direction immediately. - // Local and remote transports share the same bounded gesture queue. + // Only unsent scroll input can be cancelled. Preserve upstream report density + // within each batch, and replace an obsolete direction immediately. + // Local and remote transports share the same direction-aware gesture queue. function coalesceScrollBytes(queued, data) { const direction = (bytes) => bytes?.match(/\x1b\[<(64|65);|\x1b\[(5|6)~/)?.[0]; const previous = direction(queued); const next = direction(data); const combined = previous && previous === next ? queued + data : data; const reports = combined.match(/\x1b\[<(?:64|65);\d+;\d+M|\x1b\[[56]~/g) || []; - const limit = combined.startsWith('\x1b[<') ? SGR_WHEEL_MAX_TICKS : PAGE_KEY_MAX_PER_BATCH; - return reports.slice(-limit).join(''); + return reports.join(''); } // Rows of travel per page key for a terminal `rows` tall (half a screen). @@ -214,15 +199,15 @@ // 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 ordinary wheel/touch opening event (at least - // PAGE_KEY_MIN_START_ROWS, under one page of travel) pages AT ONCE, + // - a new gesture's trackpad-sized opening event (at least + // PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) 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; - // - larger openings and subsequent events accumulate through + // - everything else, a wheel notch included, accumulates through // pageKeysForTravel, carried across notches. // Returns { state, keys } with keys '' when nothing is due. function pageKeysForGesture(state, ev, lines, rows, now) { @@ -248,14 +233,13 @@ gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS); } if (gestureStart || reversed) { - // Travel and debt belong to their direction; never make a reversal pay - // off movement accumulated going the other way. - if (prepaid || reversed) pending = 0; + // A pre-paid debt belongs to its gesture and its direction. + if (prepaid) pending = 0; prepaid = false; } if (gestureStart) { const size = Math.abs(lines); - if (size >= PAGE_KEY_MIN_START_ROWS && size < perPage) { + if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) { // 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 }, @@ -422,8 +406,6 @@ PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, wheelDeltaLines, - wheelDeltaWholeLines, - wheelDeltaNativeLines, SGR_WHEEL_MAX_TICKS, sgrWheelReports, wheelTicksForGesture, @@ -1149,7 +1131,7 @@ Object.assign(CodemanApp.prototype, { if (ev.ctrlKey || (!ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY))) return; if (this._shouldForwardWheelToApp(ev)) { this._logScrollRouting('forward-sgr'); - this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollNativeLines(ev)); + this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev)); return; } // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits @@ -5817,32 +5799,6 @@ Object.assign(CodemanApp.prototype, { return true; }, - // Forward only to CLIs that explicitly enable mouse tracking. Fullscreen - // Codex 0.163.0 was verified against canonical tmux captures: one SGR report - // scrolls one transcript row. Inline Codex (tracking off) must retain local - // history/fallback behavior; unconditional forwarding caused the old #227 bug. - // Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger - // scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and - // Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns - // false on Shift). So under Shift, read whichever axis dominates — otherwise - // deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through - // history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are - // unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving. - // - // `deltaMode` says what UNIT the delta is in, and ignoring it made every - // non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1) - // with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to - // the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In - // Claude mode the same value also capped the forwarded SGR report at one tick. - _wheelScrollLines(ev) { - // Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines). - return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows); - }, - - _wheelScrollNativeLines(ev) { - return window.CodemanTerminalInput.wheelDeltaNativeLines(ev, this.terminal?.rows); - }, - /** Unrounded variant for the smooth local-scroll path, which accumulates * sub-line fractions across events instead of forcing every tiny trackpad * delta to a whole ±1 line. Same unit handling and Shift-axis trap. The @@ -5877,7 +5833,6 @@ Object.assign(CodemanApp.prototype, { const session = this.sessions?.get(target.sessionId || this.activeSessionId); const sessionMode = session?.mode || 'claude'; if (sessionMode !== 'claude' && sessionMode !== 'codex') return false; - if (sessionMode === 'codex' && this.tabAlerts?.get(target.sessionId || this.activeSessionId) === 'action') return false; if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; // Only while Claude is actually listening for the mouse. In its default // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the @@ -5934,8 +5889,8 @@ Object.assign(CodemanApp.prototype, { * Coalesces scroll input into 40ms batches after the first native report (SGR * wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a * tmux send-keys server-side, so per-event writes would spawn a process storm - * on a single flick; report counts are bounded so a wild scroll can't build a - * backlog that keeps scrolling after the finger stops. + * on a single flick. Per-event report limits preserve the upstream wheel/touch + * scaling; a change of direction discards only movement that is still unsent. * * A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes, * terminal-tile.js: same 40ms window and report limits) because this queue @@ -6023,7 +5978,8 @@ Object.assign(CodemanApp.prototype, { * scrollback is never touched. Shift is excluded on purpose: it is the explicit * "give me local scrollback" gesture and must keep that meaning. * - * The FIRST event of a deliberate sub-page gesture pages at once. Waiting for half a screen of + * The FIRST event of a trackpad-sized gesture (opening under + * PAGE_KEY_IMMEDIATE_MAX_ROWS) 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 @@ -6031,8 +5987,8 @@ Object.assign(CodemanApp.prototype, { * (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; * one contact is never split by time, and a quick reversal never pre-pays. A - * wheel opening below the page threshold also pages immediately. Subsequent events - * repay the skipped travel rather than paging once per notch. + * larger opening event is a wheel notch and accumulates as before, so slow + * notches still page once per `perPage` rows. * * 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/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 156d315b..e6dae7d5 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -435,3 +435,12 @@ describe('capability shapes', () => { expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']); }); }); + +describe('observed mouse-tracking wheel policy', () => { + it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => { + const codex = STOCK_CLIS.find((e) => e.id === 'codex')!; + expect(codex.capabilities.wheelForward).toEqual({ mode: 'mouse-tracking' }); + expect(CliEntrySchema.safeParse(codex).success).toBe(true); + expect(codex.capabilities.transcriptPageKeys).toBe(true); + }); +}); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index bb8efdae..786d4aae 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -36,7 +36,18 @@ function loadTerminalUiHarness( // terminal-ui.js hangs CodemanTerminalInput off window; tests read it there. const windowRef: Record<string, any> = { ...windowGlobals }; // Mutable so a test can move time (gesture gaps); defaults to a frozen clock. - const clock = { now: () => 1_000 }; + const timers = new Map<number, { due: number; fn: () => void }>(); + let timerId = 0; + const clock = { + now: () => 1_000, + flushTimers: () => { + for (const [id, timer] of timers) + if (timer.due <= clock.now()) { + timers.delete(id); + timer.fn(); + } + }, + }; const context = vm.createContext({ window: windowRef, CodemanApp, @@ -44,8 +55,12 @@ function loadTerminalUiHarness( _crashDiag: { log: vi.fn() }, performance: clock, requestAnimationFrame: (_fn: () => void) => 1, - setTimeout: (_fn: () => void) => 1, - clearTimeout: vi.fn(), + setTimeout: (fn: () => void, ms = 0) => { + const id = ++timerId; + timers.set(id, { due: clock.now() + ms, fn }); + return id; + }, + clearTimeout: (id: number) => timers.delete(id), Blob: function Blob() {}, URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} }, Worker: function Worker(this: any) { @@ -206,24 +221,30 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 app._maybePageCliTranscript({ shiftKey: false }, -1); // first event: one PageUp app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal inside the gap: accumulates, no page now += 200; + clock.flushTimers(); app._maybePageCliTranscript({ shiftKey: false }, -1); // a reversal after the gap: a PageUp at once now += 1_100; + clock.flushTimers(); app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: a PageDown at once app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[6~' }]); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[5~' }, + { id: 'sess-1', data: '\x1b[6~' }, + ]); }); - it('answers the first wheel notch and amortizes the page over subsequent notches', () => { - // The first 100px notch responds immediately. Later notches repay the - // pre-paid page, including when they arrive past the normal gesture gap. - for (const gapMs of [250, 40]) { + it('accumulates coarse wheel notches across pauses instead of prepaying each opening', () => { + // Coarse events accumulate as on master, even across pauses; they do not + // pre-pay a full page each time a new gesture starts. + for (const gapMs of [250, 40, 1200]) { const { app, sent, clock } = hollowApp(); let now = 1_000; clock.now = () => now; for (let i = 0; i < 5; i++) { expect(app._maybePageCliTranscript({ shiftKey: false, deltaY: -100 }, -4)).toBe(true); now += gapMs; + clock.flushTimers(); } app._flushWheelSgrQueue(); expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); @@ -446,7 +467,14 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter h.app._flushWheelSgrQueue(); return h.sent.map((s) => s.data).join(''); }; - return { ...h, keys, advance: (ms: number) => (now += ms) }; + return { + ...h, + keys, + advance: (ms: number) => { + now += ms; + h.clock.flushTimers(); + }, + }; } const count = (keys: string, key: string) => keys.split(key).length - 1; @@ -533,7 +561,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter } // The opening event is a fresh gesture and pages; no reversal inside the // gap pre-pays, so the wobble sends nothing after it. - expect(p.keys()).toBe(''); // obsolete queued direction cancelled before its flush + expect(p.keys()).toBe(''); // the opening report was cancelled before its 40ms flush }); it('still pages a deliberate reversal at once after a pause', () => { @@ -541,7 +569,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1); p.advance(200); p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1); - expect(p.keys()).toBe(DOWN); + expect(p.keys()).toBe(UP + DOWN); }); it('does not let a reversal inherit the other direction’s debt', () => { @@ -863,28 +891,43 @@ describe('consistent scroll gesture adapters', () => { expect(app._wheelSgrQueue || '').toBe(''); }); - it('answers the first ordinary Codex wheel event, without paging every event', () => { + it('preserves accumulated coarse Codex wheel fallback travel', () => { const { app, sent } = hollowApp({ mode: 'codex', rows: 43 }); app._maybePageCliTranscript({ deltaY: -100 }, -4); app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + expect(sent).toEqual([]); for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4); app._flushWheelSgrQueue(); expect(sent).toHaveLength(1); }); - it('bounds a sustained burst and cancels obsolete direction before transport', () => { + it('preserves sustained report density and cancels obsolete direction before transport', () => { const { coalesceScrollBytes } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; const up = '\x1b[<64;1;1M'; const down = '\x1b[<65;1;1M'; let queued = ''; for (let i = 0; i < 100; i++) queued = coalesceScrollBytes(queued, up); - expect(queued).toBe(up.repeat(5)); + expect(queued).toBe(up.repeat(100)); expect(coalesceScrollBytes(queued, down)).toBe(down); - expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(3)); + expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(4)); expect(coalesceScrollBytes('\x1b[5~', '\x1b[6~')).toBe('\x1b[6~'); }); + it('preserves all 120 reports of a 30-frame touch fling with real batch deadlines', () => { + const { app, sent, clock } = hollowApp(); + let now = 1000; + clock.now = () => now; + app._clientPointToCell = () => ({ col: 1, row: 1 }); + for (let frame = 0; frame < 30; frame++) { + app._sendSyntheticSgrWheel(0, 0, -4); + now += 16; + clock.flushTimers(); + } + now += 40; + clock.flushTimers(); + expect(sent.map((s) => s.data).join('')).toBe('\x1b[<64;1;1M'.repeat(120)); + }); + it('does not deliver a queued wheel batch to a newly selected session', () => { const { app, sent } = hollowApp(); app._queueScrollBytes('\x1b[5~'); @@ -939,16 +982,6 @@ describe('native Codex scrolling instead of an initial page jump', () => { } }); - it('preserves the Codex dialog guard for primary and tiled routing', () => { - const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true }); - app.sessions.set('tile', { mode: 'codex', cliMouseTracking: true }); - app.tabAlerts = new Map([['sess-1', 'action']]); - expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); - expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).toBe(true); - app.tabAlerts.set('tile', 'action'); - expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).toBe(false); - }); - it('keeps local-history opt-out and native xterm mouse routing', () => { const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true }); app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); @@ -959,9 +992,9 @@ describe('native Codex scrolling instead of an initial page jump', () => { }); }); -describe('one native tick per wheel event', () => { +describe('native wheel travel preserves upstream units', () => { it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => { - const { wheelDeltaNativeLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + const { wheelDeltaLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; for (const [deltaY, deltaMode] of [ [100, 0], [120, 0], @@ -969,21 +1002,25 @@ describe('one native tick per wheel event', () => { [3, 1], [1, 2], ]) { - expect(wheelDeltaNativeLines({ deltaY, deltaMode }, 43)).toBe(1); - expect(wheelDeltaNativeLines({ deltaY: -deltaY, deltaMode }, 43)).toBe(-1); + expect(wheelDeltaLines({ deltaY, deltaMode }, 43)).toBe( + deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43 + ); + expect(wheelDeltaLines({ deltaY: -deltaY, deltaMode }, 43)).toBe( + -(deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43) + ); } }); it('keeps fractional touchpad distance and primary/tile normalization identical', () => { const { app, windowRef } = hollowApp({ mode: 'codex', cliMouseTracking: true }); - const { wheelDeltaNativeLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput; + const { wheelDeltaLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput; let state; let ticks = 0; for (let i = 0; i < 10; i++) { const ev = { deltaY: -3, deltaMode: 0 }; - const lines = wheelDeltaNativeLines(ev, 43); + const lines = wheelDeltaLines(ev, 43); expect(lines).toBe(-0.12); - expect(app._wheelScrollNativeLines(ev)).toBe(lines); + expect(app._wheelScrollLinesFloat(ev)).toBe(lines); const step = wheelTicksForGesture(state, lines, i * 10); state = step.state; ticks += step.ticks; diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index aca5d7e2..e403132a 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -319,11 +319,11 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(up.preventDefault).toHaveBeenCalled(); expect(up.stopPropagation).toHaveBeenCalled(); - expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]); // first report never waits for a timer - expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); // no seq: never persisted + expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(3) }]); // first report never waits for a timer + expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted mount.fire('wheel', wheelLines(2, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) }); }); it('uses fractional native scrolling for a tracked Codex tile, never PageUp', async () => { @@ -334,14 +334,14 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); }); - it('normalizes a pixel wheel event to one native tick, including accelerated deltas', async () => { + it('preserves coarse pixel wheel travel and caps individual accelerated events', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); mount.fire('wheel', wheel(-100, AT)); - expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]); mount.fire('wheel', wheelLines(-1000, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) }); }); it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => { @@ -366,7 +366,18 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' mount.fire('wheel', wheelLines(-2, AT)); mount.fire('wheel', wheelLines(-2, AT)); - expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]); + expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(2) }]); + expect(flushed(ws)).toEqual([ + { t: 'i', d: UP.repeat(2) }, + { t: 'i', d: UP.repeat(4) }, + ]); + }); + + it('does not discard queued reports when a zero-delta wheel arrives', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); + mount.fire('wheel', wheelLines(-1, AT)); + mount.fire('wheel', wheelLines(-2, AT)); + mount.fire('wheel', wheel(0, AT)); expect(flushed(ws)).toEqual([ { t: 'i', d: UP }, { t: 'i', d: UP.repeat(2) }, @@ -451,14 +462,14 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' const primary = makeApp({ other: FULLSCREEN }, 'other') as App & { terminal: unknown; _sendSyntheticSgrWheel(x: number, y: number, lines: number): void; - _wheelScrollNativeLines(ev: unknown): number; + _wheelScrollLinesFloat(ev: unknown): number; _flushWheelSgrQueue(): void; }; const sent: string[] = []; primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data); primary.terminal = new FakeTerminal({}); for (const ev of sequence) { - primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollNativeLines(ev)); + primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLinesFloat(ev)); primary._flushWheelSgrQueue(); } @@ -546,11 +557,11 @@ describe('page-key travel, cap and coalescing', () => { FakeFit.proposed = { cols: 80, rows: 36 }; }); - it('answers sub-page travel immediately and repays it on the next event', async () => { + it('accumulates coarse sub-page travel before sending one page', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); mount.fire('wheel', wheelLines(-10)); - expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + expect(flushed(ws)).toEqual([]); mount.fire('wheel', wheelLines(-10)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); @@ -614,6 +625,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper clock.now = () => now; return (ms: number) => { now += ms; + vi.advanceTimersByTime(ms); }; } @@ -750,7 +762,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper .map((f) => f.d) .join(''); // One page for the slow ticks, one for the wobble's opening event, nothing more. - expect(tileBytes).toBe(''); // rapid reversals cancel unsent movement + expect(tileBytes).toBe(PAGE_UP); // the first slow tick flushed before the later wobble const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & { terminal: unknown; @@ -909,7 +921,7 @@ describe('rows the tile pushed above the screen itself are not history', () => { const first = wheelLines(-6); mount.fire('wheel', first); expect(first.preventDefault).toHaveBeenCalled(); - expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + expect(flushed(ws)).toEqual([]); mount.fire('wheel', wheelLines(-6)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); }); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 66a25cab..3cf86a49 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -738,17 +738,17 @@ describe('terminal touch tap mouse guard', () => { app.terminal = { rows: 40 }; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad): ~110px per notch. - expect(app._wheelScrollLines({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4); + expect(app._wheelScrollLinesFloat({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4.4); // DOM_DELTA_LINE (Firefox mouse wheel): deltaY is already lines. Read as // pixels this rounded to 0 and fell through to the ±1 fallback. - expect(app._wheelScrollLines({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3); - expect(app._wheelScrollLines({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3); + expect(app._wheelScrollLinesFloat({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3); + expect(app._wheelScrollLinesFloat({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3); // DOM_DELTA_PAGE: one page is one screenful. - expect(app._wheelScrollLines({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40); + expect(app._wheelScrollLinesFloat({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40); // A pure horizontal swipe must not fall through to a phantom -1. - expect(app._wheelScrollLines({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0); + expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0); // Shift + macOS trackpad reports the magnitude on deltaX (issue #154). - expect(app._wheelScrollLines({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4); + expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4); }); it('wheel: gates claude forwarding on CLI version 2.1.187+ (unknown or older stays local)', () => { @@ -838,19 +838,19 @@ describe('terminal touch tap mouse guard', () => { const { app } = loadTerminalUiHarness(); // Plain vertical wheel: unchanged, driven by deltaY. - expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4); - expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2); + expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4); + expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2); // Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude. // Old code collapsed this to a fixed -1; now it tracks the horizontal delta. - expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up - expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down // Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored. - expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4); + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4); - // Sub-25px delta still nudges one line in the gesture's direction. - expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1); + // Sub-25px deltas retain their fractional travel. + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-0.2); }); it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => {