From 39d7c01b7b45d7dee62eade97996d3cd325262d4 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Fri, 16 Oct 2026 11:02:46 +0400 Subject: [PATCH] fix(terminal): make native scrolling consistent and responsive Track fractional wheel gestures, bound unsent input, enable tracked Codex scrolling and send the first native report immediately after tab selection. Refs: https://github.com/johoja12/Codeman/issues/7 --- src/web/public/terminal-tile.js | 27 +++- src/web/public/terminal-ui.js | 147 +++++++++++++------- test/terminal-scroll-routing.test.ts | 194 ++++++++++++++++++++++++--- test/terminal-tile-scroll.test.ts | 50 ++++--- test/terminal-touch-tap.test.ts | 20 +-- 5 files changed, 342 insertions(+), 96 deletions(-) diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 4850929b..8002e44a 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -229,6 +229,7 @@ } async connect() { + this._wheelNativeStarted = false; const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10); this.terminal = new Terminal({ theme: { ...global.codemanCurrentXtermTheme() }, @@ -1089,19 +1090,23 @@ 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.wheelDeltaNativeLines(ev, this.terminal.rows); + 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; } @@ -1201,6 +1206,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); @@ -1208,7 +1215,7 @@ } // 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 + // one send per 40 ms, bounded by report count so a fling cannot build a backlog // that keeps scrolling after it stops. A narrow // twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue // (terminal-ui.js; keep the two in step), which flushes to the active @@ -1216,8 +1223,16 @@ // 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); + 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 cb7d0561..3e78470e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -98,10 +98,6 @@ const PAGE_KEY_GESTURE_GAP_MS = 150; // ...but not for sub-row jitter (0.1 row, ~2px)... const PAGE_KEY_MIN_START_ROWS = 0.1; - // ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px, - // 4 rows) accumulates toward half a screen as before; paging at once on it made - // slow notches, each its own gesture past the gap, page a full screen apiece. - const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2; // A pre-paid page's debt outlives the gesture gap for this long when the next // event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a // Firefox 1-line notch) pay it back instead of each pre-paying a page of its own. @@ -121,6 +117,14 @@ : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) } + // A wheel event is at most one native tick: a common mouse notch is + // deltaY=100px (or Firefox's 3 lines), not four/three separate notches. + // Small touchpad events retain their fractional distance and accumulate in + // wheelTicksForGesture. Touch drags bypass this wheel-only normalization. + function wheelDeltaNativeLines(ev, rows) { + return Math.max(-1, Math.min(1, wheelDeltaLines(ev, rows))); + } + // The same travel rounded to whole lines for the SGR wheel reports: a pure // horizontal swipe is 0 (nothing to send), and anything else moves at least // one line, so the small pixel deltas of a precision touchpad still scroll. @@ -147,6 +151,35 @@ 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. + 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 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. Keep at most five SGR reports + // or three page keys per flush, and replace an obsolete direction immediately. + // Local and remote transports share the same bounded gesture queue. + function coalesceScrollBytes(queued, data) { + const direction = (bytes) => bytes?.match(/\x1b\[<(64|65);|\x1b\[(5|6)~/)?.[0]; + const previous = direction(queued); + const next = direction(data); + const combined = previous && previous === next ? queued + data : data; + const reports = combined.match(/\x1b\[<(?:64|65);\d+;\d+M|\x1b\[[56]~/g) || []; + const limit = combined.startsWith('\x1b[<') ? SGR_WHEEL_MAX_TICKS : PAGE_KEY_MAX_PER_BATCH; + return reports.slice(-limit).join(''); + } + // 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)); @@ -181,15 +214,15 @@ // PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid // page is still owed in the same direction. One contact is never split by // time, so a slow drag (one line per 170 ms) is one gesture; - // - a new gesture's trackpad-sized opening event (at least - // PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE, + // - a new gesture's ordinary wheel/touch opening event (at least + // PAGE_KEY_MIN_START_ROWS, under one page of travel) pages AT ONCE, // pre-paid: the half screen it skipped is owed back by the rest of the // gesture, so the rate stays one page per half screen. A debt left by a // pre-paid page dies with its gesture; // - a direction change inside a gesture (a reversal within the gap, or // within the same contact) drops a pre-paid debt and accumulates, never // pre-paying, so a +/-3 px trackpad wobble cannot page back and forth; - // - everything else, a wheel notch included, accumulates through + // - larger openings and subsequent events accumulate through // pageKeysForTravel, carried across notches. // Returns { state, keys } with keys '' when nothing is due. function pageKeysForGesture(state, ev, lines, rows, now) { @@ -215,13 +248,14 @@ gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS); } if (gestureStart || reversed) { - // A pre-paid debt belongs to its gesture and its direction. - if (prepaid) pending = 0; + // Travel and debt belong to their direction; never make a reversal pay + // off movement accumulated going the other way. + if (prepaid || reversed) pending = 0; prepaid = false; } if (gestureStart) { const size = Math.abs(lines); - if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) { + if (size >= PAGE_KEY_MIN_START_ROWS && size < perPage) { // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). return { state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId }, @@ -389,13 +423,15 @@ PAGE_KEY_MAX_PER_BATCH, wheelDeltaLines, wheelDeltaWholeLines, + wheelDeltaNativeLines, SGR_WHEEL_MAX_TICKS, sgrWheelReports, + wheelTicksForGesture, + coalesceScrollBytes, pageKeysForTravel, 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, @@ -1110,9 +1146,10 @@ 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)) { this._logScrollRouting('forward-sgr'); - this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev)); + this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollNativeLines(ev)); return; } // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits @@ -5780,23 +5817,10 @@ 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. + // Forward only to CLIs that explicitly enable mouse tracking. Fullscreen + // Codex 0.163.0 was verified against canonical tmux captures: one SGR report + // scrolls one transcript row. Inline Codex (tracking off) must retain local + // history/fallback behavior; unconditional forwarding caused the old #227 bug. // Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger // scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and // Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns @@ -5815,6 +5839,10 @@ Object.assign(CodemanApp.prototype, { return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows); }, + _wheelScrollNativeLines(ev) { + return window.CodemanTerminalInput.wheelDeltaNativeLines(ev, this.terminal?.rows); + }, + /** Unrounded variant for the smooth local-scroll path, which accumulates * sub-line fractions across events instead of forcing every tiny trackpad * delta to a whole ±1 line. Same unit handling and Shift-axis trap. The @@ -5848,8 +5876,9 @@ Object.assign(CodemanApp.prototype, { if (mode && mode !== 'none') return false; const session = this.sessions?.get(target.sessionId || this.activeSessionId); const sessionMode = session?.mode || 'claude'; - if (sessionMode !== 'claude') return false; - if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; + if (sessionMode !== 'claude' && sessionMode !== 'codex') return false; + if (sessionMode === 'codex' && this.tabAlerts?.get(target.sessionId || this.activeSessionId) === 'action') return false; + if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; // Only while Claude is actually listening for the mouse. In its default // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the // transcript lives in real scrollback, like codex, and SGR wheel reports are @@ -5892,25 +5921,45 @@ Object.assign(CodemanApp.prototype, { 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 + * on a single flick; report counts are bounded so a wild scroll can't build a * backlog that keeps scrolling after the finger stops. * * 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); }, @@ -5930,9 +5979,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 @@ -5964,8 +6012,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. @@ -5975,8 +6023,7 @@ Object.assign(CodemanApp.prototype, { * scrollback is never touched. Shift is excluded on purpose: it is the explicit * "give me local scrollback" gesture and must keep that meaning. * - * The FIRST event of a trackpad-sized gesture (opening under - * PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of + * The FIRST event of a deliberate sub-page gesture pages at once. Waiting for half a screen of * travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick * never sent a key at all. That page is pre-paid: the travel it skipped is * owed back by the rest of the gesture, so the rate stays one page per @@ -5984,8 +6031,8 @@ Object.assign(CodemanApp.prototype, { * (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is * still owed), a reversal after the gap or a tab switch starts a new gesture; * one contact is never split by time, and a quick reversal never pre-pays. A - * larger opening event is a wheel notch and accumulates as before, so slow - * notches still page once per `perPage` rows. + * wheel opening below the page threshold also pages immediately. Subsequent events + * repay the skipped travel rather than paging once per notch. * * Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad * pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|; @@ -6009,6 +6056,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. @@ -6021,6 +6069,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, @@ -6071,7 +6120,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/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 3001db84..bb8efdae 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -45,6 +45,7 @@ function loadTerminalUiHarness( performance: clock, requestAnimationFrame: (_fn: () => void) => 1, setTimeout: (_fn: () => void) => 1, + clearTimeout: vi.fn(), Blob: function Blob() {}, URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} }, Worker: function Worker(this: any) { @@ -210,14 +211,12 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 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[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. + it('answers the first wheel notch and amortizes the page over subsequent notches', () => { + // The first 100px notch responds immediately. Later notches repay the + // pre-paid page, including when they arrive past the normal gesture gap. for (const gapMs of [250, 40]) { const { app, sent, clock } = hollowApp(); let now = 1_000; @@ -350,8 +349,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' }]]); @@ -535,7 +533,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter } // The opening event is a fresh gesture and pages; no reversal inside the // gap pre-pays, so the wobble sends nothing after it. - expect(p.keys()).toBe(UP); + expect(p.keys()).toBe(''); // obsolete queued direction cancelled before its flush }); it('still pages a deliberate reversal at once after a pause', () => { @@ -543,7 +541,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1); p.advance(200); p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1); - expect(p.keys()).toBe(UP + DOWN); + expect(p.keys()).toBe(DOWN); }); it('does not let a reversal inherit the other direction’s debt', () => { @@ -552,10 +550,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', () => { @@ -643,8 +641,8 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => { expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP]); }); - it('accumulates a notch-sized opening event, and carries plain travel across notches', () => { - // Five 4-row notches 400 ms apart: each is a new gesture, none pages alone. + it('responds immediately to a notch without paging for every later notch', () => { + // Five 4-row notches 400ms apart share the pre-paid page debt. const steps = run([ [-4, 0], [-4, 400], @@ -742,13 +740,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 }]); }); @@ -829,3 +827,167 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { expect(logs[0]).toContain('cliVersion=unknown'); }); }); + +describe('consistent scroll gesture adapters', () => { + 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(0); + const { app } = 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(''); + }); + + it('answers the first ordinary Codex wheel event, without paging every event', () => { + const { app, sent } = hollowApp({ mode: 'codex', rows: 43 }); + app._maybePageCliTranscript({ deltaY: -100 }, -4); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4); + app._flushWheelSgrQueue(); + expect(sent).toHaveLength(1); + }); + + it('bounds a sustained burst and cancels obsolete direction before transport', () => { + const { coalesceScrollBytes } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + const up = '\x1b[<64;1;1M'; + const down = '\x1b[<65;1;1M'; + let queued = ''; + for (let i = 0; i < 100; i++) queued = coalesceScrollBytes(queued, up); + expect(queued).toBe(up.repeat(5)); + expect(coalesceScrollBytes(queued, down)).toBe(down); + expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(3)); + expect(coalesceScrollBytes('\x1b[5~', '\x1b[6~')).toBe('\x1b[6~'); + }); + + 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('preserves the Codex dialog guard for primary and tiled routing', () => { + const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true }); + app.sessions.set('tile', { mode: 'codex', cliMouseTracking: true }); + app.tabAlerts = new Map([['sess-1', 'action']]); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).toBe(true); + app.tabAlerts.set('tile', 'action'); + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).toBe(false); + }); + + it('keeps local-history opt-out and native xterm mouse routing', () => { + const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true }); + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + app.loadAppSettingsFromStorage = () => ({}); + app.terminal.modes.mouseTrackingMode = 'drag'; + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + }); +}); + +describe('one native tick per wheel event', () => { + it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => { + const { wheelDeltaNativeLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + for (const [deltaY, deltaMode] of [ + [100, 0], + [120, 0], + [1000, 0], + [3, 1], + [1, 2], + ]) { + expect(wheelDeltaNativeLines({ deltaY, deltaMode }, 43)).toBe(1); + expect(wheelDeltaNativeLines({ deltaY: -deltaY, deltaMode }, 43)).toBe(-1); + } + }); + + it('keeps fractional touchpad distance and primary/tile normalization identical', () => { + const { app, windowRef } = hollowApp({ mode: 'codex', cliMouseTracking: true }); + const { wheelDeltaNativeLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput; + let state; + let ticks = 0; + for (let i = 0; i < 10; i++) { + const ev = { deltaY: -3, deltaMode: 0 }; + const lines = wheelDeltaNativeLines(ev, 43); + expect(lines).toBe(-0.12); + expect(app._wheelScrollNativeLines(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.test.ts b/test/terminal-tile-scroll.test.ts index 9629e926..aca5d7e2 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)); @@ -319,27 +319,37 @@ 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(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted + expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]); // first report never waits for a timer + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); // no seq: never persisted mount.fire('wheel', wheelLines(2, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN }); }); - 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('normalizes a pixel wheel event to one native tick, including accelerated deltas', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); mount.fire('wheel', wheel(-100, AT)); - expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); mount.fire('wheel', wheelLines(-1000, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP }); }); - it('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => { + 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)); // 0.12 of a line must not round up + expect(flushed(ws)).toEqual([]); + 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 }); @@ -348,14 +358,19 @@ 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 }]); + 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 () => { @@ -436,14 +451,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; + _wheelScrollNativeLines(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._wheelScrollNativeLines(ev)); primary._flushWheelSgrQueue(); } @@ -531,11 +546,11 @@ 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('answers sub-page travel immediately and repays it on the next event', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); mount.fire('wheel', wheelLines(-10)); - expect(flushed(ws)).toEqual([]); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); mount.fire('wheel', wheelLines(-10)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); @@ -707,7 +722,6 @@ describe("gesture handling is the primary pane's, through the shared pure helper primary._flushWheelSgrQueue(); expect(tileBytes).toContain(PAGE_UP); - expect(tileBytes).toContain(PAGE_DOWN); expect(tileBytes).toBe(sent.join('')); }); @@ -736,7 +750,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper .map((f) => f.d) .join(''); // One page for the slow ticks, one for the wobble's opening event, nothing more. - expect(tileBytes).toBe(PAGE_UP + PAGE_UP); + expect(tileBytes).toBe(''); // rapid reversals cancel unsent movement const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & { terminal: unknown; @@ -895,7 +909,7 @@ describe('rows the tile pushed above the screen itself are not history', () => { const first = wheelLines(-6); mount.fire('wheel', first); expect(first.preventDefault).toHaveBeenCalled(); - expect(flushed(ws)).toEqual([]); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); mount.fire('wheel', wheelLines(-6)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); }); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 94005e2c..66a25cab 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', @@ -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); @@ -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', () => {