Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/codex-menu-wheel.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/architecture-invariants.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/web/public/terminal-tile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
55 changes: 53 additions & 2 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,41 @@
}
}

// 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)/;
// 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; 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) {
Expand Down Expand Up @@ -404,6 +439,7 @@
isComposerNavKey,
classifyPredictInput,
isCodexComposerRow,
isCodexSelectionMenu,
cursorViewportRow,
CODEX_COMPOSER_ROW_RE,
BRACKETED_PASTE_START,
Expand Down Expand Up @@ -6057,8 +6093,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).
Expand All @@ -6083,6 +6120,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;
Expand All @@ -6102,6 +6145,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.
*
Expand Down
188 changes: 188 additions & 0 deletions test/codex-menu-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* 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<string, any> = { __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('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(
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);
});
});
48 changes: 48 additions & 0 deletions test/terminal-tile-scroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> } }, 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
Expand Down
Loading