mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(terminal): the wheel no longer moves a Codex selection menu's selection
This commit is contained in:
1 parent
002de32a08
commit
9fa6d98c7c
4 files changed
+238
-2
No files matched your search
@@ -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<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('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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user