diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index fe8f5167..a24c9250 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -229,9 +229,9 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codex has no Claude hook alert, so a `tabAlerts === 'action'` test cannot detect this prompt and is not a native-wheel safety gate. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. -Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. Fractional deltas accumulate within one recent direction, rather than rounding every tiny event up. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. +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) pre-pays one tick when its opening travel is under a row, so an isolated 4px mouse click responds. 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 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. +**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). `_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`. diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 037fe1e1..2326c533 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -145,9 +145,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 e621e14b..25780ffb 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -226,10 +226,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 41c64ef4..d208bfca 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -173,8 +173,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/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 33d3a903..16d8136e 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -1225,6 +1225,8 @@ _queueScrollBytes(data) { if (!data || this._destroyed) return; 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); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 632c1a12..2d215434 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -137,15 +137,20 @@ return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); } - // Fractional travel belongs to one direction and one recent gesture. Unlike - // rounding every event up, splitting 25px into ten touchpad events still sends - // one tick. Excess fling travel is discarded, never replayed after the finger - // stops. Both the primary pane (including touch) and tiles use this adapter. + // A deliberate opening event pre-pays a tick, so a lone small mouse click + // responds immediately. The signed remainder is debt 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 || state.dir !== dir || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS; - const total = (fresh ? 0 : state.pending || 0) + 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 && Math.abs(lines) < 1) { + return { state: { dir, lastAt: now, pending: lines - dir }, ticks: dir }; + } + 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 }, @@ -414,6 +419,7 @@ pageKeysForGesture, PAGE_KEY_GESTURE_GAP_MS, PAGE_KEY_MIN_START_ROWS, + PAGE_KEY_IMMEDIATE_MAX_ROWS, PAGE_KEY_DEBT_MS, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, @@ -1113,7 +1119,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); @@ -1128,8 +1134,8 @@ Object.assign(CodemanApp.prototype, { if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); ev.stopPropagation(); - if (ev.ctrlKey || (!ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY))) return; 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._wheelScrollLinesFloat(ev)); return; @@ -5867,9 +5873,9 @@ 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) { diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 786d4aae..c5ba96ea 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -559,8 +559,8 @@ 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. + // 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 }); @@ -857,6 +857,44 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { }); 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('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; @@ -882,13 +920,17 @@ describe('consistent scroll gesture adapters', () => { 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(0); - const { app } = hollowApp(); + 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(app._wheelSgrQueue || '').toBe(''); + 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', () => { diff --git a/test/terminal-tile-scroll.browser.test.ts b/test/terminal-tile-scroll.browser.test.ts index cfeccbdb..9dfb7d69 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']) { + 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 e403132a..62f8f8e0 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -344,11 +344,33 @@ 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('forwards an isolated small wheel event and five slow clicks for Claude and tracked Codex', async () => { + for (const mode of ['claude', 'codex']) { + 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 must not round up - expect(flushed(ws)).toEqual([]); + 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 }]);