Files
Codeman/test/codex-terminal-output.test.ts
T
Aamer AkhterandSaqeb Akhter d7606366a2 fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
Codex's TUI emits alternate-screen toggles (DECSET/DECRST 47/1047/1049),
scrollback-erase (CSI 3 J), and mouse-tracking enables (?1000-1007) during
startup and on every repaint. xterm.js obeys them: it switches to the
scrollback-less alternate buffer, wipes saved lines, and forwards the scroll
wheel to codex — so the user's conversation history both disappears and
becomes unreachable on each tab switch / pane refresh.

Strip these sequences in two places, leaving the visible-viewport erases
(2J / J) intact so codex can still repaint its own rows:

- Session._handleTerminalOutput: filter the live SSE/WS stream and the
  persisted terminal buffer at the source, for mode === 'codex'.
- GET /api/sessions/:id/terminal: apply the same strip to the replayed
  buffer (ALT_SCREEN_TOGGLE_PATTERN / ERASE_SCROLLBACK_PATTERN) so a
  tab-switch replay keeps full scrollback.

Adds test/codex-terminal-output.test.ts covering the strip (alt-screen and
3J removed, 2J/J preserved, Ctrl+L redraws preserved) and confirming codex
output passes through without Ink row-repair mangling.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 13:18:45 -04:00

251 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_ptyRows: number;
};
function handleOutput(session: Session, data: string): void {
(session as unknown as SessionInternals)._handleTerminalOutput(data);
}
describe('Codex terminal output filtering', () => {
it('keeps browser scrollback guards but skips Codeman row repair in hybrid render mode', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex', codexConfig: { renderMode: 'hybrid' } });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const hybridRedraw = '\x1b[?1049h\x1b[55;1H\x1b[2m• Working (21s)\x1b[3J\x1b[?1006h\x1b[?1049l';
handleOutput(session, hybridRedraw);
expect(emitted[0]).toBe('\x1b[55;1H\x1b[2m• Working (21s)');
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(session.terminalBuffer).toBe(emitted[0]);
});
it('preserves Codex erase-display redraws used by the TUI layout engine', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
handleOutput(session, '\x1b[H\x1b[Jidle redraw');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jidle redraw');
});
it('strips Codex scrollback erase without stripping visible-screen erase', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
});
it('preserves Codex erase-display redraw when the user pressed Ctrl+L', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
session.write('\x0c');
handleOutput(session, '\x1b[H\x1b[Jredraw after clear');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jredraw after clear');
});
it('passes native Codex TUI prompt/status redraws through without row repair', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const bottomBandRedraw =
'\x1b[48;2;42;42;42m' +
'\x1b[60;2H\x1b[K' +
'\x1b[61;39H\x1b[K' +
'\x1b[62;2H\x1b[K' +
'\x1b[52;1H\x1b[49m\x1b[2m• \x1b[1mRunning node -e ...' +
'\x1b[60;1H\x1b[48;2;42;42;42m \r\n' +
'\x1b[1m›\x1b[0m\x1b[48;2;42;42;42m \x1b[2mUse /skills to list available skills\r\n' +
'\x1b[63;3H\x1b[49m\x1b[38;2;246;226;183mgpt-5.5 xhigh\x1b[39m' +
'\x1b[2m · \x1b[38;2;242;181;144mContext 42% left\x1b[39m' +
'\x1b[61;3H';
handleOutput(session, bottomBandRedraw);
expect(emitted[0]).not.toContain('\x1b[52;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[60;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[63;1H\x1b[2K');
expect(emitted[0]).toContain(bottomBandRedraw);
});
it('passes Codex advisory rows through without row repair', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const advisoryRedraw =
'\x1b[55;1H\x1b[2mMessages\x1b[Cto\x1b[Cbe submitted\x1b[Cafter\x1b[Cnext toolcall ' +
'(press esc to interrupt and send immediately)\x1b[56;1H';
handleOutput(session, advisoryRedraw);
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[0]).toContain(advisoryRedraw);
});
it('does not clear Codex resume-picker rows just because an option is selected', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const resumePickerRedraw =
'\x1b[1;2H\x1b[36m\x1b[1mResume a previous session' +
'\x1b[3;2H\x1b[2mType to search Filter: \x1b[35m[Cwd]\x1b[39m\x1b[2m All' +
'\x1b[5;3H\x1b[33m\x1b[48;2;42;42;42m\x1b[1m❯ \x1b[2m22h ago ll' +
'\x1b[6;3H\x1b[2m 1d ago $kb-health' +
'\x1b[60;1H\x1b[2m──── 2 / 2 · 100% ─' +
'\x1b[61;1H enter resume esc exit ↑/↓ browse';
handleOutput(session, resumePickerRedraw);
expect(emitted[0]).not.toContain('\x1b[4;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[5;1H\x1b[2K');
expect(emitted[0]).toContain(resumePickerRedraw);
});
it('does not full-clear sparse Codex resume-picker navigation redraws', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
let sparseResumePickerRedraw = '';
for (let row = 1; row <= 51; row++) {
const col = row % 2 === 0 ? 239 : 27;
sparseResumePickerRedraw += `\x1b[${row};${col}H\x1b[K`;
}
sparseResumePickerRedraw +=
'\x1b[21;3H \x1b[2m9d ago \x1b[mreview this webex room webexteams://im?space=672465b0-4fcb-11f1-9d54-51475df86e3a\x1b[K' +
'\x1b[22;3H\x1b[33m\x1b[1m❯ \x1b[m\x1b[33m\x1b[2m9d ago \x1b[m\x1b[33mcisco hybrid mesh firewall includes support for smart switch enforcement...\x1b[K' +
'\x1b[52;229H\x1b[39m\x1b[2m8\x1b[m';
handleOutput(session, sparseResumePickerRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
expect(emitted[0]).toContain(sparseResumePickerRedraw);
});
it('passes Codex UI rows through when the status band moves downward', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(
session,
'\x1b[55;1H\x1b[2m• Working (1s)\x1b[56;1H\x1b[1m›\x1b[0m ask\x1b[57;3Hgpt-5.5 · Context 80% left'
);
handleOutput(
session,
'\x1b[58;1H\x1b[2m• Working (2s)\x1b[59;1H\x1b[1m›\x1b[0m ask\x1b[60;3Hgpt-5.5 · Context 79% left'
);
expect(emitted[1]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[56;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[57;1H\x1b[2K');
expect(emitted[1]).toContain('\x1b[58;1H');
expect(emitted[1]).not.toContain('\x1b[54;1H\x1b[2K');
});
it('does not full-clear the viewport for stable Codex UI rows at the same position', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const stableRedraw =
'\x1b[58;1H\x1b[2m• Working (2s)\x1b[59;1H\x1b[1m›\x1b[0m ask\x1b[60;3Hgpt-5.5 · Context 79% left';
handleOutput(session, stableRedraw);
handleOutput(session, stableRedraw.replace('2s', '3s'));
expect(emitted[1]).not.toContain('\x1b[H\x1b[2J');
});
it('passes status-only Codex Working redraw rows through', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const workingRedraw = '\x1b[55;1H\x1b[2m• Working (21s)';
handleOutput(session, workingRedraw);
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[0]).toContain(workingRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
});
it('passes Codex spinner Working rows that omit elapsed time parentheses through', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 29;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const spinnerRedraw = '\x1b[24;1H\x1b[38;5;254m\x1b[1m•\x1b[CWorking\x1b[27;3H';
handleOutput(session, spinnerRedraw);
expect(emitted[0]).not.toContain('\x1b[24;1H\x1b[2K');
expect(emitted[0]).toContain(spinnerRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
});
it('does not treat ordinary gpt model mentions as Codex status rows', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const outputRow = '\x1b[20;1Hnormal output comparing gpt-5 and another model';
handleOutput(session, outputRow);
expect(emitted[0]).toContain(outputRow);
expect(emitted[0]).not.toContain('\x1b[19;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[20;1H\x1b[2K');
});
it('does not inject row erases during partial Working spinner ticks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(
session,
'\x1b[55;1H\x1b[2m• Working (1s)' +
'\x1b[56;1H\x1b[1m›\x1b[0m ask' +
'\x1b[57;3Hgpt-5.5 xhigh fast · codeman · Working · Context 79% left'
);
handleOutput(session, '\x1b[55;1H\x1b[2m• Working (2s)');
expect(emitted[1]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[56;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[57;1H\x1b[2K');
expect(emitted[1]).toContain('\x1b[55;1H\x1b[2m• Working (2s)');
});
});