diff --git a/CLAUDE.md b/CLAUDE.md index 1d0bf0bc..f4c001c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 and copilot ONLY, and only while they have mouse tracking on** (copilot has no version gate; with a stale flag it falls back to PageUp/PageDown through its `transcriptPageKeys` capability) (`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). Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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/config/test-suites.ts b/config/test-suites.ts index f2925ae7..ae866007 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -37,6 +37,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/codex-predictive-echo.test.ts', // also needs a real codex binary 'test/split-pane-terminal.browser.test.ts', 'test/terminal-tile-scroll.browser.test.ts', + 'test/codex-wheel-routing.browser.test.ts', 'test/terminal-touch-paging.browser.test.ts', 'test/shift-enter-keypress.browser.test.ts', 'test/key-tester.browser.test.ts', diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index db83fef5..911f152e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -229,11 +229,17 @@ 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 and copilot, each with mouse tracking on, forward 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. **Copilot is the second forwarder** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. ⚠️ "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, tracked Codex or tracked Copilot** (`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 a 0.162.0 measurement (2026-10) 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: 'always'` (observed mouse tracking is still required); 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. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. 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. -**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). 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. `_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`. +**Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. -**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`. +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. Codeman does alert on this prompt: `src/web/screen-blocked.ts` reads Codex dialogs off the pane (`capabilities.workDetect.blockedLine`) and broadcasts `hook:permission_prompt`, which `_onHookPermissionPrompt` (settings-ui.js) records and `updateTabAlertFromHooks` (app.js) turns into the `'action'` tab alert, and both page-key fallbacks check it. The SGR wheel path deliberately does not consult that alert, because this measurement showed wheel reports leave the approval selection unchanged. 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. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. 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 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. + +**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still pages the CLI transcript. Tracked fullscreen Codex 0.163.0 forwards SGR reports first, as measured above. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to tracked Claude/Codex fullscreen renderers as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. **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/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index f5968957..6ff5a8c0 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -146,9 +146,9 @@ Two behaviours that are deliberate and worth knowing: a `/` opens a live-filtering picker, arrows edit server-side state. Buffering keystrokes until Enter starved it, so Codex paints each keystroke at the predicted cell while the bytes on the wire stay byte-identical to what you typed. -- **The wheel is not forwarded** into its transcript. Codex ignores the mouse reports - Codeman would send, so forwarding produced a dead wheel. Scrolling in a Codex session is - local scrollback. +- **Wheel forwarding follows mouse tracking.** Codex with mouse tracking on gets the wheel + forwarded like fullscreen Claude; otherwise it pages its own transcript when local history + is empty, or scrolls locally. - **Work detection is Codex's own.** Codex declares its `›` composer glyph and its `esc to interrupt` working line, so it gets the same screen-checked idle detection Claude does; before 1.26.1 every Codex session reported idle for its whole life. Codex diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 8d1f47e3..5583d3f6 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -235,10 +235,12 @@ Worth knowing: - **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal. - Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is + Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise + it pages its transcript or scrolls locally. Claude's default inline view keeps its history + in the terminal and scrolls locally. `Shift+Wheel` is always local scrollback. OpenCode's wheel and swipes page its own conversation - (PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen - and leave the terminal no history of its own (an older Codex that draws inline keeps + (PageUp/PageDown), as do untracked fullscreen Codex versions that leave the terminal + no history of its own (an older Codex that draws inline keeps its history in the terminal and scrolls locally). The first small movement of a gesture pages at once, then one page per half screen of travel; one finger on the screen is one gesture however slowly it moves. In a grid tile or the split view's diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 114ca37d..cd191493 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -178,8 +178,8 @@ Things to try: ### The wheel does nothing in a Codex session -Codex ignores the mouse reports that forwarding would send, so Codeman does not forward -there. Scrolling is local, and `Shift+Wheel` behaves the same way. +Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise +it pages its transcript or scrolls locally, while `Shift+Wheel` always uses local scrollback. ### Selected text is invisible on a light skin diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index ce5c14e3..2c1a6830 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -768,8 +768,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: 'always' }, // 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 c5e7b8f0..81fd48b4 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -557,8 +557,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 5cb99dc7..1a0c7058 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -18,13 +18,14 @@ * What it does carry over from the primary pane, through the primary pane's * own code aimed at THIS pane (its terminal, its session, never the active * one): - * - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen - * renderer scrolls its own transcript on SGR wheel reports, while this - * xterm holds only replayed repaint frames, so the wheel goes to the CLI - * as reports at the pointer's cell in this pane, through the primary - * pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel - * scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the - * primary pane does, since xterm turns it into a horizontal no-op. + * - SGR wheel forwarding (_maybeForwardWheelToCli): a tracked CLI + * (fullscreen Claude, tracked Codex) scrolls its own transcript on SGR + * wheel reports, while this xterm holds only replayed repaint frames, so + * the wheel goes to the CLI as reports at the pointer's cell in this pane, + * through the primary pane's forwarding gate and its encoding + * (sgrWheelReports). Shift+wheel scrolls the local scrollback itself + * (_maybeScrollLocalOnShift), as the primary pane does, since xterm turns + * it into a horizontal no-op. * - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex * on the alternate screen, Claude's repaint mode) leaves the xterm no * scrollback, so the wheel pages the CLI's own transcript with @@ -51,7 +52,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 */ @@ -229,6 +230,7 @@ } async connect() { + this._wheelNativeStarted = false; const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10); this.terminal = new Terminal({ theme: { ...global.codemanCurrentXtermTheme() }, @@ -1051,7 +1053,7 @@ // Capture phase, because xterm's own wheel handler stopPropagation()s every // event it consumes, so a bubbling listener here would never see the wheel // while the pane still has scrollback to scroll. Not passive: the three - // routes this pane takes over, forwarding the wheel to Claude's fullscreen + // routes this pane takes over, forwarding the wheel to a tracked CLI's // renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI // transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback // (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus @@ -1073,11 +1075,11 @@ // SGR wheel forwarding, the twin of the primary pane's capture-phase wheel // handler and _forwardScrollToApp (terminal-ui.js; keep them in step). - // Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is - // on, cliMouseTracking) scrolls its own transcript on SGR wheel reports, + // Fullscreen Claude (2.1.187+) and measured tracked Codex scroll their own + // transcripts on SGR wheel reports while cliMouseTracking is on, // while this xterm holds only Codeman's replayed repaint frames (tmux keeps // no history for such a pane). Left to xterm, the wheel dragged those stale - // frames, Claude's pinned input box with them, up the tile, or scrolled + // frames, including the CLI's pinned input box, up the tile, or scrolled // nothing at all. The 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 reports go to this @@ -1090,19 +1092,24 @@ if (this._destroyed || !this.terminal || !ev) return false; const app = global.app; const input = global.CodemanTerminalInput; - if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false; + if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelTicksForGesture) return false; // xterm's own encoder forwards the wheel while the CLI's tracking reaches // it, and its alt-scroll owns the alternate buffer, as in the primary pane. const tracking = this.terminal.modes?.mouseTrackingMode; if (tracking && tracking !== 'none') return false; if (this.terminal.buffer?.active?.type === 'alternate') return false; if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false; + if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true; // 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.wheelDeltaWholeLines(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; const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal); - const bytes = input.sgrWheelReports(lines, pos); + const bytes = input.sgrWheelReports(step.ticks, pos); if (bytes) this._queueScrollBytes(bytes); return true; } @@ -1180,9 +1187,9 @@ const tracking = this.terminal.modes?.mouseTrackingMode; if (tracking && tracking !== 'none') return false; const target = { terminal: this.terminal, sessionId: this.sessionId }; - // A wheel for Claude's fullscreen renderer was forwarded as SGR reports - // before this ran (_maybeForwardWheelToCli); the gate is repeated so a - // forwarding session is never paged. + // A wheel for any tracked CLI (a session the forwarding gate accepts) was + // forwarded as SGR reports before this ran (_maybeForwardWheelToCli); the + // gate is repeated so a forwarding session is never paged. if (app._shouldForwardWheelToApp?.(ev, target)) return false; if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false; // Only from the live screen. The one gate the primary pane never needs: a @@ -1202,6 +1209,8 @@ // session, which is usually not the active one: a page key would move its // selector. Consumed, so xterm does not scroll the rows under it either. if (app.tabAlerts?.get(this.sessionId) === 'action') return true; + if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true; + if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._scrollBytes = ''; const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now()); this._pageKeyGesture = step.state; if (step.keys) this._queueScrollBytes(step.keys); @@ -1209,16 +1218,26 @@ } // Coalesces the scroll bytes (SGR wheel reports and page keys alike) into - // one send per 40 ms, bounded at 512 bytes 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 // session, over this pane's socket while it is open. _queueScrollBytes(data) { if (!data || this._destroyed) return; - if (this._scrollBytes.length > 512) return; - this._scrollBytes += data; + this._scrollBytes = global.CodemanTerminalInput.coalesceScrollBytes(this._scrollBytes, data); + // Tiles have a fixed session: the leading report is once per connect(), + // unlike the primary pane, which resets it on every tab selection. + if (data.startsWith('\x1b[<') && !this._wheelNativeStarted) { + this._wheelNativeStarted = true; + if (this._scrollFlushTimer) clearTimeout(this._scrollFlushTimer); + this._scrollFlushTimer = null; + const bytes = this._scrollBytes; + this._scrollBytes = ''; + global.app?._sendInputEphemeral?.(this.sessionId, bytes); + return; + } if (this._scrollFlushTimer) return; this._scrollFlushTimer = setTimeout(() => { this._scrollFlushTimer = null; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 7643ba1e..c4a7e846 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -121,17 +121,7 @@ : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) } - // 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; @@ -147,6 +137,43 @@ return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); } + // A deliberate opening event preserves upstream rounding and pre-pays at + // least one tick, so isolated clicks retain their travel. The signed remainder + // is repaid by subsequent same-direction travel: ten 0.1-line events still total one tick. Debt and + // fractions expire with a 150ms gesture gap or reversal; a reversal within + // the gesture accumulates without another pre-payment (like page gestures). + function wheelTicksForGesture(state, lines, now) { + if (!Number.isFinite(lines) || !lines) return { state: state || {}, ticks: 0 }; + const dir = Math.sign(lines); + const fresh = !state || typeof state.lastAt !== 'number' || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS; + const reversed = state?.dir && state.dir !== dir; + if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS) { + const rounded = dir * Math.max(1, Math.round(Math.abs(lines))); + return { + state: { dir, lastAt: now, pending: lines - rounded }, + ticks: dir * Math.min(Math.abs(rounded), SGR_WHEEL_MAX_TICKS), + }; + } + const total = (fresh || reversed ? 0 : state.pending || 0) + lines; + const whole = Math.trunc(total + Math.sign(total) * 1e-9); + return { + state: { dir, lastAt: now, pending: Math.abs(total - whole) < 1e-9 ? 0 : total - whole }, + ticks: whole === 0 ? 0 : Math.sign(whole) * Math.min(Math.abs(whole), SGR_WHEEL_MAX_TICKS), + }; + } + + // 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) || []; + return reports.join(''); + } + // Rows of travel per page key for a terminal `rows` tall (half a screen). function pageKeyRows(rows) { return Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION)); @@ -388,9 +415,10 @@ PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, wheelDeltaLines, - wheelDeltaWholeLines, SGR_WHEEL_MAX_TICKS, sgrWheelReports, + wheelTicksForGesture, + coalesceScrollBytes, pageKeysForTravel, pageKeysForGesture, PAGE_KEY_GESTURE_GAP_MS, @@ -1098,7 +1126,7 @@ Object.assign(CodemanApp.prototype, { // and stops propagation, keeping xterm's scroller out of it entirely. // Local scrolling goes through terminal.scrollLines() — buffer-level, so // it keeps working after resets — with our own deltaMode normalization - // (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still + // (_wheelScrollLinesFloat) covering Firefox's line-unit wheels. Two cases still // belong to xterm and are passed through untouched: // - mouseTrackingMode active: xterm's own encoder forwards the wheel to // the PTY (htop/vim with mouse on in a shell pane); @@ -1114,8 +1142,9 @@ Object.assign(CodemanApp.prototype, { ev.preventDefault(); ev.stopPropagation(); if (this._shouldForwardWheelToApp(ev)) { + if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return; this._logScrollRouting('forward-sgr'); - this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev)); + this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev)); return; } // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits @@ -5807,41 +5836,6 @@ Object.assign(CodemanApp.prototype, { return true; }, - // Wheel forwarding gate for the container wheel handler: no Shift override, - // xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to - // scroll its transcript on SGR wheel reports, which today is claude 2.1.187+ - // with mouse tracking on (fullscreen) and nothing else (older Claude Code - // captures wheel as select-menu option navigation, an unknown version is - // treated as older, and inline Claude ignores it). Gemini and codex are - // strip modes too but keep the local wheel — taps/clicks are still forwarded - // for them (harmless no-ops at worst). - // - // Codex USED to forward here and was the #227 regression (DodgyBadger, Codex - // latest / Chrome / Win11: dead wheel in codex, working scrollbar drag). - // Measured on 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 — it runs an inline viewport (`alternate_on=0`) and pushes - // its transcript into the terminal's own scrollback (tmux `history_size` - // grows), so there is no in-app pager to drive and local scrollback IS the - // codex transcript. Forwarding therefore swallowed every tick. - // 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); - }, - /** 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 @@ -5899,11 +5893,11 @@ Object.assign(CodemanApp.prototype, { // same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a // version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's. if (sessionMode === 'copilot') return session?.cliMouseTracking === true; - if (sessionMode !== 'claude') return false; - if (!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 - // transcript lives in real scrollback, like codex, and SGR wheel reports are + if (sessionMode !== 'claude' && sessionMode !== 'codex') return false; + if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; + // Only while Claude or Codex is actually listening for the mouse. Claude's + // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0), like + // older inline Codex, keeps its transcript in real scrollback; SGR reports are // ignored, so forwarding made every swipe and wheel tick dead. Fullscreen // (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json) // turns on alt-screen + mode 1003/1006, which the server records as @@ -5934,34 +5928,54 @@ Object.assign(CodemanApp.prototype, { // trackpad emits dozens of wheel events per second and each send becomes a // tmux send-keys on the server — unbatched, a single flick would spawn a // process storm. Per-event tick count is capped (Claude applies its own - // scroll-speed multiplier and acceleration on top), and the queue is bounded - // so a wild scroll can't build a backlog that keeps scrolling after the finger - // stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue. + // scroll-speed multiplier and acceleration on top). Batching is bounded by + // the 40ms window and the per-event cap, not an aggregate byte/tick limit. + // Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue. // The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a // TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli). _sendSyntheticSgrWheel(clientX, clientY, lines) { if (!this.activeSessionId || !lines) return; const pos = this._clientPointToCell(clientX, clientY); if (!pos) return; - this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos)); + const input = window.CodemanTerminalInput; + const state = this._wheelTickSession === this.activeSessionId ? this._wheelTickGesture : null; + if (state?.dir && state.dir !== Math.sign(lines)) this._wheelSgrQueue = ''; + const step = input.wheelTicksForGesture(state, lines, performance.now()); + this._wheelTickSession = this.activeSessionId; + this._wheelTickGesture = step.state; + this._queueScrollBytes(input.sgrWheelReports(step.ticks, pos)); }, /** - * Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (SGR + * 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; the queue is 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, same 512-byte bound) because this queue + * terminal-tile.js: same 40ms window and report limits) because this queue * flushes to the active session only; keep the two in step. */ _queueScrollBytes(data) { if (!data || !this.activeSessionId) return; - const queued = this._wheelSgrQueue || ''; - if (queued.length > 512) return; - this._wheelSgrQueue = queued + data; + // The first native report of a newly selected tab must not wait behind + // replay/atlas work for a 40ms timer (measured 60-250ms in practice). + // Selection generation also covers A -> B -> A when B was never scrolled. + const firstNativeForVisit = data.startsWith('\x1b[<') && + (this._wheelNativeStartSession !== this.activeSessionId || + this._wheelNativeStartGeneration !== this._selectGeneration); + const queued = !firstNativeForVisit && this._wheelQueueSession === this.activeSessionId + ? this._wheelSgrQueue || '' : ''; + this._wheelQueueSession = this.activeSessionId; + this._wheelSgrQueue = window.CodemanTerminalInput.coalesceScrollBytes(queued, data); + if (firstNativeForVisit) { + this._wheelNativeStartSession = this.activeSessionId; + this._wheelNativeStartGeneration = this._selectGeneration; + if (this._wheelSgrFlushTimer) clearTimeout(this._wheelSgrFlushTimer); + this._flushWheelSgrQueue(); + return; + } if (this._wheelSgrFlushTimer) return; this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40); }, @@ -5981,9 +5995,8 @@ Object.assign(CodemanApp.prototype, { * PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel * reports — six `\x1b[<64;…M` reports against an idle pane left the capture * byte-identical — so paging is the only gesture that reaches it; - * - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which - * the full strip hides from xterm, and pages on PageUp/PageDown while SGR - * wheel reports do nothing. + * - `codex` with no observed mouse tracking. Native tracked fullscreen + * Codex uses fractional SGR reports; this remains its fallback capability. * * ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the * list the server builds from the `transcriptPageKeys` CAPABILITY, never an id @@ -6015,8 +6028,8 @@ Object.assign(CodemanApp.prototype, { * unset (the inline renderer, or fullscreen right after a server restart), the * user turned on "Wheel scrolls local history" (which pins the wheel to a buffer * that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is - * one that never fills the buffer and never accepts the wheel (opencode, codex: - * both ignore SGR wheel reports, #227, and page on PageUp/PageDown). Before + * one that never fills the buffer and does not accept the wheel (OpenCode, + * or Codex without observed mouse tracking). Those modes page instead. Before * this, all of those produced a completely dead gesture; the #205 reporter * proved the keyboard route works by paging back through intact text with Fn+Up. * This is not wheel forwarding: it sends plain keys, and only over a hollow buffer. @@ -6060,6 +6073,7 @@ Object.assign(CodemanApp.prototype, { */ _maybePageCliTranscript(ev, lines) { if (!lines || ev?.shiftKey || !this.activeSessionId) return false; + if (ev?.ctrlKey || Math.abs(ev?.deltaX || 0) > Math.abs(ev?.deltaY || 0)) return this._localScrollbackIsHollow(); if (!this._localScrollbackIsHollow()) return false; // An open dialog in THIS pane's session (the active one): a page key would // move its selector. Consumed, so the buffer does not scroll under it either. @@ -6072,6 +6086,7 @@ Object.assign(CodemanApp.prototype, { this._pageKeySession = this.activeSessionId; this._pageKeyGesture = null; } + if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._wheelSgrQueue = ''; const step = window.CodemanTerminalInput.pageKeysForGesture( this._pageKeyGesture, ev, @@ -6127,7 +6142,9 @@ Object.assign(CodemanApp.prototype, { // Ephemeral (fire-and-forget): wheel reports are loss-tolerant, so they skip // the durable seq/ACK queue — no localStorage churn, no "Nb queued" flicker // in the connection indicator on every scroll tick. - if (data && this.activeSessionId) this._sendInputEphemeral(this.activeSessionId, data); + if (data && this.activeSessionId && this._wheelQueueSession === this.activeSessionId) { + this._sendInputEphemeral(this.activeSessionId, data); + } }, // Desktop counterpart of the touchend tap branch: hand-encode an SGR report diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 4812d062..9fa1c6ee 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -486,15 +486,15 @@ describe('capability shapes', () => { }); it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => { - // claude forwards from 2.1.187 (fullscreen renderer only); copilot forwards at any version while - // it tracks the mouse. codex ignores SGR wheel reports, so every other entry stays 'never'. + // Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot + // forward without a version gate. Unmeasured entries stay 'never'. const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward; expect(mode('copilot')).toEqual({ mode: 'always' }); expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' }); const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map( (e) => e.id as string ); - expect(forwarding.sort()).toEqual(['claude', 'copilot']); + expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']); expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true); }); @@ -505,3 +505,12 @@ describe('capability shapes', () => { expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', '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: 'always' }); + expect(CliEntrySchema.safeParse(codex).success).toBe(true); + expect(codex.capabilities.transcriptPageKeys).toBe(true); + }); +}); diff --git a/test/codex-wheel-routing.browser.test.ts b/test/codex-wheel-routing.browser.test.ts new file mode 100644 index 00000000..cf573fcc --- /dev/null +++ b/test/codex-wheel-routing.browser.test.ts @@ -0,0 +1,82 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +// Adversarial browser layout fixture, not evidence of native Codex history: +// authentic CLI behavior is measured separately in an isolated live pane. +describe('tracked Codex wheel routing in the primary pane', () => { + let server: WebServer; + let browser: Browser; + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + }, 60000); + afterAll(async () => { + await browser?.close(); + await server?.stop(); + }); + + it('sends plain wheel to the CLI rather than scrolling stale repaint frames', async () => { + const page = await browser.newPage({ viewport: { width: 1200, height: 800 } }); + try { + await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal); + await page.waitForTimeout(3500); + await page.evaluate(async () => { + const app = (window as any).app; + app.activeSessionId = 'codex-wheel-probe'; + app.sessions.set(app.activeSessionId, { id: app.activeSessionId, mode: 'codex', cliMouseTracking: true }); + (window as any).scrollInputs = []; + // Cell hit testing is irrelevant to routing; the empty dashboard covers the test terminal. + app._clientPointToCell = () => ({ col: 1, row: 1 }); + app._sendInputEphemeral = (id: string, data: string) => (window as any).scrollInputs.push({ id, data }); + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + await new Promise<void>((resolve) => app.terminal.write('\r\nold repaint row'.repeat(100), resolve)); + app.terminal.scrollToTop(); + }); + const fire = (shiftKey = false) => + page.evaluate((shiftKey) => { + const t = (window as any).app.terminal; + t.element.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, shiftKey, bubbles: true, cancelable: true })); + }, shiftKey); + await fire(); + await page.waitForTimeout(150); + const forwarded = await page.evaluate(() => { + const app = (window as any).app; + return { + inputs: (window as any).scrollInputs, + baseY: app.terminal.buffer.active.baseY, + viewportY: app.terminal.buffer.active.viewportY, + }; + }); + expect(forwarded.inputs.length).toBeGreaterThan(0); + expect(forwarded.inputs.every((i: { id: string }) => i.id === 'codex-wheel-probe')).toBe(true); + expect( + forwarded.inputs + .map((i: { data: string }) => i.data) + .join('') + .match(/\x1b\[<64;/g) + ).toHaveLength(4); + expect(forwarded.viewportY).toBe(forwarded.baseY); + for (const shift of [false, true]) { + await page.evaluate((tracked) => { + const app = (window as any).app; + app.sessions.get(app.activeSessionId).cliMouseTracking = tracked; + app.terminal.scrollToBottom(); + (window as any).scrollInputs = []; + }, shift); + await fire(shift); + await page.waitForTimeout(150); + const local = await page.evaluate(() => { + const b = (window as any).app.terminal.buffer.active; + return { inputs: (window as any).scrollInputs, baseY: b.baseY, viewportY: b.viewportY }; + }); + expect(local.inputs).toEqual([]); + expect(local.viewportY).toBeLessThan(local.baseY); + } + } finally { + await page.close(); + } + }); +}); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 7fd26e64..a374b395 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,7 +55,12 @@ function loadTerminalUiHarness( _crashDiag: { log: vi.fn() }, performance: clock, requestAnimationFrame: (_fn: () => void) => 1, - setTimeout: (_fn: () => void) => 1, + 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) { @@ -205,26 +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[5~\x1b[5~\x1b[6~' }]); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[5~' }, + { id: 'sess-1', data: '\x1b[6~' }, + ]); }); - it('lets wheel notches accumulate instead of paging a full screen on each one', () => { - // A 100 px notch is 4 rows. Only a trackpad-sized opening event (under 2 rows) - // pages at once; a notch adds up toward half a screen as it always did, so - // slow notches (each one its own gesture by the 150 ms gap) still page once - // per 18 rows here, not once per notch. - 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~' }]); @@ -375,8 +395,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('pages a Codex transcript only while its local scrollback is empty', () => { - // Codex is never sent SGR wheel reports (it ignores them); this is the - // separate PageUp/PageDown fallback, which codex does honour. + // Codex without observed mouse tracking retains its PageUp/PageDown fallback. const { app, sent } = hollowApp(); app.sessions = new Map([['sess-1', { mode: 'codex' }]]); @@ -473,7 +492,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; @@ -558,9 +584,9 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter p.app._maybePageCliTranscript({ shiftKey: false, deltaY: dy }, dy / 25); p.advance(16); } - // 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(UP); + // A reversal inside the 40ms batch window cancels the unsent opening + // PageUp; subsequent in-gesture reversals do not pre-pay another page. + expect(p.keys()).toBe(''); // the opening report was cancelled before its 40ms flush }); it('still pages a deliberate reversal at once after a pause', () => { @@ -577,10 +603,10 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter p.advance(16); // Downward travel just short of a page: the upward debt must not count toward it. p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 * 18 }, 18); - expect(p.keys()).toBe(UP); + expect(p.keys()).toBe(''); // reversal discards the unsent UP p.advance(16); p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1); - expect(p.keys()).toBe(UP + DOWN); + expect(p.keys()).toBe(DOWN); }); it('ends the one-second debt lifetime once the debt is repaid, exactly or overpaid', () => { @@ -747,8 +773,8 @@ describe('GitHub Copilot wheel forwarding', () => { expect(idle.app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); }); - it('does not change what other non-claude modes do', () => { - for (const mode of ['codex', 'opencode', 'grok', 'gemini']) { + it('keeps unmeasured CLI modes out of native forwarding', () => { + for (const mode of ['opencode', 'grok', 'gemini']) { const { app } = hollowApp({ mode, cliMouseTracking: true }); expect(app._shouldForwardWheelToApp({ shiftKey: false }), mode).toBe(false); } @@ -790,13 +816,13 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => { const event = { shiftKey: false, ...extra }; const step = pageKeysForGesture(state, event, lines, 36, 1_000); state = step.state; - expected += step.keys; + expected = windowRef.CodemanTerminalInput.coalesceScrollBytes(expected, step.keys) || expected; app._maybePageCliTranscript(event, lines); } app._flushWheelSgrQueue(); expect(app._pageKeyGesture).toEqual(state); expect(expected).toContain('\x1b[5~'); - expect(expected).toContain('\x1b[6~'); + expect(expected).not.toContain('\x1b[6~'); expect(sent).toEqual([{ id: 'sess-1', data: expected }]); }); @@ -933,3 +959,237 @@ describe('touch scrolling when xterm owns native mouse tracking', () => { expect(app._shouldForwardTouchToApp()).toBe(false); }); }); + +describe('consistent scroll gesture adapters', () => { + it('keeps the shared paging opening threshold exported', () => { + expect(loadTerminalUiHarness().windowRef.CodemanTerminalInput.PAGE_KEY_IMMEDIATE_MAX_ROWS).toBe(2); + }); + + it('preserves rounded travel for isolated coarse clicks without accumulating a capped backlog', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + for (const direction of [-1, 1]) { + expect(wheelTicksForGesture(null, direction * 4.8, 0).ticks).toBe(direction * 5); // 120px + expect(wheelTicksForGesture(null, direction * 1.6, 0).ticks).toBe(direction * 2); // 40px + let state = null; + let ticks = 0; + for (let i = 0; i < 5; i++) { + const step = wheelTicksForGesture(state, direction * 4.8, i * 300); + state = step.state; + ticks += step.ticks; + } + expect(ticks).toBe(direction * 25); + const capped = wheelTicksForGesture(null, direction * 40, 0); + expect(capped.ticks).toBe(direction * 5); + expect(capped.state.pending).toBe(0); // don't retain travel discarded by the event cap + } + }); + + it('pre-pays an isolated deliberate opening tick and repays its signed debt', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + const opening = wheelTicksForGesture(null, -0.16, 0); // a 4px mouse click + expect(opening.ticks).toBe(-1); + expect(opening.state.pending).toBeCloseTo(0.84); + const continued = wheelTicksForGesture(opening.state, -0.16, 20); + expect(continued.ticks).toBe(0); + expect(continued.state.pending).toBeCloseTo(0.68); + expect(wheelTicksForGesture(null, 0.04, 0).ticks).toBe(0); // sub-threshold jitter + }); + + it('sends each slow 4px click, in either direction, while ten 2.5px events total one tick', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + for (const direction of [-1, 1]) { + let slowState; + const slowTicks = []; + for (let i = 0; i < 5; i++) { + const step = wheelTicksForGesture(slowState, direction * 0.16, i * 300); + slowState = step.state; + slowTicks.push(step.ticks); + } + expect(slowTicks).toEqual(Array(5).fill(direction)); + let fineState; + const fineTicks = []; + for (let i = 0; i < 10; i++) { + const step = wheelTicksForGesture(fineState, direction * 0.1, i * 8); + fineState = step.state; + fineTicks.push(step.ticks); + } + expect(fineTicks).toEqual([direction, ...Array(9).fill(0)]); + expect(fineState.pending).toBe(0); + } + }); + + it('makes ten small Claude deltas equal one combined delta', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + let state; + let ticks = 0; + for (let i = 0; i < 10; i++) { + const step = wheelTicksForGesture(state, -0.12, i * 8); + state = step.state; + ticks += step.ticks; + } + expect(ticks).toBe(-1); + let decimalState; + let decimalTicks = 0; + for (let i = 0; i < 10; i++) { + const step = wheelTicksForGesture(decimalState, 0.1, i * 8); + decimalState = step.state; + decimalTicks += step.ticks; + } + expect(decimalTicks).toBe(1); + expect(wheelTicksForGesture(null, -1.2, 0).ticks).toBe(ticks); + }); + + it('drops fractional movement on reversal, inactivity and session changes', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + const state = wheelTicksForGesture(null, 0.9, 0).state; + expect(wheelTicksForGesture(state, -0.2, 10).ticks).toBe(0); + expect(wheelTicksForGesture(state, 0.2, 200).ticks).toBe(1); + const { app, sent } = hollowApp(); + app._clientPointToCell = () => ({ col: 1, row: 1 }); + app._sendSyntheticSgrWheel(0, 0, 0.9); + app.activeSessionId = 'other'; + app._sendSyntheticSgrWheel(0, 0, 0.2); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[<65;1;1M' }, + { id: 'other', data: '\x1b[<65;1;1M' }, + ]); + expect(app._wheelTickGesture.pending).toBeCloseTo(-0.8); + }); + + 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([]); + for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4); + app._flushWheelSgrQueue(); + expect(sent).toHaveLength(1); + }); + + 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(100)); + expect(coalesceScrollBytes(queued, down)).toBe(down); + 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~'); + app.activeSessionId = 'other'; + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); + }); + + it('sends the first native report synchronously on every tab visit, batching subsequent reports', () => { + const { app, sent } = hollowApp(); + const up = '\x1b[<64;1;1M'; + app._selectGeneration = 1; + app._queueScrollBytes(up); + expect(sent).toEqual([{ id: 'sess-1', data: up }]); + app._queueScrollBytes(up); + app._queueScrollBytes(up); + expect(sent).toHaveLength(1); + app._flushWheelSgrQueue(); + expect(sent.at(-1)).toEqual({ id: 'sess-1', data: up.repeat(2) }); + // Returning A -> B -> A must reset onset even if B was never scrolled. + app._queueScrollBytes(up); // previous visit has an unsent report + app._selectGeneration = 3; + app._queueScrollBytes(up); + expect(sent).toHaveLength(3); + expect(sent.at(-1)).toEqual({ id: 'sess-1', data: up }); + app._queueScrollBytes(up); // unsent A report must never leak to B + app.activeSessionId = 'other'; + app._selectGeneration = 4; + app._queueScrollBytes(up); + expect(sent.at(-1)).toEqual({ id: 'other', data: up }); + app._flushWheelSgrQueue(); + expect(sent).toHaveLength(4); + }); + + it('cancels unsent Claude ticks even when reversed travel is sub-line', () => { + const { app, sent } = hollowApp(); + app._clientPointToCell = () => ({ col: 1, row: 1 }); + app._sendSyntheticSgrWheel(0, 0, -1); // leading report is already delivered + app._sendSyntheticSgrWheel(0, 0, -2); + app._sendSyntheticSgrWheel(0, 0, 0.2); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;1;1M' }]); + }); +}); + +describe('native Codex scrolling instead of an initial page jump', () => { + it('forwards only when Codex explicitly requests mouse tracking', () => { + for (const cliMouseTracking of [undefined, false, true]) { + const { app } = hollowApp({ mode: 'codex', cliMouseTracking }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(cliMouseTracking === true); + expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false); + } + }); + + it('keeps local-history opt-out and native xterm mouse routing', () => { + const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true }); + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + app.loadAppSettingsFromStorage = () => ({}); + app.terminal.modes.mouseTrackingMode = 'drag'; + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + }); +}); + +describe('native wheel travel preserves upstream units', () => { + it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => { + const { wheelDeltaLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + for (const [deltaY, deltaMode] of [ + [100, 0], + [120, 0], + [1000, 0], + [3, 1], + [1, 2], + ]) { + 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 { wheelDeltaLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput; + let state; + let ticks = 0; + for (let i = 0; i < 10; i++) { + const ev = { deltaY: -3, deltaMode: 0 }; + const lines = wheelDeltaLines(ev, 43); + expect(lines).toBe(-0.12); + expect(app._wheelScrollLinesFloat(ev)).toBe(lines); + const step = wheelTicksForGesture(state, lines, i * 10); + state = step.state; + ticks += step.ticks; + } + expect(ticks).toBe(-1); + }); +}); diff --git a/test/terminal-tile-scroll.browser.test.ts b/test/terminal-tile-scroll.browser.test.ts index cfeccbdb..b1afb3f5 100644 --- a/test/terminal-tile-scroll.browser.test.ts +++ b/test/terminal-tile-scroll.browser.test.ts @@ -276,6 +276,16 @@ describe('TerminalTile wheel paging in a real browser', () => { const down = `\x1b[<65;${expected.col};${expected.row}M`; expect(after.sent.map(([, data]) => data)).toEqual([up.repeat(4), down.repeat(2)]); + // A slow macOS mouse click is only ~4px. Each isolated click must report, + // not lose its fraction during the 300ms pause between clicks. + await page.waitForTimeout(300); + for (let click = 0; click < 5; click++) { + await page.mouse.wheel(0, -4); + await page.waitForTimeout(300); + } + const afterSlow = await snap(); + expect(afterSlow.sent.slice(after.sent.length).map(([, data]) => data)).toEqual(Array(5).fill(up)); + // Shift+wheel is the explicit "local scrollback" gesture: scrolled locally // by the tile (xterm alone turns it into a horizontal no-op), unforwarded. await page.keyboard.down('Shift'); @@ -283,7 +293,7 @@ describe('TerminalTile wheel paging in a real browser', () => { await page.keyboard.up('Shift'); await page.waitForTimeout(150); const shifted = await snap(); - expect(shifted.sent.length).toBe(after.sent.length); + expect(shifted.sent.length).toBe(afterSlow.sent.length); expect(shifted.viewportY).toBeLessThan(before.baseY); } finally { await page.evaluate((id) => { @@ -297,4 +307,123 @@ describe('TerminalTile wheel paging in a real browser', () => { }, PROBE_ID); } }); + + it('preserves local Ctrl/sideways scrolling and forwards small native clicks without amplifying touchpad streams', async () => { + const id = 'primary-wheel-probe'; + await page.evaluate(async (id) => { + const a = (window as any).app; + const t = a.terminal; + (window as any).__primaryWheelProbe = { + sent: [], + activeId: a.activeSessionId, + realEphemeral: a._sendInputEphemeral, + point: a._clientPointToCell, + }; + a._sendInputEphemeral = (sid: string, data: string) => (window as any).__primaryWheelProbe.sent.push([sid, data]); + a._clientPointToCell = () => ({ col: 1, row: 1 }); + a.sessions.set(id, { id, mode: 'shell' }); + a.activeSessionId = id; + t.reset(); + await new Promise<void>((resolve) => + t.write(Array.from({ length: 100 }, (_, i) => `LOCAL_${i}\r\n`).join(''), resolve) + ); + t.scrollToBottom(); + }, id); + const fire = async (options: { deltaY: number; deltaX?: number; ctrlKey?: boolean }) => { + await page.evaluate((options) => { + const t = (window as any).app.terminal; + t.element.dispatchEvent(new WheelEvent('wheel', { ...options, deltaMode: 0, bubbles: true, cancelable: true })); + }, options); + await page.waitForTimeout(80); // actual 40ms batch and animation frames + }; + try { + for (const options of [ + { deltaY: -25, ctrlKey: true }, + { deltaY: -25, deltaX: 100 }, + ]) { + const before = await page.evaluate(() => { + const t = (window as any).app.terminal; + t.scrollToBottom(); + return t.buffer.active.viewportY; + }); + expect(before).toBeGreaterThan(0); + await fire(options); + const after = await page.evaluate(() => (window as any).app.terminal.buffer.active.viewportY); + expect(after).toBe(before - 1); // local shell behavior matches master + } + for (const mode of ['claude', 'codex', 'copilot']) { + await page.evaluate( + ({ id, mode }) => { + const a = (window as any).app; + a.sessions.set(id, { + id, + mode, + cliVersion: mode === 'claude' ? '2.1.300' : undefined, + cliMouseTracking: true, + }); + a._wheelTickGesture = null; + a._wheelTickSession = null; + a._selectGeneration++; + (window as any).__primaryWheelProbe.sent.length = 0; + }, + { id, mode } + ); + for (const options of [ + { deltaY: -25, ctrlKey: true }, + { deltaY: -25, deltaX: 100 }, + ]) + await fire(options); + expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0); + for (let click = 0; click < 5; click++) { + await fire({ deltaY: -4 }); + await page.waitForTimeout(220); + } + expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual( + Array.from({ length: 5 }, () => [id, '\x1b[<64;1;1M']) + ); + await page.waitForTimeout(200); + await page.evaluate(() => { + (window as any).__primaryWheelProbe.sent.length = 0; + }); + await page.evaluate(() => { + const t = (window as any).app.terminal; + for (let event = 0; event < 10; event++) + t.element.dispatchEvent( + new WheelEvent('wheel', { + deltaY: -2.5, + deltaMode: 0, + bubbles: true, + cancelable: true, + }) + ); + }); + await page.waitForTimeout(80); + expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual([[id, '\x1b[<64;1;1M']]); + } + await page.evaluate((id) => { + const a = (window as any).app; + a.sessions.set(id, { id, mode: 'codex', cliMouseTracking: false }); + a.terminal.clear(); + a.terminal.scrollToBottom(); + (window as any).__primaryWheelProbe.sent.length = 0; + }, id); + expect(await page.evaluate(() => (window as any).app.terminal.buffer.active.baseY)).toBe(0); + for (const options of [ + { deltaY: -25, ctrlKey: true }, + { deltaY: -25, deltaX: 100 }, + ]) + await fire(options); + expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0); + } finally { + await page.evaluate((id) => { + const a = (window as any).app, + p = (window as any).__primaryWheelProbe; + a.activeSessionId = p.activeId; + a._sendInputEphemeral = p.realEphemeral; + a._clientPointToCell = p.point; + a.sessions.delete(id); + delete (window as any).__primaryWheelProbe; + }, id); + } + }); }); diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index b99a6a73..8f17e0dd 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -232,7 +232,7 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => { expect(up.preventDefault).toHaveBeenCalled(); expect(up.stopPropagation).toHaveBeenCalled(); - expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event + expect(ws.inputFrames()).toEqual([]); // fallback page reports remain batched expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); // no seq: never persisted mount.fire('wheel', wheelLines(term.rows / 2)); @@ -328,14 +328,22 @@ 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([]); // coalesced, not sent per event + 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.repeat(2) }); }); - it('forwards a Chrome-on-Windows notch (100 px) as four ticks and caps a fling at five', async () => { + it('uses fractional native scrolling for a tracked Codex tile, never PageUp', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex', cliMouseTracking: true } }), { + mode: 'codex', + }); + for (let i = 0; i < 10; i++) mount.fire('wheel', wheel(-3, AT)); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + }); + + 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)); @@ -345,10 +353,34 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) }); }); - it('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => { + it('forwards an isolated small wheel event and five slow clicks for every tracked CLI', async () => { + for (const mode of ['claude', 'codex', 'copilot']) { + let now = 1000; + clock.now = () => now; + const session = mode === 'claude' ? FULLSCREEN : { mode, cliMouseTracking: true }; + const { ws, mount } = await connectTile(makeApp({ 's-tile': session }), { mode }); + mount.fire('wheel', wheel(-4, AT)); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + for (let i = 1; i < 5; i++) { + now += 300; + vi.advanceTimersByTime(300); + mount.fire('wheel', wheel(-4, AT)); + flushed(ws); + } + expect(ws.inputFrames().map((f) => f.d)).toEqual(Array(5).fill(UP)); + // Separate pane/visit: ten 2.5px events produce only the pre-paid tick. + const fine = await connectTile(makeApp({ 's-tile': session }), { mode }); + for (let i = 0; i < 10; i++) fine.mount.fire('wheel', wheel(-2.5, AT)); + expect(flushed(fine.ws)).toEqual([{ t: 'i', d: UP }]); + } + }); + + it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); - mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line + mount.fire('wheel', wheel(-3, AT)); // pre-pay one tick, then repay it + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + for (let i = 0; i < 8; i++) mount.fire('wheel', wheel(-3, AT)); expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); const sideways = wheel(0, { ...AT, deltaX: 120 }); @@ -357,14 +389,30 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(flushed(ws)).toHaveLength(1); }); - it('coalesces the reports of wheels within 40 ms into one frame', async () => { + it('sends the first report immediately and coalesces subsequent wheels within 40 ms', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); mount.fire('wheel', wheelLines(-2, AT)); vi.advanceTimersByTime(20); mount.fire('wheel', wheelLines(-2, AT)); - expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]); + mount.fire('wheel', wheelLines(-2, AT)); + 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) }, + ]); }); it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => { @@ -445,14 +493,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; - _wheelScrollLines(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._wheelScrollLines(ev)); + primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLinesFloat(ev)); primary._flushWheelSgrQueue(); } @@ -540,7 +588,7 @@ describe('page-key travel, cap and coalescing', () => { FakeFit.proposed = { cols: 80, rows: 36 }; }); - it('accumulates sub-page travel: two wheels of 10 lines are one PageUp', 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)); @@ -608,10 +656,11 @@ describe("gesture handling is the primary pane's, through the shared pure helper clock.now = () => now; return (ms: number) => { now += ms; + vi.advanceTimersByTime(ms); }; } - it('pages a Codex tile, which draws on the alternate screen and ignores SGR wheel reports', async () => { + it('pages an untracked Codex tile through the hollow-pane fallback', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' }); mount.fire('wheel', wheelLines(-18)); @@ -744,8 +793,8 @@ describe("gesture handling is the primary pane's, through the shared pure helper const tileBytes = flushed(ws) .map((f) => f.d) .join(''); - // One page for the slow ticks, one for the wobble's opening event, nothing more. - expect(tileBytes).toBe(PAGE_UP + PAGE_UP); + // One page for the slow ticks; the wobble cancels its unsent opening page on reversal. + 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; diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 114ac24d..0a467404 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -26,6 +26,7 @@ function loadTerminalUiHarness() { performance: { now: () => now }, requestAnimationFrame: (_fn: () => void) => 1, setTimeout: (_fn: () => void) => 1, + clearTimeout: vi.fn(), Blob: function Blob() {}, URL: { createObjectURL: () => 'blob:yield', @@ -737,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)', () => { @@ -789,7 +790,7 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); }); - it('wheel: only claude forwards — codex and gemini keep the local wheel', () => { + it('wheel: tracked fullscreen Codex forwards; inline Codex and Gemini do not', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; app.terminal = { @@ -803,10 +804,10 @@ describe('terminal touch tap mouse guard', () => { // (the codex transcript lives there — inline viewport, no in-app pager) sat unused. app.sessions = new Map([['sess-1', { mode: 'codex' }]]); expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); - // Tracking on and a high version, so only the mode check can say no: without - // them the gate is false for claude too and this would pin nothing. - app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9', cliMouseTracking: true }]]); // no version rescues it - expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + // Current fullscreen Codex requests tracking and supports native row scrolling. + // Its version is not published as Claude-specific cliInfo; observed tracking gates it. + app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9', cliMouseTracking: true }]]); // unverified TUI expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); @@ -837,22 +838,22 @@ 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', () => { + it('wheel: encodes SGR 64/65 ticks, caps per event, and sends the first report before batching the rest', () => { const { app } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; @@ -871,13 +872,16 @@ describe('terminal touch tap mouse guard', () => { app._sendSyntheticSgrWheel(50, 50, -2); // 2 ticks up app._sendSyntheticSgrWheel(50, 50, 9); // capped at 5 ticks down - expect(sent).toEqual([]); // nothing until the flush timer fires + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) }]); // leading report is immediate app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) + '\x1b[<65;7;4M'.repeat(5) }]); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) }, + { id: 'sess-1', data: '\x1b[<65;7;4M'.repeat(5) }, + ]); app._flushWheelSgrQueue(); // queue drained — no duplicate send - expect(sent).toHaveLength(1); + expect(sent).toHaveLength(2); }); it('forwarded scrolls (wheel AND touch) snap the viewport home first, then encode SGR ticks', () => {