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
45 changes: 45 additions & 0 deletions src/session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,6 +258,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
Expand DownExpand Up@@ -1052,6 +1055,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();
Expand DownExpand Up@@ -1665,6 +1709,7 @@ export class Session extends EventEmitter {
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._codexSeqCarry = '';
this._lastActivityAt = Date.now();
}

Expand Down
37 changes: 36 additions & 1 deletion src/web/routes/session-routes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,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
Expand DownExpand Up@@ -916,7 +941,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.
Expand Down
278 changes: 278 additions & 0 deletions test/codex-terminal-output.test.ts
Original file line numberDiff line numberDiff line change
@@ -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)');
});
});