Merge pull request #645: fix(terminal): isolate scroll batches and support tracked Codex scrolling

The remote-history PATH bootstrap that rode along in this PR was left out
at merge (it belongs in its own PR that routes every remote tmux call
through one wrapper), so 242bbfcd's title overstates what lands here.

Conflicts in CLAUDE.md and docs/architecture-invariants.md resolved by
keeping this PR's rewrite of the wheel/touch paragraphs and re-adding
#664's touch-forwarding sentence to both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:29:53 +02:00
commit 3652670a7d
16 files changed
+747 -169

No files matched your search

+2 -2
View File
@@ -768,8 +768,8 @@ const CODEX: CliEntry = {
transcript: 'codex-rollout',
altScreen: 'strip-full',
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward
// ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local,
wheelForward: { mode: 'always' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking
// Without observed mouse tracking, retain PageUp/PageDown fallback. Measured on 0.157.1 (local,
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
// This is the page-key fallback for a hollow pane, not wheel forwarding.
+2 -2
View File
@@ -557,8 +557,8 @@ export interface CliCapabilities {
*
* ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends
* plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`).
* A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its
* `wheelForward` stays 'never') yet pages on PageUp/PageDown.
* A CLI can ignore one and honour the other: Codex without observed mouse tracking
* uses this fallback, while fullscreen Codex 0.163.0 accepts SGR wheel reports.
*
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/
+41 -22
View File
@@ -18,13 +18,14 @@
* What it does carry over from the primary pane, through the primary pane's
* own code aimed at THIS pane (its terminal, its session, never the active
* one):
* - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
* renderer scrolls its own transcript on SGR wheel reports, while this
* xterm holds only replayed repaint frames, so the wheel goes to the CLI
* as reports at the pointer's cell in this pane, through the primary
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* primary pane does, since xterm turns it into a horizontal no-op.
* - SGR wheel forwarding (_maybeForwardWheelToCli): a tracked CLI
* (fullscreen Claude, tracked Codex) scrolls its own transcript on SGR
* wheel reports, while this xterm holds only replayed repaint frames, so
* the wheel goes to the CLI as reports at the pointer's cell in this pane,
* through the primary pane's forwarding gate and its encoding
* (sgrWheelReports). Shift+wheel scrolls the local scrollback itself
* (_maybeScrollLocalOnShift), as the primary pane does, since xterm turns
* it into a horizontal no-op.
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex
* on the alternate screen, Claude's repaint mode) leaves the xterm no
* scrollback, so the wheel pages the CLI's own transcript with
@@ -51,7 +52,7 @@
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelTicksForGesture/coalesceScrollBytes/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
@@ -229,6 +230,7 @@
}
async connect() {
this._wheelNativeStarted = false;
const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10);
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
@@ -1051,7 +1053,7 @@
// Capture phase, because xterm's own wheel handler stopPropagation()s every
// event it consumes, so a bubbling listener here would never see the wheel
// while the pane still has scrollback to scroll. Not passive: the three
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
// routes this pane takes over, forwarding the wheel to a tracked CLI's
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
@@ -1073,11 +1075,11 @@
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
// Fullscreen Claude (2.1.187+) and measured tracked Codex scroll their own
// transcripts on SGR wheel reports while cliMouseTracking is on,
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
// no history for such a pane). Left to xterm, the wheel dragged those stale
// frames, Claude's pinned input box with them, up the tile, or scrolled
// frames, including the CLI's pinned input box, up the tile, or scrolled
// nothing at all. The gate is the primary pane's own, asked for THIS pane
// (its terminal, its session, never the active one), so the CLI rules stay
// in terminal-ui.js and this file names no CLI; the reports go to this
@@ -1090,19 +1092,24 @@
if (this._destroyed || !this.terminal || !ev) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelTicksForGesture) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true;
// SGR coordinates address the live screen, so a report from a scrolled-up
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
if (!lines) return true;
if (this._wheelTickGesture?.dir && this._wheelTickGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
const step = input.wheelTicksForGesture(this._wheelTickGesture, lines, performance.now());
this._wheelTickGesture = step.state;
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
const bytes = input.sgrWheelReports(lines, pos);
const bytes = input.sgrWheelReports(step.ticks, pos);
if (bytes) this._queueScrollBytes(bytes);
return true;
}
@@ -1180,9 +1187,9 @@
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
const target = { terminal: this.terminal, sessionId: this.sessionId };
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
// forwarding session is never paged.
// A wheel for any tracked CLI (a session the forwarding gate accepts) was
// forwarded as SGR reports before this ran (_maybeForwardWheelToCli); the
// gate is repeated so a forwarding session is never paged.
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
// Only from the live screen. The one gate the primary pane never needs: a
@@ -1202,6 +1209,8 @@
// session, which is usually not the active one: a page key would move its
// selector. Consumed, so xterm does not scroll the rows under it either.
if (app.tabAlerts?.get(this.sessionId) === 'action') return true;
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true;
if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now());
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys);
@@ -1209,16 +1218,26 @@
}
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
// one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
// that keeps scrolling after it stops. A narrow
// one send per 40 ms after the leading native report, preserving upstream
// report density and cancelling only unsent movement on reversal. A narrow
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
// (terminal-ui.js; keep the two in step), which flushes to the active
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
// session, over this pane's socket while it is open.
_queueScrollBytes(data) {
if (!data || this._destroyed) return;
if (this._scrollBytes.length > 512) return;
this._scrollBytes += data;
this._scrollBytes = global.CodemanTerminalInput.coalesceScrollBytes(this._scrollBytes, data);
// Tiles have a fixed session: the leading report is once per connect(),
// unlike the primary pane, which resets it on every tab selection.
if (data.startsWith('\x1b[<') && !this._wheelNativeStarted) {
this._wheelNativeStarted = true;
if (this._scrollFlushTimer) clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null;
const bytes = this._scrollBytes;
this._scrollBytes = '';
global.app?._sendInputEphemeral?.(this.sessionId, bytes);
return;
}
if (this._scrollFlushTimer) return;
this._scrollFlushTimer = setTimeout(() => {
this._scrollFlushTimer = null;
+88 -71
View File
@@ -121,17 +121,7 @@
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
}
// The same travel rounded to whole lines for the SGR wheel reports: a pure
// horizontal swipe is 0 (nothing to send), and anything else moves at least
// one line, so the small pixel deltas of a precision touchpad still scroll.
// The body of the primary pane's _wheelScrollLines.
function wheelDeltaWholeLines(ev, rows) {
const lines = wheelDeltaLines(ev, rows);
if (!lines) return 0;
return Math.round(lines) || (lines > 0 ? 1 : -1);
}
// Ticks one gesture batch may report: Claude applies its own scroll-speed
// Ticks one input event may report: Claude applies its own scroll-speed
// multiplier and acceleration on top, so a bigger batch only overshoots.
const SGR_WHEEL_MAX_TICKS = 5;
@@ -147,6 +137,43 @@
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// A deliberate opening event preserves upstream rounding and pre-pays at
// least one tick, so isolated clicks retain their travel. The signed remainder
// is repaid by subsequent same-direction travel: ten 0.1-line events still total one tick. Debt and
// fractions expire with a 150ms gesture gap or reversal; a reversal within
// the gesture accumulates without another pre-payment (like page gestures).
function wheelTicksForGesture(state, lines, now) {
if (!Number.isFinite(lines) || !lines) return { state: state || {}, ticks: 0 };
const dir = Math.sign(lines);
const fresh = !state || typeof state.lastAt !== 'number' || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS;
const reversed = state?.dir && state.dir !== dir;
if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS) {
const rounded = dir * Math.max(1, Math.round(Math.abs(lines)));
return {
state: { dir, lastAt: now, pending: lines - rounded },
ticks: dir * Math.min(Math.abs(rounded), SGR_WHEEL_MAX_TICKS),
};
}
const total = (fresh || reversed ? 0 : state.pending || 0) + lines;
const whole = Math.trunc(total + Math.sign(total) * 1e-9);
return {
state: { dir, lastAt: now, pending: Math.abs(total - whole) < 1e-9 ? 0 : total - whole },
ticks: whole === 0 ? 0 : Math.sign(whole) * Math.min(Math.abs(whole), SGR_WHEEL_MAX_TICKS),
};
}
// Only unsent scroll input can be cancelled. Preserve upstream report density
// within each batch, and replace an obsolete direction immediately.
// Local and remote transports share the same direction-aware gesture queue.
function coalesceScrollBytes(queued, data) {
const direction = (bytes) => bytes?.match(/\x1b\[<(64|65);|\x1b\[(5|6)~/)?.[0];
const previous = direction(queued);
const next = direction(data);
const combined = previous && previous === next ? queued + data : data;
const reports = combined.match(/\x1b\[<(?:64|65);\d+;\d+M|\x1b\[[56]~/g) || [];
return reports.join('');
}
// Rows of travel per page key for a terminal `rows` tall (half a screen).
function pageKeyRows(rows) {
return Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
@@ -388,9 +415,10 @@
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
wheelDeltaLines,
wheelDeltaWholeLines,
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
wheelTicksForGesture,
coalesceScrollBytes,
pageKeysForTravel,
pageKeysForGesture,
PAGE_KEY_GESTURE_GAP_MS,
@@ -1098,7 +1126,7 @@ Object.assign(CodemanApp.prototype, {
// and stops propagation, keeping xterm's scroller out of it entirely.
// Local scrolling goes through terminal.scrollLines() — buffer-level, so
// it keeps working after resets — with our own deltaMode normalization
// (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still
// (_wheelScrollLinesFloat) covering Firefox's line-unit wheels. Two cases still
// belong to xterm and are passed through untouched:
// - mouseTrackingMode active: xterm's own encoder forwards the wheel to
// the PTY (htop/vim with mouse on in a shell pane);
@@ -1114,8 +1142,9 @@ Object.assign(CodemanApp.prototype, {
ev.preventDefault();
ev.stopPropagation();
if (this._shouldForwardWheelToApp(ev)) {
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return;
this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev));
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev));
return;
}
// Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits
@@ -5807,41 +5836,6 @@ Object.assign(CodemanApp.prototype, {
return true;
},
// Wheel forwarding gate for the container wheel handler: no Shift override,
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
// scroll its transcript on SGR wheel reports, which today is claude 2.1.187+
// with mouse tracking on (fullscreen) and nothing else (older Claude Code
// captures wheel as select-menu option navigation, an unknown version is
// treated as older, and inline Claude ignores it). Gemini and codex are
// strip modes too but keep the local wheel — taps/clicks are still forwarded
// for them (harmless no-ops at worst).
//
// Codex USED to forward here and was the #227 regression (DodgyBadger, Codex
// latest / Chrome / Win11: dead wheel in codex, working scrollbar drag).
// Measured on codex-cli 0.147.0 in a bare tmux: it never enables mouse
// tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change
// NOTHING on screen — it runs an inline viewport (`alternate_on=0`) and pushes
// its transcript into the terminal's own scrollback (tmux `history_size`
// grows), so there is no in-app pager to drive and local scrollback IS the
// codex transcript. Forwarding therefore swallowed every tick.
// Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger
// scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and
// Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns
// false on Shift). So under Shift, read whichever axis dominates — otherwise
// deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through
// history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are
// unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving.
//
// `deltaMode` says what UNIT the delta is in, and ignoring it made every
// non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1)
// with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
// Claude mode the same value also capped the forwarded SGR report at one tick.
_wheelScrollLines(ev) {
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
},
/** Unrounded variant for the smooth local-scroll path, which accumulates
* sub-line fractions across events instead of forcing every tiny trackpad
* delta to a whole ±1 line. Same unit handling and Shift-axis trap. The
@@ -5899,11 +5893,11 @@ Object.assign(CodemanApp.prototype, {
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
if (sessionMode !== 'claude') return false;
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// Only while Claude is actually listening for the mouse. In its default
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the
// transcript lives in real scrollback, like codex, and SGR wheel reports are
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// Only while Claude or Codex is actually listening for the mouse. Claude's
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0), like
// older inline Codex, keeps its transcript in real scrollback; SGR reports are
// ignored, so forwarding made every swipe and wheel tick dead. Fullscreen
// (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json)
// turns on alt-screen + mode 1003/1006, which the server records as
@@ -5934,34 +5928,54 @@ Object.assign(CodemanApp.prototype, {
// trackpad emits dozens of wheel events per second and each send becomes a
// tmux send-keys on the server — unbatched, a single flick would spawn a
// process storm. Per-event tick count is capped (Claude applies its own
// scroll-speed multiplier and acceleration on top), and the queue is bounded
// so a wild scroll can't build a backlog that keeps scrolling after the finger
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
// scroll-speed multiplier and acceleration on top). Batching is bounded by
// the 40ms window and the per-event cap, not an aggregate byte/tick limit.
// Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
// The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a
// TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli).
_sendSyntheticSgrWheel(clientX, clientY, lines) {
if (!this.activeSessionId || !lines) return;
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return;
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
const input = window.CodemanTerminalInput;
const state = this._wheelTickSession === this.activeSessionId ? this._wheelTickGesture : null;
if (state?.dir && state.dir !== Math.sign(lines)) this._wheelSgrQueue = '';
const step = input.wheelTicksForGesture(state, lines, performance.now());
this._wheelTickSession = this.activeSessionId;
this._wheelTickGesture = step.state;
this._queueScrollBytes(input.sgrWheelReports(step.ticks, pos));
},
/**
* Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (SGR
* Coalesces scroll input into 40ms batches after the first native report (SGR
* wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a
* tmux send-keys server-side, so per-event writes would spawn a process storm
* on a single flick; the queue is bounded so a wild scroll can't build a
* backlog that keeps scrolling after the finger stops.
* on a single flick. Per-event report limits preserve the upstream wheel/touch
* scaling; a change of direction discards only movement that is still unsent.
*
* A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes,
* terminal-tile.js: same 40ms window, same 512-byte bound) because this queue
* terminal-tile.js: same 40ms window and report limits) because this queue
* flushes to the active session only; keep the two in step.
*/
_queueScrollBytes(data) {
if (!data || !this.activeSessionId) return;
const queued = this._wheelSgrQueue || '';
if (queued.length > 512) return;
this._wheelSgrQueue = queued + data;
// The first native report of a newly selected tab must not wait behind
// replay/atlas work for a 40ms timer (measured 60-250ms in practice).
// Selection generation also covers A -> B -> A when B was never scrolled.
const firstNativeForVisit = data.startsWith('\x1b[<') &&
(this._wheelNativeStartSession !== this.activeSessionId ||
this._wheelNativeStartGeneration !== this._selectGeneration);
const queued = !firstNativeForVisit && this._wheelQueueSession === this.activeSessionId
? this._wheelSgrQueue || '' : '';
this._wheelQueueSession = this.activeSessionId;
this._wheelSgrQueue = window.CodemanTerminalInput.coalesceScrollBytes(queued, data);
if (firstNativeForVisit) {
this._wheelNativeStartSession = this.activeSessionId;
this._wheelNativeStartGeneration = this._selectGeneration;
if (this._wheelSgrFlushTimer) clearTimeout(this._wheelSgrFlushTimer);
this._flushWheelSgrQueue();
return;
}
if (this._wheelSgrFlushTimer) return;
this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40);
},
@@ -5981,9 +5995,8 @@ Object.assign(CodemanApp.prototype, {
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
* byte-identical — so paging is the only gesture that reaches it;
* - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which
* the full strip hides from xterm, and pages on PageUp/PageDown while SGR
* wheel reports do nothing.
* - `codex` with no observed mouse tracking. Native tracked fullscreen
* Codex uses fractional SGR reports; this remains its fallback capability.
*
* ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
* list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
@@ -6015,8 +6028,8 @@ Object.assign(CodemanApp.prototype, {
* unset (the inline renderer, or fullscreen right after a server restart), the
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
* one that never fills the buffer and never accepts the wheel (opencode, codex:
* both ignore SGR wheel reports, #227, and page on PageUp/PageDown). Before
* one that never fills the buffer and does not accept the wheel (OpenCode,
* or Codex without observed mouse tracking). Those modes page instead. Before
* this, all of those produced a completely dead gesture; the #205 reporter
* proved the keyboard route works by paging back through intact text with Fn+Up.
* This is not wheel forwarding: it sends plain keys, and only over a hollow buffer.
@@ -6060,6 +6073,7 @@ Object.assign(CodemanApp.prototype, {
*/
_maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
if (ev?.ctrlKey || Math.abs(ev?.deltaX || 0) > Math.abs(ev?.deltaY || 0)) return this._localScrollbackIsHollow();
if (!this._localScrollbackIsHollow()) return false;
// An open dialog in THIS pane's session (the active one): a page key would
// move its selector. Consumed, so the buffer does not scroll under it either.
@@ -6072,6 +6086,7 @@ Object.assign(CodemanApp.prototype, {
this._pageKeySession = this.activeSessionId;
this._pageKeyGesture = null;
}
if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._wheelSgrQueue = '';
const step = window.CodemanTerminalInput.pageKeysForGesture(
this._pageKeyGesture,
ev,
@@ -6127,7 +6142,9 @@ Object.assign(CodemanApp.prototype, {
// Ephemeral (fire-and-forget): wheel reports are loss-tolerant, so they skip
// the durable seq/ACK queue — no localStorage churn, no "Nb queued" flicker
// in the connection indicator on every scroll tick.
if (data && this.activeSessionId) this._sendInputEphemeral(this.activeSessionId, data);
if (data && this.activeSessionId && this._wheelQueueSession === this.activeSessionId) {
this._sendInputEphemeral(this.activeSessionId, data);
}
},
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report