From 39d7c01b7b45d7dee62eade97996d3cd325262d4 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Fri, 16 Oct 2026 11:02:46 +0400 Subject: [PATCH 1/9] 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', () => { From 8cb3441dd9f8d7f50877a80697fe162a01bbd839 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 18 Oct 2026 11:23:05 +0400 Subject: [PATCH 2/9] fix(terminal): address scrolling review and preserve upstream speed policies Fix transport-timer regression coverage, zero-delta tile handling and capability documentation. Record isolated Codex 0.163.0 transcript and approval evidence; defer event/batch rate and coarse paging policy changes. Refs: https://github.com/johoja12/Codeman/issues/7 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 8 ++- src/config/cli-registry/schema.ts | 2 +- src/config/cli-registry/stock.ts | 4 +- src/config/cli-registry/types.ts | 8 +-- src/web/public/terminal-tile.js | 9 +-- src/web/public/terminal-ui.js | 88 ++++++----------------- test/cli-registry-schema.test.ts | 9 +++ test/terminal-scroll-routing.test.ts | 103 ++++++++++++++++++--------- test/terminal-tile-scroll.test.ts | 38 ++++++---- test/terminal-touch-tap.test.ts | 26 +++---- 11 files changed, 158 insertions(+), 139 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index adb38400..51fa95e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,7 +204,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187 or tracked Codex**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1836d1bf..fe8f5167 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,11 +225,15 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` ⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. -**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. +**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187 or tracked Codex** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'mouse-tracking'`; Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. + +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. **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`. -**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still pages the CLI transcript. Tracked fullscreen Codex 0.163.0 forwards SGR reports first, as measured above. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to tracked Claude/Codex fullscreen renderers as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 8bdcc281..82d7a614 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -295,7 +295,7 @@ const capabilitiesSchema = z altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']), echo: echoSchema, wheelForward: z - .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) + .object({ mode: z.enum(['never', 'version-gated', 'mouse-tracking']), minVersion: z.string().max(20).optional() }) .strict(), // Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for // the CLI to page its own transcript. Distinct from wheelForward (SGR reports). diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 3075e36f..dc1b1c21 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -733,8 +733,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: 'mouse-tracking' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking + // Without observed mouse tracking, retain PageUp/PageDown fallback. Measured on 0.157.1 (local, // alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp // moved the visible transcript while SGR wheel reports and local scrolling did nothing. // This is the page-key fallback for a hollow pane, not wheel forwarding. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 51eab76e..dcbc4897 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -513,8 +513,8 @@ export interface CliCapabilities { /** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */ predictProfile?: string; }; - /** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */ - wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string }; + /** Wheel forwarding policy. Every enabled policy also requires observed CLI mouse tracking. */ + wheelForward: { mode: 'never' | 'version-gated' | 'mouse-tracking'; minVersion?: string }; /** * The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over * a pane with no local history can be turned into those keys instead of doing nothing @@ -523,8 +523,8 @@ export interface CliCapabilities { * * ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends * plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`). - * A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its - * `wheelForward` stays 'never') yet pages on PageUp/PageDown. + * A CLI can ignore one and honour the other: Codex without observed mouse tracking + * uses this fallback, while fullscreen Codex 0.163.0 accepts SGR wheel reports. * * Absent means no paging, so a CLI nobody has measured keeps plain local scrolling. */ diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 8002e44a..33d3a903 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -51,7 +51,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 */ @@ -1101,7 +1101,8 @@ // 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.wheelDeltaNativeLines(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; @@ -1215,8 +1216,8 @@ } // Coalesces the scroll bytes (SGR wheel reports and page keys alike) into - // one send per 40 ms, bounded by report count 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 diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 3e78470e..632c1a12 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -98,6 +98,10 @@ 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. @@ -117,25 +121,7 @@ : 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. - // 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; @@ -167,17 +153,16 @@ }; } - // 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. + // 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) || []; - const limit = combined.startsWith('\x1b[<') ? SGR_WHEEL_MAX_TICKS : PAGE_KEY_MAX_PER_BATCH; - return reports.slice(-limit).join(''); + return reports.join(''); } // Rows of travel per page key for a terminal `rows` tall (half a screen). @@ -214,15 +199,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 ordinary wheel/touch opening event (at least - // PAGE_KEY_MIN_START_ROWS, under one page of travel) pages AT ONCE, + // - a new gesture's trackpad-sized opening event (at least + // PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) 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; - // - larger openings and subsequent events accumulate through + // - everything else, a wheel notch included, accumulates through // pageKeysForTravel, carried across notches. // Returns { state, keys } with keys '' when nothing is due. function pageKeysForGesture(state, ev, lines, rows, now) { @@ -248,14 +233,13 @@ gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS); } if (gestureStart || reversed) { - // Travel and debt belong to their direction; never make a reversal pay - // off movement accumulated going the other way. - if (prepaid || reversed) pending = 0; + // A pre-paid debt belongs to its gesture and its direction. + if (prepaid) pending = 0; prepaid = false; } if (gestureStart) { const size = Math.abs(lines); - if (size >= PAGE_KEY_MIN_START_ROWS && size < perPage) { + if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) { // 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 }, @@ -422,8 +406,6 @@ PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, wheelDeltaLines, - wheelDeltaWholeLines, - wheelDeltaNativeLines, SGR_WHEEL_MAX_TICKS, sgrWheelReports, wheelTicksForGesture, @@ -1149,7 +1131,7 @@ Object.assign(CodemanApp.prototype, { 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._wheelScrollNativeLines(ev)); + this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev)); return; } // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits @@ -5817,32 +5799,6 @@ Object.assign(CodemanApp.prototype, { return true; }, - // 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 - // 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); - }, - - _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 @@ -5877,7 +5833,6 @@ Object.assign(CodemanApp.prototype, { const session = this.sessions?.get(target.sessionId || this.activeSessionId); const sessionMode = session?.mode || 'claude'; 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 @@ -5934,8 +5889,8 @@ Object.assign(CodemanApp.prototype, { * 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; report counts are 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 and report limits) because this queue @@ -6023,7 +5978,8 @@ 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 deliberate sub-page gesture pages at once. Waiting for half a screen of + * The FIRST event of a trackpad-sized gesture (opening under + * PAGE_KEY_IMMEDIATE_MAX_ROWS) 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 @@ -6031,8 +5987,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 - * wheel opening below the page threshold also pages immediately. Subsequent events - * repay the skipped travel rather than paging once per notch. + * larger opening event is a wheel notch and accumulates as before, so slow + * notches still page once per `perPage` rows. * * Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad * pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|; diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 156d315b..e6dae7d5 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -435,3 +435,12 @@ describe('capability shapes', () => { expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']); }); }); + +describe('observed mouse-tracking wheel policy', () => { + it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => { + const codex = STOCK_CLIS.find((e) => e.id === 'codex')!; + expect(codex.capabilities.wheelForward).toEqual({ mode: 'mouse-tracking' }); + expect(CliEntrySchema.safeParse(codex).success).toBe(true); + expect(codex.capabilities.transcriptPageKeys).toBe(true); + }); +}); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index bb8efdae..786d4aae 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -36,7 +36,18 @@ function loadTerminalUiHarness( // terminal-ui.js hangs CodemanTerminalInput off window; tests read it there. const windowRef: Record<string, any> = { ...windowGlobals }; // Mutable so a test can move time (gesture gaps); defaults to a frozen clock. - const clock = { now: () => 1_000 }; + const timers = new Map<number, { due: number; fn: () => void }>(); + let timerId = 0; + const clock = { + now: () => 1_000, + flushTimers: () => { + for (const [id, timer] of timers) + if (timer.due <= clock.now()) { + timers.delete(id); + timer.fn(); + } + }, + }; const context = vm.createContext({ window: windowRef, CodemanApp, @@ -44,8 +55,12 @@ function loadTerminalUiHarness( _crashDiag: { log: vi.fn() }, performance: clock, requestAnimationFrame: (_fn: () => void) => 1, - setTimeout: (_fn: () => void) => 1, - clearTimeout: vi.fn(), + setTimeout: (fn: () => void, ms = 0) => { + const id = ++timerId; + timers.set(id, { due: clock.now() + ms, fn }); + return id; + }, + clearTimeout: (id: number) => timers.delete(id), Blob: function Blob() {}, URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} }, Worker: function Worker(this: any) { @@ -206,24 +221,30 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 app._maybePageCliTranscript({ shiftKey: false }, -1); // first event: one PageUp app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal inside the gap: accumulates, no page now += 200; + clock.flushTimers(); app._maybePageCliTranscript({ shiftKey: false }, -1); // a reversal after the gap: a PageUp at once now += 1_100; + clock.flushTimers(); app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: a PageDown at once app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[6~' }]); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[5~' }, + { id: 'sess-1', data: '\x1b[6~' }, + ]); }); - 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]) { + it('accumulates coarse wheel notches across pauses instead of prepaying each opening', () => { + // Coarse events accumulate as on master, even across pauses; they do not + // pre-pay a full page each time a new gesture starts. + for (const gapMs of [250, 40, 1200]) { const { app, sent, clock } = hollowApp(); let now = 1_000; clock.now = () => now; for (let i = 0; i < 5; i++) { expect(app._maybePageCliTranscript({ shiftKey: false, deltaY: -100 }, -4)).toBe(true); now += gapMs; + clock.flushTimers(); } app._flushWheelSgrQueue(); expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); @@ -446,7 +467,14 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter h.app._flushWheelSgrQueue(); return h.sent.map((s) => s.data).join(''); }; - return { ...h, keys, advance: (ms: number) => (now += ms) }; + return { + ...h, + keys, + advance: (ms: number) => { + now += ms; + h.clock.flushTimers(); + }, + }; } const count = (keys: string, key: string) => keys.split(key).length - 1; @@ -533,7 +561,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(''); // obsolete queued direction cancelled before its flush + expect(p.keys()).toBe(''); // the opening report was cancelled before its 40ms flush }); it('still pages a deliberate reversal at once after a pause', () => { @@ -541,7 +569,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(DOWN); + expect(p.keys()).toBe(UP + DOWN); }); it('does not let a reversal inherit the other direction’s debt', () => { @@ -863,28 +891,43 @@ describe('consistent scroll gesture adapters', () => { expect(app._wheelSgrQueue || '').toBe(''); }); - it('answers the first ordinary Codex wheel event, without paging every event', () => { + it('preserves accumulated coarse Codex wheel fallback travel', () => { const { app, sent } = hollowApp({ mode: 'codex', rows: 43 }); app._maybePageCliTranscript({ deltaY: -100 }, -4); app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + expect(sent).toEqual([]); 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', () => { + it('preserves sustained report density and cancels obsolete direction before transport', () => { const { coalesceScrollBytes } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; const up = '\x1b[<64;1;1M'; const down = '\x1b[<65;1;1M'; let queued = ''; for (let i = 0; i < 100; i++) queued = coalesceScrollBytes(queued, up); - expect(queued).toBe(up.repeat(5)); + expect(queued).toBe(up.repeat(100)); expect(coalesceScrollBytes(queued, down)).toBe(down); - expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(3)); + expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(4)); expect(coalesceScrollBytes('\x1b[5~', '\x1b[6~')).toBe('\x1b[6~'); }); + it('preserves all 120 reports of a 30-frame touch fling with real batch deadlines', () => { + const { app, sent, clock } = hollowApp(); + let now = 1000; + clock.now = () => now; + app._clientPointToCell = () => ({ col: 1, row: 1 }); + for (let frame = 0; frame < 30; frame++) { + app._sendSyntheticSgrWheel(0, 0, -4); + now += 16; + clock.flushTimers(); + } + now += 40; + clock.flushTimers(); + expect(sent.map((s) => s.data).join('')).toBe('\x1b[<64;1;1M'.repeat(120)); + }); + it('does not deliver a queued wheel batch to a newly selected session', () => { const { app, sent } = hollowApp(); app._queueScrollBytes('\x1b[5~'); @@ -939,16 +982,6 @@ describe('native Codex scrolling instead of an initial page jump', () => { } }); - 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 }); @@ -959,9 +992,9 @@ describe('native Codex scrolling instead of an initial page jump', () => { }); }); -describe('one native tick per wheel event', () => { +describe('native wheel travel preserves upstream units', () => { it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => { - const { wheelDeltaNativeLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + const { wheelDeltaLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; for (const [deltaY, deltaMode] of [ [100, 0], [120, 0], @@ -969,21 +1002,25 @@ describe('one native tick per wheel event', () => { [3, 1], [1, 2], ]) { - expect(wheelDeltaNativeLines({ deltaY, deltaMode }, 43)).toBe(1); - expect(wheelDeltaNativeLines({ deltaY: -deltaY, deltaMode }, 43)).toBe(-1); + expect(wheelDeltaLines({ deltaY, deltaMode }, 43)).toBe( + deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43 + ); + expect(wheelDeltaLines({ deltaY: -deltaY, deltaMode }, 43)).toBe( + -(deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43) + ); } }); it('keeps fractional touchpad distance and primary/tile normalization identical', () => { const { app, windowRef } = hollowApp({ mode: 'codex', cliMouseTracking: true }); - const { wheelDeltaNativeLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput; + const { wheelDeltaLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput; let state; let ticks = 0; for (let i = 0; i < 10; i++) { const ev = { deltaY: -3, deltaMode: 0 }; - const lines = wheelDeltaNativeLines(ev, 43); + const lines = wheelDeltaLines(ev, 43); expect(lines).toBe(-0.12); - expect(app._wheelScrollNativeLines(ev)).toBe(lines); + expect(app._wheelScrollLinesFloat(ev)).toBe(lines); const step = wheelTicksForGesture(state, lines, i * 10); state = step.state; ticks += step.ticks; diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index aca5d7e2..e403132a 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -319,11 +319,11 @@ 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([{ t: 'i', d: UP }]); // first report never waits for a timer - expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); // no seq: never persisted + expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(3) }]); // first report never waits for a timer + expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted mount.fire('wheel', wheelLines(2, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) }); }); it('uses fractional native scrolling for a tracked Codex tile, never PageUp', async () => { @@ -334,14 +334,14 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); }); - it('normalizes a pixel wheel event to one native tick, including accelerated deltas', async () => { + it('preserves coarse pixel wheel travel and caps individual accelerated events', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); mount.fire('wheel', wheel(-100, AT)); - expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]); mount.fire('wheel', wheelLines(-1000, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) }); }); it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => { @@ -366,7 +366,18 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' mount.fire('wheel', wheelLines(-2, AT)); mount.fire('wheel', wheelLines(-2, AT)); - expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]); + expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(2) }]); + expect(flushed(ws)).toEqual([ + { t: 'i', d: UP.repeat(2) }, + { t: 'i', d: UP.repeat(4) }, + ]); + }); + + it('does not discard queued reports when a zero-delta wheel arrives', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); + mount.fire('wheel', wheelLines(-1, AT)); + mount.fire('wheel', wheelLines(-2, AT)); + mount.fire('wheel', wheel(0, AT)); expect(flushed(ws)).toEqual([ { t: 'i', d: UP }, { t: 'i', d: UP.repeat(2) }, @@ -451,14 +462,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; - _wheelScrollNativeLines(ev: unknown): number; + _wheelScrollLinesFloat(ev: unknown): number; _flushWheelSgrQueue(): void; }; const sent: string[] = []; primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data); primary.terminal = new FakeTerminal({}); for (const ev of sequence) { - primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollNativeLines(ev)); + primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLinesFloat(ev)); primary._flushWheelSgrQueue(); } @@ -546,11 +557,11 @@ describe('page-key travel, cap and coalescing', () => { FakeFit.proposed = { cols: 80, rows: 36 }; }); - it('answers sub-page travel immediately and repays it on the next event', async () => { + it('accumulates coarse sub-page travel before sending one page', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); mount.fire('wheel', wheelLines(-10)); - expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + expect(flushed(ws)).toEqual([]); mount.fire('wheel', wheelLines(-10)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); @@ -614,6 +625,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper clock.now = () => now; return (ms: number) => { now += ms; + vi.advanceTimersByTime(ms); }; } @@ -750,7 +762,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(''); // rapid reversals cancel unsent movement + expect(tileBytes).toBe(PAGE_UP); // the first slow tick flushed before the later wobble const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & { terminal: unknown; @@ -909,7 +921,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([{ t: 'i', d: PAGE_UP }]); + expect(flushed(ws)).toEqual([]); 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 66a25cab..3cf86a49 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -738,17 +738,17 @@ describe('terminal touch tap mouse guard', () => { app.terminal = { rows: 40 }; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad): ~110px per notch. - expect(app._wheelScrollLines({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4); + expect(app._wheelScrollLinesFloat({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4.4); // DOM_DELTA_LINE (Firefox mouse wheel): deltaY is already lines. Read as // pixels this rounded to 0 and fell through to the ±1 fallback. - expect(app._wheelScrollLines({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3); - expect(app._wheelScrollLines({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3); + expect(app._wheelScrollLinesFloat({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3); + expect(app._wheelScrollLinesFloat({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3); // DOM_DELTA_PAGE: one page is one screenful. - expect(app._wheelScrollLines({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40); + expect(app._wheelScrollLinesFloat({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40); // A pure horizontal swipe must not fall through to a phantom -1. - expect(app._wheelScrollLines({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0); + expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0); // Shift + macOS trackpad reports the magnitude on deltaX (issue #154). - expect(app._wheelScrollLines({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4); + expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4); }); it('wheel: gates claude forwarding on CLI version 2.1.187+ (unknown or older stays local)', () => { @@ -838,19 +838,19 @@ describe('terminal touch tap mouse guard', () => { const { app } = loadTerminalUiHarness(); // Plain vertical wheel: unchanged, driven by deltaY. - expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4); - expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2); + expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4); + expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2); // Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude. // Old code collapsed this to a fixed -1; now it tracks the horizontal delta. - expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up - expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down // Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored. - expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4); + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4); - // Sub-25px delta still nudges one line in the gesture's direction. - expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1); + // Sub-25px deltas retain their fractional travel. + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-0.2); }); it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => { From 1b6c2664b69f5681110b8cfc206fb6aed26fe3f3 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter <saqeb.akhter@gmail.com> Date: Sun, 18 Oct 2026 13:59:28 +0400 Subject: [PATCH 3/9] fix(terminal): prepay isolated small wheel ticks without amplifying streams Address the remaining PR #645 review: preserve shell Ctrl/sideways local scrolling, restore the paging threshold export, document tile leading-report lifetime, and update internal and user scrolling documentation. Refs: https://github.com/johoja12/Codeman/issues/7 --- docs/architecture-invariants.md | 4 +- docs/wiki/Agent-CLIs.md | 6 +- docs/wiki/The-Dashboard.md | 8 +- docs/wiki/Troubleshooting.md | 4 +- src/web/public/terminal-tile.js | 2 + src/web/public/terminal-ui.js | 28 +++-- test/terminal-scroll-routing.test.ts | 52 ++++++++- test/terminal-tile-scroll.browser.test.ts | 131 +++++++++++++++++++++- test/terminal-tile-scroll.test.ts | 26 ++++- 9 files changed, 232 insertions(+), 29 deletions(-) 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 `<prefix>: <title>` 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 }]); From 131ee1a31e7c7017f986b494f0e75db08a42c2b9 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter <saqeb.akhter@gmail.com> Date: Tue, 20 Oct 2026 12:30:52 +0400 Subject: [PATCH 4/9] test(terminal): restore reversal assertion and clarify scrolling guidance (#7) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- test/terminal-scroll-routing.test.ts | 4 ++-- test/terminal-tile-scroll.test.ts | 5 +++-- test/terminal-touch-tap.test.ts | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 193ba569..54090d5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,7 +206,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 5b58d86e..90174cca 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` ⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. -**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. +**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. **Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index a3bee9d3..bc0dc4cd 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -694,8 +694,8 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => { expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP]); }); - it('responds immediately to a notch without paging for every later notch', () => { - // Five 4-row notches 400ms apart share the pre-paid page debt. + 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. const steps = run([ [-4, 0], [-4, 400], diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 1c9d3f51..8f17e0dd 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -660,7 +660,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper }; } - it('pages a Codex tile, which draws on the alternate screen and ignores SGR wheel reports', async () => { + it('pages an untracked Codex tile through the hollow-pane fallback', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' }); mount.fire('wheel', wheelLines(-18)); @@ -765,6 +765,7 @@ 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('')); }); @@ -792,7 +793,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper const tileBytes = flushed(ws) .map((f) => f.d) .join(''); - // One page for the slow ticks, one for the wobble's opening event, nothing more. + // One page for the slow ticks; the wobble cancels its unsent opening page on reversal. expect(tileBytes).toBe(PAGE_UP); // the first slow tick flushed before the later wobble const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & { diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index d55b2e96..0a467404 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -853,7 +853,7 @@ describe('terminal touch tap mouse guard', () => { expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-0.2); }); - it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => { + it('wheel: encodes SGR 64/65 ticks, caps per event, and sends the first report before batching the rest', () => { const { app } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; From 242bbfcd316a8b7ccb830a122aa07c80393e9a34 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter <saqeb.akhter@gmail.com> Date: Thu, 22 Oct 2026 02:01:17 +0400 Subject: [PATCH 5/9] fix(terminal): capture remote history and preserve isolated wheel travel (#7) --- config/test-suites.ts | 1 + docs/architecture-invariants.md | 4 +- src/remote-hosts.ts | 8 +++ src/remote-pane-history.ts | 13 +++- src/web/public/terminal-tile.js | 6 +- src/web/public/terminal-ui.js | 20 +++--- test/codex-wheel-routing.browser.test.ts | 82 ++++++++++++++++++++++++ test/remote-pane-history.test.ts | 36 ++++++++++- test/terminal-scroll-routing.test.ts | 19 ++++++ 9 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 test/codex-wheel-routing.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index 2148a467..eb10e9a9 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -37,6 +37,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/codex-predictive-echo.test.ts', // also needs a real codex binary 'test/split-pane-terminal.browser.test.ts', 'test/terminal-tile-scroll.browser.test.ts', + 'test/codex-wheel-routing.browser.test.ts', 'test/terminal-touch-paging.browser.test.ts', 'test/shift-enter-keypress.browser.test.ts', 'test/key-tester.browser.test.ts', diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 90174cca..debd7fba 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,13 +225,13 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` ⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. -**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. +**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. **Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. 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. 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. +Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_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`. diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 3dea3bde..3a68aa29 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -89,6 +89,14 @@ export function remoteLoginShellCommand(command: string): string { return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`; } +/** Standard CLI locations missing from non-interactive SSH shells (notably Homebrew). */ +export const REMOTE_PATH_BOOTSTRAP = + 'export PATH=$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH'; + +export function withRemotePathBootstrap(command: string): string { + return `${REMOTE_PATH_BOOTSTRAP}; ${command}`; +} + /** * The CLI text a location overlay should launch for `mode`, or null when this build has no * entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index f4a12e48..b3eab6b0 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -48,7 +48,13 @@ import { exec } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { promisify } from 'node:util'; import type { SessionRemote } from './types/session.js'; -import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js'; +import { + buildSshConnectionArgs, + classifyRemoteAliveExit, + remoteSshTarget, + shellescape, + withRemotePathBootstrap, +} from './remote-hosts.js'; import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js'; import { remoteTmuxLocation } from './tmux-manager.js'; @@ -153,7 +159,10 @@ export function buildRemoteScrollbackCommand( marker: string ): string { const { socket, sessionName } = remoteTmuxLocation(remote, sessionId); - const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker); + // Non-interactive macOS shells omit Homebrew and other per-user CLI paths. + const script = withRemotePathBootstrap( + buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker) + ); // `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`. return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' '); } diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index e1ecf319..0c188d36 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -1074,11 +1074,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 diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 6ab39f55..45afb289 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -137,9 +137,9 @@ return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); } - // 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 + // 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) { @@ -147,8 +147,12 @@ 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 && Math.abs(lines) < 1) { - return { state: { dir, lastAt: now, pending: lines - dir }, ticks: 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); @@ -5852,9 +5856,9 @@ Object.assign(CodemanApp.prototype, { if (sessionMode === 'copilot') return session?.cliMouseTracking === true; if (sessionMode !== 'claude' && sessionMode !== 'codex') 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 + // 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 diff --git a/test/codex-wheel-routing.browser.test.ts b/test/codex-wheel-routing.browser.test.ts new file mode 100644 index 00000000..0cf76b8c --- /dev/null +++ b/test/codex-wheel-routing.browser.test.ts @@ -0,0 +1,82 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +// Adversarial browser layout fixture, not evidence of native Codex history: +// authentic CLI behavior is measured separately in an isolated live pane. +describe('tracked remote Codex wheel routing (#13)', () => { + let server: WebServer; + let browser: Browser; + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + }, 60000); + afterAll(async () => { + await browser?.close(); + await server?.stop(); + }); + + it('sends plain wheel to the CLI rather than scrolling stale repaint frames', async () => { + const page = await browser.newPage({ viewport: { width: 1200, height: 800 } }); + try { + await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal); + await page.waitForTimeout(3500); + await page.evaluate(async () => { + const app = (window as any).app; + app.activeSessionId = 'codex-wheel-probe'; + app.sessions.set(app.activeSessionId, { id: app.activeSessionId, mode: 'codex', cliMouseTracking: true }); + (window as any).scrollInputs = []; + // Cell hit testing is irrelevant to routing; the empty dashboard covers the test terminal. + app._clientPointToCell = () => ({ col: 1, row: 1 }); + app._sendInputEphemeral = (id: string, data: string) => (window as any).scrollInputs.push({ id, data }); + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + await new Promise<void>((resolve) => app.terminal.write('\r\nold repaint row'.repeat(100), resolve)); + app.terminal.scrollToTop(); + }); + const fire = (shiftKey = false) => + page.evaluate((shiftKey) => { + const t = (window as any).app.terminal; + t.element.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, shiftKey, bubbles: true, cancelable: true })); + }, shiftKey); + await fire(); + await page.waitForTimeout(150); + const forwarded = await page.evaluate(() => { + const app = (window as any).app; + return { + inputs: (window as any).scrollInputs, + baseY: app.terminal.buffer.active.baseY, + viewportY: app.terminal.buffer.active.viewportY, + }; + }); + expect(forwarded.inputs.length).toBeGreaterThan(0); + expect(forwarded.inputs.every((i: { id: string }) => i.id === 'codex-wheel-probe')).toBe(true); + expect( + forwarded.inputs + .map((i: { data: string }) => i.data) + .join('') + .match(/\x1b\[<64;/g) + ).toHaveLength(4); + expect(forwarded.viewportY).toBe(forwarded.baseY); + for (const shift of [false, true]) { + await page.evaluate((tracked) => { + const app = (window as any).app; + app.sessions.get(app.activeSessionId).cliMouseTracking = tracked; + app.terminal.scrollToBottom(); + (window as any).scrollInputs = []; + }, shift); + await fire(shift); + await page.waitForTimeout(150); + const local = await page.evaluate(() => { + const b = (window as any).app.terminal.buffer.active; + return { inputs: (window as any).scrollInputs, baseY: b.baseY, viewportY: b.viewportY }; + }); + expect(local.inputs).toEqual([]); + expect(local.viewportY).toBeLessThan(local.baseY); + } + } finally { + await page.close(); + } + }); +}); diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 014e7f48..03f9aa77 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -79,6 +79,40 @@ describe('buildRemoteScrollbackCommand', () => { }); }); +describe('remote history PATH bootstrap', () => { + it('finds tmux installed under the remote home when the login shell omits it', () => { + const root = mkdtempSync(join(tmpdir(), 'remote-history-path-')); + const home = join(root, 'remote home'); + const sshBin = join(root, 'local-bin'); + const remoteBin = join(home, '.local', 'bin'); + mkdirSync(sshBin, { recursive: true }); + mkdirSync(remoteBin, { recursive: true }); + writeFileSync(join(sshBin, 'ssh'), '#!/bin/sh\nfor arg do command="$arg"; done\nexec /bin/sh -c "$command"\n', { + mode: 0o755, + }); + writeFileSync( + join(remoteBin, 'tmux'), + `#!/bin/sh +printf 'older row\\n${MARKER}\\n1\\ncurrent screen\\n' +`, + { mode: 0o755 } + ); + try { + const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 100, 0, MARKER); + const stdout = execFileSync('/bin/sh', ['-c', command], { + encoding: 'utf-8', + env: { ...process.env, HOME: home, PATH: `${sshBin}:/usr/bin:/bin` }, + }); + expect(parseRemoteScrollbackOutput(stdout, MARKER, 0)).toEqual({ + scrollback: 'older row\n', + screen: 'current screen\n', + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('remoteTailBytes', () => { it('adds room for the marker, size and screen to the cap, so a cut lands in the history', () => { expect(remoteTailBytes(2048)).toBe(2048 + 1024 * 1024); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index bc0dc4cd..2694c012 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -909,6 +909,25 @@ describe('consistent scroll gesture adapters', () => { expect(loadTerminalUiHarness().windowRef.CodemanTerminalInput.PAGE_KEY_IMMEDIATE_MAX_ROWS).toBe(2); }); + it('preserves rounded travel for isolated coarse clicks without accumulating a capped backlog', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + for (const direction of [-1, 1]) { + expect(wheelTicksForGesture(null, direction * 4.8, 0).ticks).toBe(direction * 5); // 120px + expect(wheelTicksForGesture(null, direction * 1.6, 0).ticks).toBe(direction * 2); // 40px + let state = null; + let ticks = 0; + for (let i = 0; i < 5; i++) { + const step = wheelTicksForGesture(state, direction * 4.8, i * 300); + state = step.state; + ticks += step.ticks; + } + expect(ticks).toBe(direction * 25); + const capped = wheelTicksForGesture(null, direction * 40, 0); + expect(capped.ticks).toBe(direction * 5); + expect(capped.state.pending).toBe(0); // don't retain travel discarded by the event cap + } + }); + it('pre-pays an isolated deliberate opening tick and repays its signed debt', () => { const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; const opening = wheelTicksForGesture(null, -0.16, 0); // a 4px mouse click From 71bf7288e0d32c7f15f41bb9d5ba103f00747a8d Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:03:04 +0200 Subject: [PATCH 6/9] fix(remote): leave the remote-history PATH bootstrap out of this PR Merge-time fix for #645: the PATH bootstrap for remote history reads (src/remote-pane-history.ts, src/remote-hosts.ts and its test) is a separate fix from the scroll work. On its own it does not reach a host whose tmux lives only under /opt/homebrew/bin, because the link precheck, the launch, the alive probe and discovery still run a bare tmux through the same non-interactive ssh shell. It belongs in a follow-up PR that routes every remote tmux call through one wrapper. These files are restored to the PR's merge base; every scroll change stays. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/remote-hosts.ts | 8 ------- src/remote-pane-history.ts | 13 ++---------- test/remote-pane-history.test.ts | 36 +------------------------------- 3 files changed, 3 insertions(+), 54 deletions(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 3a68aa29..3dea3bde 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -89,14 +89,6 @@ export function remoteLoginShellCommand(command: string): string { return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`; } -/** Standard CLI locations missing from non-interactive SSH shells (notably Homebrew). */ -export const REMOTE_PATH_BOOTSTRAP = - 'export PATH=$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH'; - -export function withRemotePathBootstrap(command: string): string { - return `${REMOTE_PATH_BOOTSTRAP}; ${command}`; -} - /** * The CLI text a location overlay should launch for `mode`, or null when this build has no * entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index b3eab6b0..f4a12e48 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -48,13 +48,7 @@ import { exec } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { promisify } from 'node:util'; import type { SessionRemote } from './types/session.js'; -import { - buildSshConnectionArgs, - classifyRemoteAliveExit, - remoteSshTarget, - shellescape, - withRemotePathBootstrap, -} from './remote-hosts.js'; +import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js'; import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js'; import { remoteTmuxLocation } from './tmux-manager.js'; @@ -159,10 +153,7 @@ export function buildRemoteScrollbackCommand( marker: string ): string { const { socket, sessionName } = remoteTmuxLocation(remote, sessionId); - // Non-interactive macOS shells omit Homebrew and other per-user CLI paths. - const script = withRemotePathBootstrap( - buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker) - ); + const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker); // `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`. return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' '); } diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 03f9aa77..014e7f48 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -79,40 +79,6 @@ describe('buildRemoteScrollbackCommand', () => { }); }); -describe('remote history PATH bootstrap', () => { - it('finds tmux installed under the remote home when the login shell omits it', () => { - const root = mkdtempSync(join(tmpdir(), 'remote-history-path-')); - const home = join(root, 'remote home'); - const sshBin = join(root, 'local-bin'); - const remoteBin = join(home, '.local', 'bin'); - mkdirSync(sshBin, { recursive: true }); - mkdirSync(remoteBin, { recursive: true }); - writeFileSync(join(sshBin, 'ssh'), '#!/bin/sh\nfor arg do command="$arg"; done\nexec /bin/sh -c "$command"\n', { - mode: 0o755, - }); - writeFileSync( - join(remoteBin, 'tmux'), - `#!/bin/sh -printf 'older row\\n${MARKER}\\n1\\ncurrent screen\\n' -`, - { mode: 0o755 } - ); - try { - const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 100, 0, MARKER); - const stdout = execFileSync('/bin/sh', ['-c', command], { - encoding: 'utf-8', - env: { ...process.env, HOME: home, PATH: `${sshBin}:/usr/bin:/bin` }, - }); - expect(parseRemoteScrollbackOutput(stdout, MARKER, 0)).toEqual({ - scrollback: 'older row\n', - screen: 'current screen\n', - }); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -}); - describe('remoteTailBytes', () => { it('adds room for the marker, size and screen to the cap, so a cut lands in the history', () => { expect(remoteTailBytes(2048)).toBe(2048 + 1024 * 1024); From ac8409920b74d30ca525efbee19500c90eb45a6d Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:04:32 +0200 Subject: [PATCH 7/9] docs: say Codex approval prompts do raise the action alert Merge-time fix for #645: architecture-invariants said Codex has no hook alert, so tabAlerts === 'action' could not see its approval prompt. It can: src/web/screen-blocked.ts reads Codex dialogs off the pane (capabilities.workDetect.blockedLine) and broadcasts hook:permission_prompt, which becomes the 'action' tab alert, and both page-key fallbacks check it. The sentence now says the alert exists and that the SGR wheel path deliberately does not consult it, because the 0.163.0 measurement showed wheel reports leave the approval selection unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index debd7fba..2addf9c6 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -229,7 +229,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` **Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. -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. +Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codeman does alert on this prompt: `src/web/screen-blocked.ts` reads Codex dialogs off the pane (`capabilities.workDetect.blockedLine`) and broadcasts `hook:permission_prompt`, which `_onHookPermissionPrompt` (settings-ui.js) records and `updateTabAlertFromHooks` (app.js) turns into the `'action'` tab alert, and both page-key fallbacks check it. The SGR wheel path deliberately does not consult that alert, because this measurement showed wheel reports leave the approval selection unchanged. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. From 0fa79b37ed7b7253e111d41c21f3cc481037bfea Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:04:32 +0200 Subject: [PATCH 8/9] fix(terminal): name any tracked CLI in the tile's wheel-forwarding comments Merge-time fix for #645: the tile's paging gate comment (and the two other tile comments describing the same route, the @fileoverview bullet and the wheel listener's) still said the wheel is forwarded to Claude's fullscreen renderer. Forwarding now covers every tracked CLI the shared gate accepts, tracked Codex included. Comments only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/web/public/terminal-tile.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 0c188d36..1a0c7058 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -18,13 +18,14 @@ * What it does carry over from the primary pane, through the primary pane's * own code aimed at THIS pane (its terminal, its session, never the active * one): - * - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen - * renderer scrolls its own transcript on SGR wheel reports, while this - * xterm holds only replayed repaint frames, so the wheel goes to the CLI - * as reports at the pointer's cell in this pane, through the primary - * pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel - * scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the - * primary pane does, since xterm turns it into a horizontal no-op. + * - SGR wheel forwarding (_maybeForwardWheelToCli): a tracked CLI + * (fullscreen Claude, tracked Codex) scrolls its own transcript on SGR + * wheel reports, while this xterm holds only replayed repaint frames, so + * the wheel goes to the CLI as reports at the pointer's cell in this pane, + * through the primary pane's forwarding gate and its encoding + * (sgrWheelReports). Shift+wheel scrolls the local scrollback itself + * (_maybeScrollLocalOnShift), as the primary pane does, since xterm turns + * it into a horizontal no-op. * - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex * on the alternate screen, Claude's repaint mode) leaves the xterm no * scrollback, so the wheel pages the CLI's own transcript with @@ -1052,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 @@ -1186,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 From 7db4d90bb63a1429fbc96db85474efa49669752e Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:04:32 +0200 Subject: [PATCH 9/9] test(terminal): rename the Codex wheel browser test after what it drives Merge-time fix for #645: the describe title mentioned "remote" and the fork's #13, but the test drives the local primary pane. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/codex-wheel-routing.browser.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/codex-wheel-routing.browser.test.ts b/test/codex-wheel-routing.browser.test.ts index 0cf76b8c..cf573fcc 100644 --- a/test/codex-wheel-routing.browser.test.ts +++ b/test/codex-wheel-routing.browser.test.ts @@ -4,7 +4,7 @@ import { WebServer } from '../src/web/server.js'; // Adversarial browser layout fixture, not evidence of native Codex history: // authentic CLI behavior is measured separately in an isolated live pane. -describe('tracked remote Codex wheel routing (#13)', () => { +describe('tracked Codex wheel routing in the primary pane', () => { let server: WebServer; let browser: Browser; beforeAll(async () => {