diff --git a/.changeset/codex-menu-wheel.md b/.changeset/codex-menu-wheel.md new file mode 100644 index 00000000..6cc6692d --- /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 1a0c7058..101d2c9e 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 c3778ffe..6dcb9d09 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 00000000..be9c9e56 --- /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); + }); +});