mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #116 from aakhter/pr/cod-35-codex-polish
fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
This commit is contained in:
@@ -259,6 +259,9 @@ export class Session extends EventEmitter {
|
||||
private _messages: ClaudeMessage[] = [];
|
||||
private _lineBuffer: string = '';
|
||||
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
||||
// Codex only: trailing partial CSI held back so sequences split across PTY
|
||||
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
|
||||
private _codexSeqCarry: string = '';
|
||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||
@@ -1091,6 +1094,47 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
private _handleTerminalOutput(data: string): void {
|
||||
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
|
||||
// enables that hijack the scroll wheel so the user can't reach scrollback:
|
||||
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
|
||||
// scrollback) — \x1b[?...l switches back.
|
||||
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
|
||||
// the visible viewport — are left intact; the TUI repaints those rows.)
|
||||
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
|
||||
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
|
||||
// xterm.js forwards wheel events to codex instead of scrolling the
|
||||
// viewport, so the conversation is in scrollback but unreachable.
|
||||
// (Focus events at ?1004 are left alone — codeman uses them for
|
||||
// active-tab detection.)
|
||||
// Strip them at the source so neither the persisted buffer nor the live
|
||||
// SSE/WS stream carries them, keeping everything in the main buffer with
|
||||
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
|
||||
// cells they actually target, so the non-erased rows keep their content.
|
||||
if (this.mode === 'codex') {
|
||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||
// until the next buffer replay. Hold back an incomplete digit-only CSI
|
||||
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
|
||||
// prepend it to the next chunk; complete sequences are never held.
|
||||
data = this._codexSeqCarry + data;
|
||||
this._codexSeqCarry = '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
||||
if (splitTail) {
|
||||
this._codexSeqCarry = splitTail[0];
|
||||
data = data.slice(0, -splitTail[0].length);
|
||||
if (!data) return;
|
||||
}
|
||||
data = data
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[3J/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
}
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
@@ -1711,6 +1755,7 @@ export class Session extends EventEmitter {
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._codexSeqCarry = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,31 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
||||
const CTRL_L_PATTERN = /\x0c/g;
|
||||
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
||||
|
||||
/**
|
||||
* Match xterm alternate-screen mode toggles + the standalone scrollback-erase.
|
||||
*
|
||||
* - DECSET/DECRST 47, 1047, 1049 = enter/exit alternate screen buffer
|
||||
* (1049 also saves cursor and clears the alt buffer).
|
||||
* - CSI 3 J = erase saved lines (scrollback).
|
||||
*
|
||||
* Codex emits `\x1b[?1049h` and clear-scrollback sequences during startup and
|
||||
* on repaint. xterm.js obeys them by switching to the alt buffer (no native
|
||||
* scrollback) and wiping saved lines, so the user's conversation history
|
||||
* disappears on every tab switch / pane refresh. Stripping these from the
|
||||
* replayed byte stream keeps everything in the main buffer with scrollback
|
||||
* intact. Mirrors the live-stream strip in Session._handleTerminalOutput.
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g;
|
||||
// Mouse-tracking enables (X10/button/any-event/UTF-8/SGR/alt-scroll) — once on,
|
||||
// xterm.js forwards wheel events to the app instead of scrolling the viewport.
|
||||
// Live streams are stripped at the source, but buffers persisted BEFORE that
|
||||
// strip existed can still carry them; strip on replay for parity.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g;
|
||||
|
||||
/**
|
||||
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
|
||||
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
|
||||
@@ -917,7 +942,17 @@ export function registerSessionRoutes(
|
||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||
// the terminal appears empty when switching tabs.
|
||||
const strippedBuffer = stripInkRedrawBloat(rawBuffer);
|
||||
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
// Strip alt-screen toggles and scrollback-erase from codex byte streams.
|
||||
// xterm.js obeys them by switching to its scrollback-less alt buffer and
|
||||
// wiping saved lines, so conversation history disappears on tab switch.
|
||||
if (session.mode === 'codex') {
|
||||
strippedBuffer = strippedBuffer
|
||||
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
|
||||
.replace(ERASE_SCROLLBACK_PATTERN, '')
|
||||
.replace(MOUSE_TRACKING_PATTERN, '');
|
||||
}
|
||||
|
||||
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
||||
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
|
||||
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
// '\x1b[?1049h' split mid-sequence, then '\x1b[3J' split before its final byte.
|
||||
handleOutput(session, 'before\x1b[?104');
|
||||
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
|
||||
handleOutput(session, 'Jtail');
|
||||
|
||||
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
|
||||
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
|
||||
});
|
||||
|
||||
it('emits nothing for a chunk that is only a partial CSI, and completes it next chunk', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
|
||||
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
|
||||
handleOutput(session, '6h\x1b[55;1H• Working'); // completes ?1006h (stripped); rest passes
|
||||
|
||||
expect(emitted).toEqual(['\x1b[55;1H• Working']);
|
||||
expect(session.terminalBuffer).toBe('\x1b[55;1H• Working');
|
||||
});
|
||||
|
||||
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)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user