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
This commit is contained in:
Saqeb Akhter committed 2026-10-16 11:02:46 +04:00
1 parent b6218c1490
commit 39d7c01b7b
5 files changed
+342 -96

No files matched your search

+21 -6
View File
@@ -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;
+99 -48
View File
@@ -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