Merge pull request #698: fix(terminal): the wheel no longer moves a Codex selection menu's selection

Conflicts resolved by keeping both sides:
- src/web/public/terminal-ui.js (_maybePageCliTranscript): #698's
  Codex-menu consume check sits right after the open-dialog consume
  check (its sibling, as the docblock lists them), and #681's
  unloaded-scrollback fall-through (paneHistoryLines > 0) follows it.
- docs/architecture-invariants.md: the hollow-pane paragraph carries
  #681's unloaded-scrollback sentence and #698's menu rewording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-24 15:21:58 +02:00
commit 0160db9927
6 files changed
+299 -4

No files matched your search

+188
View File
@@ -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
View File
@@ -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