diff --git a/src/session.ts b/src/session.ts index d0657d4f5..d6e6dfcae 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 @@ -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(); @@ -1665,6 +1709,7 @@ export class Session extends EventEmitter { this._errorBuffer = ''; this._messages = []; this._lineBuffer = ''; + this._codexSeqCarry = ''; this._lastActivityAt = Date.now(); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 60b34cb36..98d70f315 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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 @@ -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. diff --git a/test/codex-terminal-output.test.ts b/test/codex-terminal-output.test.ts new file mode 100644 index 000000000..1e35ec1a0 --- /dev/null +++ b/test/codex-terminal-output.test.ts @@ -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)'); + }); +});