diff --git a/src/session.ts b/src/session.ts index 505d9b93..d6e6dfca 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 @@ -1069,6 +1072,21 @@ export class Session extends EventEmitter { // 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, '') @@ -1691,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 92bd643e..98d70f31 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -83,6 +83,12 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; 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. @@ -941,7 +947,10 @@ export function registerSessionRoutes( // 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, ''); + strippedBuffer = strippedBuffer + .replace(ALT_SCREEN_TOGGLE_PATTERN, '') + .replace(ERASE_SCROLLBACK_PATTERN, '') + .replace(MOUSE_TRACKING_PATTERN, ''); } if (tailBytes > 0 && strippedBuffer.length > tailBytes) { diff --git a/test/codex-terminal-output.test.ts b/test/codex-terminal-output.test.ts index 05ca87dc..1e35ec1a 100644 --- a/test/codex-terminal-output.test.ts +++ b/test/codex-terminal-output.test.ts @@ -42,6 +42,34 @@ describe('Codex terminal output filtering', () => { 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' });