From 9fa6d98c7cbe764fceaa59d36918af8d41b48fc3 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:28:42 +0800 Subject: [PATCH 1/2] fix(terminal): the wheel no longer moves a Codex selection menu's selection --- .changeset/codex-menu-wheel.md | 5 + src/web/public/terminal-tile.js | 3 + src/web/public/terminal-ui.js | 56 +++++++++- test/codex-menu-scroll.test.ts | 176 ++++++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 .changeset/codex-menu-wheel.md create mode 100644 test/codex-menu-scroll.test.ts diff --git a/.changeset/codex-menu-wheel.md b/.changeset/codex-menu-wheel.md new file mode 100644 index 000000000..6cc6692de --- /dev/null +++ b/.changeset/codex-menu-wheel.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +Scrolling over a Codex selection menu no longer moves its selection. Codex does not track the mouse on 0.162 to 0.164, so Codeman turns the wheel into PageUp and PageDown, which those menus use to move the selection: at the "Implement this plan?" prompt one notch down landed on "No, stay in Plan mode" and one up on "Yes, implement this plan". The wheel is now consumed while such a menu is showing (main pane and tiles) and pages the transcript again once it closes. diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 1a0c70584..101d2c9e2 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -1209,6 +1209,9 @@ // 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; + // A Codex selection menu is not an alert, but PageUp/PageDown move its selection (see + // CodemanTerminalInput.isCodexSelectionMenu), so it consumes the wheel the same way. + if (app._codexMenuOpen?.({ terminal: this.terminal, sessionId: this.sessionId })) 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()); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index c3778ffec..6dcb9d095 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -319,6 +319,42 @@ } } + // A Codex selection menu on screen: the "Implement this plan?" prompt, the /model, /permissions and + // /review pickers, an approval, a question or the trust prompt. Each draws its options as numbered rows + // with the selected one marked `› N. ` (a question indents it two spaces, and its notes field reads + // `› Add notes`) and a key-hint footer (`enter select · esc back`, `enter + // continue · esc back`, `Press enter to confirm ...`). Codex uses PageUp and PageDown there to move + // the SELECTION (measured on 0.164.0: PageDown from option 1 lands on option 3), so a wheel turned + // into page keys would walk the cursor through the options, one notch onto "Yes, implement this plan". + // Read off the live screen rows (the last `rows` rows of the active buffer), counting non-blank rows from + // the bottom: the trust prompt is drawn at the TOP of an otherwise blank screen. + const CODEX_MENU_OPTION_RE = /^ {0,2}› (?:\d+\. |Add notes)/; + const CODEX_MENU_SCAN_ROWS = 24; + // A wrapped key hint continues on rows that start in lower case (`cancel`, `esc to interrupt`) or with a separator. + const CODEX_MENU_HINT_WRAP_RE = /^ {2}[a-z←|]/; + const CODEX_MENU_FOOTER_RE = /^ {1,4}(?:Press enter to confirm|enter (?:select|continue)|tab (?:to add|or esc to clear) notes)/; + function isCodexSelectionMenu(terminal) { + try { + const buf = terminal.buffer.active; + // The non-blank rows from the bottom of the screen up. A menu's key hints are the last thing drawn, and a + // narrow pane wraps them over more rows that start in lower case: nothing else may sit under them, so + // an answer that quotes the hints (above the composer, a tip and a status row) is not a menu. + const rows = []; + for (let y = buf.baseY + terminal.rows - 1; y >= buf.baseY && rows.length < CODEX_MENU_SCAN_ROWS; y--) { + const text = buf.getLine(y)?.translateToString(true) ?? ''; + if (text.trim()) rows.push(text); + } + let footerAt = 0; + while (footerAt < rows.length && !CODEX_MENU_FOOTER_RE.test(rows[footerAt])) { + if (!CODEX_MENU_HINT_WRAP_RE.test(rows[footerAt])) return false; + footerAt++; + } + return footerAt < rows.length && rows.slice(footerAt + 1).some((text) => CODEX_MENU_OPTION_RE.test(text)); + } catch { + return false; + } + } + // Screen row of the cursor, for the local-echo prompt finders: xterm's cursorY is // baseY-relative, so a viewport parked above the bottom shifts it. Null when off screen. function cursorViewportRow(terminal) { @@ -404,6 +440,7 @@ isComposerNavKey, classifyPredictInput, isCodexComposerRow, + isCodexSelectionMenu, cursorViewportRow, CODEX_COMPOSER_ROW_RE, BRACKETED_PASTE_START, @@ -6057,8 +6094,9 @@ Object.assign(CodemanApp.prototype, { * pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|; * the touch path locks its own axis before it gets here), and any gesture while * the session has an open dialog (tab alert 'action', set by a pending - * permission_prompt or elicitation_dialog), so a page key never reaches its - * selector. + * permission_prompt or elicitation_dialog) or while a Codex selection menu is on + * screen (the "Implement this plan?" prompt, a /model picker; see + * isCodexSelectionMenu), so a page key never reaches its selector. * * @returns true when the gesture was consumed here (the caller must not also * scroll locally). @@ -6083,6 +6121,12 @@ Object.assign(CodemanApp.prototype, { this._logScrollRouting('page-keys-dialog-open'); return true; } + // Codex's own selection menus (the "Implement this plan?" prompt among them) are not alerts, but PageUp + // and PageDown move their selection, so the wheel is consumed there too. + if (this._codexMenuOpen()) { + this._logScrollRouting('page-keys-menu-open'); + return true; + } // Leftover travel and the gesture in progress belong to the tab they were made on. if (this._pageKeySession !== this.activeSessionId) { this._pageKeySession = this.activeSessionId; @@ -6102,6 +6146,14 @@ Object.assign(CodemanApp.prototype, { return true; }, + // Is a Codex selection menu showing in this pane (or the pane `target` names: a tile passes its own + // terminal and session)? See isCodexSelectionMenu. + _codexMenuOpen(target = {}) { + const session = this.sessions?.get(target.sessionId || this.activeSessionId); + if (session?.mode !== 'codex') return false; + return window.CodemanTerminalInput.isCodexSelectionMenu(target.terminal || this.terminal); + }, + /** * One line in the console saying WHY a scroll gesture went where it went. * diff --git a/test/codex-menu-scroll.test.ts b/test/codex-menu-scroll.test.ts new file mode 100644 index 000000000..be9c9e56c --- /dev/null +++ b/test/codex-menu-scroll.test.ts @@ -0,0 +1,176 @@ +/** + * Codex's selection menus (the "Implement this plan?" prompt, the /model, /permissions and /review pickers, + * approvals, questions, the trust prompt) take PageUp and PageDown as "move the selection". Codeman turns + * the wheel into those keys for a Codex pane that does not track the mouse (codex-cli 0.162 and 0.164 never + * do), so a wheel notch walked the cursor through the options: one notch down landed on "No, stay in Plan + * mode", one up on "Yes, implement this plan". The pane must consume the wheel there and send nothing. + * + * The screens are the live `tmux capture-pane` frames in test/fixtures/codex-dialogs/. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id); +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const fixture = (name: string) => readFileSync(resolve(import.meta.dirname, 'fixtures/codex-dialogs', name), 'utf8'); + +/** A terminal whose screen shows `text`, padded to `rows` rows (what xterm holds for a hollow pane). */ +function terminalShowing(text: string, rows = 40) { + const lines = text.replace(/\n+$/, '').split('\n'); + // A capture taller than the pane keeps its bottom rows (the menu), a shorter one sits at the top. + const shown = lines.length > rows ? lines.slice(-rows) : lines; + const screen = Array.from({ length: rows }, (_, i) => shown[i] ?? ''); + return { + cols: 100, + rows, + modes: { mouseTrackingMode: 'none' }, + buffer: { + active: { + type: 'normal', + baseY: 0, + viewportY: 0, + length: rows, + getLine: (y: number) => (screen[y] === undefined ? undefined : { translateToString: () => screen[y] }), + }, + }, + }; +} + +function load() { + const CodemanApp = function CodemanApp(this: any) {}; + const windowRef: Record = { __codemanTranscriptPageKeys: PAGE_KEY_MODES }; + const context = vm.createContext({ + window: windowRef, + CodemanApp, + console: { warn: vi.fn(), log: vi.fn() }, + _crashDiag: { log: vi.fn() }, + performance: { now: () => 1_000 }, + requestAnimationFrame: () => 1, + setTimeout: () => 1, + clearTimeout: () => undefined, + Blob: function Blob() {}, + URL: { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} }, + Worker: function Worker(this: any) { + this.postMessage = () => {}; + }, + MobileDetection: { isTouchDevice: () => true }, + DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g, + TERMINAL_CHUNK_SIZE: 32 * 1024, + }); + vm.runInContext(readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8'), context, { filename: 'terminal-ui.js' }); + return { app: new (CodemanApp as any)(), windowRef }; +} + +/** Every dialog or menu a Codex pane can be sitting in, and the screens where PageUp must still page. */ +const MENUS = [ + 'implement-plan.txt', + 'model-popup.txt', + 'permissions-popup.txt', + 'review-popup.txt', + 'exec-approval.txt', + 'exec-approval-w36.txt', + 'edit-approval.txt', + 'question.txt', + 'question-1of2.txt', + 'question-notes.txt', + 'trust-dialog.txt', + 'trust-dialog-back.txt', + 'trust-dialog-w40.txt', + 'exec-approval-w44.txt', + 'exec-approval-60x14.txt', + 'exec-approval-long-command.txt', + 'network-escalation-approval.txt', + 'question-option2-selected.txt', + 'question-2of2-submit-all.txt', + 'question-notes-typed.txt', + 'question-w36.txt', // the key hint one segment per row + 'question-w60.txt', +]; +const NOT_MENUS = [ + 'idle-start.txt', + 'composer-typed.txt', + 'working.txt', + 'after-approve.txt', + 'after-deny.txt', + 'after-question.txt', + 'slash-popup.txt', + 'transcript-quotes-dialog.txt', + 'composer-typed-dialog-text.txt', + 'composer-typed-dialog-text-w60.txt', +]; + +describe('isCodexSelectionMenu on live captures', () => { + const { windowRef } = load(); + const isMenu = (name: string) => windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminalShowing(fixture(name))); + + it.each(MENUS)('reads %s as a menu', (name) => { + expect(isMenu(name), name).toBe(true); + }); + + it.each(NOT_MENUS)('does not read %s as a menu', (name) => { + expect(isMenu(name), name).toBe(false); + }); + + it('is false for a terminal it cannot read', () => { + expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(undefined)).toBe(false); + expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu({ rows: 10, buffer: { active: { baseY: 0 } } })).toBe( + false + ); + }); +}); + +describe('the wheel over a Codex pane', () => { + function codexPane(screen: string) { + const { app } = load(); + const sent: string[] = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: false }]]); + app._sendInputEphemeral = (_id: string, data: string) => sent.push(data); + app.terminal = terminalShowing(screen); + return { app, sent }; + } + + it('sends no page key while the "Implement this plan?" menu is open, and consumes the wheel', () => { + const { app, sent } = codexPane(fixture('implement-plan.txt')); + for (const lines of [-30, 30, -30, 30]) expect(app._maybePageCliTranscript({ shiftKey: false }, lines)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); + }); + + it('still pages the transcript from the composer', () => { + const { app, sent } = codexPane(fixture('composer-typed.txt')); + expect(app._maybePageCliTranscript({ shiftKey: false }, -30)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual(['\x1b[5~']); + }); + + it('only looks at Codex: another page-key CLI showing the same text is paged as before', () => { + const { app, sent } = codexPane(fixture('implement-plan.txt')); + app.sessions.set('sess-1', { mode: 'opencode' }); + app._maybePageCliTranscript({ shiftKey: false }, -30); + app._flushWheelSgrQueue(); + expect(sent).toEqual(['\x1b[5~']); + }); +}); + +describe('a tile asks the same question', () => { + it('consumes the wheel for an open Codex menu in its own terminal and session', () => { + const tile = readFileSync(resolve(publicDir, 'terminal-tile.js'), 'utf8'); + expect(tile).toContain('app._codexMenuOpen?.({ terminal: this.terminal, sessionId: this.sessionId })'); + const { app } = load(); + app.sessions = new Map([ + ['active', { mode: 'claude' }], + ['tile', { mode: 'codex' }], + ]); + app.terminal = terminalShowing(fixture('composer-typed.txt')); + const tileTerminal = terminalShowing(fixture('implement-plan.txt')); + // the tile's own terminal and session decide, not the active pane's + expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'tile' })).toBe(true); + expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'active' })).toBe(false); + expect(app._codexMenuOpen()).toBe(false); + }); +}); From 372df864b9561559c17caa6f0ee91ee4130f0f76 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:10:38 +0800 Subject: [PATCH 2/2] fix(terminal): review of the Codex menu wheel guard --- docs/architecture-invariants.md | 2 +- src/web/public/terminal-ui.js | 3 +- test/codex-menu-scroll.test.ts | 12 ++++++++ test/terminal-tile-scroll.test.ts | 48 +++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f446f4a1a..a80ae96d2 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -243,7 +243,7 @@ Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Fi **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). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_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`. +**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. ⚠️ Four 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), anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), and anything while a Codex selection menu is on the pane's screen (the "Implement this plan?" prompt, the /model, /permissions and /review pickers, a question, an approval, the trust prompt). All of them exist so a page key never lands in an open selector: Codex binds PageUp/PageDown to the SELECTION in its menus (codex-cli 0.164.0 measured: PageDown from option 1 lands on option 3), so a wheel turned into page keys walked the cursor through the options, one notch onto "Yes, implement this plan". The menu is read off the screen by `CodemanTerminalInput.isCodexSelectionMenu` (a selected numbered option above a key-hint footer that is the last thing drawn; an answer that quotes the hints above the composer is not a menu), not from an alert: the plan prompt is deliberately NOT a blocked dialog (`test/codex-blocked-detection.test.ts`), so nothing about blocked state or notifications changes. The primary pane logs the two dialog-like cases as `page-keys-dialog-open` and `page-keys-menu-open` so a wheel dead under a stale alert or an open menu leaves a `[scroll]` line. The alert check and the menu check are the two rules outside the helper, because they are per pane: the primary pane reads `tabAlerts` and its own terminal for `activeSessionId`, a tile its own `sessionId` and terminal (usually not the active ones; `_codexMenuOpen(target)`). ⚠️ 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] → 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/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 6dcb9d095..b378c9eb6 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -329,7 +329,6 @@ // Read off the live screen rows (the last `rows` rows of the active buffer), counting non-blank rows from // the bottom: the trust prompt is drawn at the TOP of an otherwise blank screen. const CODEX_MENU_OPTION_RE = /^ {0,2}› (?:\d+\. |Add notes)/; - const CODEX_MENU_SCAN_ROWS = 24; // A wrapped key hint continues on rows that start in lower case (`cancel`, `esc to interrupt`) or with a separator. const CODEX_MENU_HINT_WRAP_RE = /^ {2}[a-z←|]/; const CODEX_MENU_FOOTER_RE = /^ {1,4}(?:Press enter to confirm|enter (?:select|continue)|tab (?:to add|or esc to clear) notes)/; @@ -340,7 +339,7 @@ // narrow pane wraps them over more rows that start in lower case: nothing else may sit under them, so // an answer that quotes the hints (above the composer, a tip and a status row) is not a menu. const rows = []; - for (let y = buf.baseY + terminal.rows - 1; y >= buf.baseY && rows.length < CODEX_MENU_SCAN_ROWS; y--) { + for (let y = buf.baseY + terminal.rows - 1; y >= buf.baseY; y--) { const text = buf.getLine(y)?.translateToString(true) ?? ''; if (text.trim()) rows.push(text); } diff --git a/test/codex-menu-scroll.test.ts b/test/codex-menu-scroll.test.ts index be9c9e56c..baf7724a0 100644 --- a/test/codex-menu-scroll.test.ts +++ b/test/codex-menu-scroll.test.ts @@ -115,6 +115,18 @@ describe('isCodexSelectionMenu on live captures', () => { expect(isMenu(name), name).toBe(false); }); + it('still reads a narrow question whose option descriptions wrap over many rows', () => { + // The selected option sat 16 rows up from the bottom at 36 columns; sentence-length descriptions on + // a phone-width pane push it further, and an earlier row cap lost it (the menu was missed and the + // wheel moved the selection again). + const rows = fixture('question-w36.txt').split('\n'); + const at = rows.findIndex((row) => /^\s{0,2}› 1\./.test(row)); + expect(at).toBeGreaterThan(-1); + rows.splice(at + 1, 0, ...Array.from({ length: 12 }, (_, i) => ` wrapped description row ${i + 1}`)); + const terminal = terminalShowing(rows.join('\n'), 60); + expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminal)).toBe(true); + }); + it('is false for a terminal it cannot read', () => { expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(undefined)).toBe(false); expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu({ rows: 10, buffer: { active: { baseY: 0 } } })).toBe( diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 8f17e0dd2..42cab6b82 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -843,6 +843,54 @@ describe("the dialog check reads the TILE's session, never the active tab's", () }); }); +describe("a Codex selection menu on the tile's own screen", () => { + const MENU = [ + ' Implement this plan?', + '', + '› 1. Yes, implement this plan Switch to Default and start coding', + ' 2. Yes, clear context and implement Start a fresh thread (current context: 2% used)', + ' 3. No, stay in Plan mode Continue planning with the model', + '', + ' enter select · esc back', + ]; + /** Puts `rows` on the bottom of the tile's 24-row screen, the way xterm's buffer answers getLine. */ + function showOnScreen(term: { rows: number; buffer: { active: Record } }, rows: string[]) { + const screen = [...Array(term.rows - rows.length).fill(''), ...rows]; + term.buffer.active.getLine = (y: number) => ({ translateToString: () => screen[y] ?? '' }); + } + + it('consumes the wheel without paging, and pages again once the menu is gone', async () => { + const app = makeApp({ other: { mode: 'claude' }, 's-tile': { mode: 'codex' } }, 'other'); + const { ws, term, mount } = await connectTile(app, { mode: 'codex' }); + showOnScreen(term, MENU); + + for (const lines of [-18, 18, -18]) { + const ev = wheelLines(lines); + mount.fire('wheel', ev); + // Consumed (xterm does not scroll the stale rows under the menu either), and no key was sent. + expect(ev.preventDefault).toHaveBeenCalled(); + } + expect(flushed(ws)).toEqual([]); + + // Menu closed: the composer is back on screen and the same wheel pages the transcript. + showOnScreen(term, ['› Ask Codex to do anything', '', ' GPT-6-Astra medium · ~/cases/x', ' ? for shortcuts']); + mount.fire('wheel', wheelLines(-18)); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it("asks about THIS tile's terminal, not the active tab's", async () => { + const app = makeApp({ other: { mode: 'codex' }, 's-tile': { mode: 'codex' } }, 'other'); + (app as App & { terminal: unknown }).terminal = { + rows: 24, + buffer: { active: { getLine: () => ({ translateToString: () => '' }) } }, + }; + const { ws, term, mount } = await connectTile(app, { mode: 'codex' }); + showOnScreen(term, MENU); + mount.fire('wheel', wheelLines(-18)); + expect(flushed(ws)).toEqual([]); + }); +}); + describe('rows the tile pushed above the screen itself are not history', () => { it('pages after a one-screen capture taken at a taller size (the first load)', async () => { serveCapture(lines(40), 40); // the PTY was 40 rows when it was captured