diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 873d4f793..53da68994 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -9192,6 +9192,308 @@ describe('connectPanePty', () => { expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('schedules WebGL atlas recovery for Vim-style foreground alternate-screen redraws', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.( + '\x1b[2J\x1b[H{"name":"eepo"}\r\n\x1b[2;1H{"name":"expo"}\x1b[K' + ) + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when a foreground rewrite enters alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[HVim package.json') + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + // Why: xterm switches to the alternate buffer while parsing the chunk; + // the write callback observes the post-parse buffer state. + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when the alternate-screen enter sequence splits across chunks', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Why: PTY reads split CSI sequences at arbitrary byte boundaries (real + // vim sessions split cursor moves at 1024-byte chunk edges), so the + // enter sequence itself can straddle two onData chunks. + capturedDataCallback.current?.('\x1b[?104') + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + capturedDataCallback.current?.('9h\x1b[2J\x1b[H~\x1b[K') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when a foreground rewrite leaves alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Vim's final frame erases the status line and restores the normal + // buffer in one chunk; the atlas must still rebuild for the restored + // prompt even though the post-parse buffer is back to normal. + capturedDataCallback.current?.('\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1049l\x1b[?25h') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when one chunk enters and exits alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + let bufferChangeListener: (() => void) | undefined + ;( + pane.terminal.buffer as { + onBufferChange?: (listener: () => void) => { dispose: () => void } + } + ).onBufferChange = (listener) => { + bufferChangeListener = listener + return { dispose: vi.fn() } + } + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // A coalesced backlog flush can parse a whole enter -> draw -> exit TUI + // interaction in one write, netting buffer type back to 'normal'; the + // buffer-switch count is what still marks it as alternate-screen work. + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') + bufferChangeListener?.() + bufferChangeListener?.() + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery for real captured Vim redraw chunks split mid-sequence', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Captured from a real `vim package.json` session: a 1024-byte PTY read + // boundary cuts the cursor move \x1b[30;5H into "\x1b[30" + ";5H". + capturedDataCallback.current?.('"rules": {\x1b[29;15H\x1b[K\x1b[30') + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + + capturedDataCallback.current?.( + ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' + ) + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(2) + } finally { + restoreNavigator() + } + }) + + it('does not schedule WebGL atlas recovery for ordinary foreground shell rewrites', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\r\x1b[Korca % npm test') + + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + it('does not schedule WebGL atlas recovery for plain synchronized foreground frames', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 49ce4062a..f614aea12 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2550,6 +2550,14 @@ export function connectPanePty( forwardPtyResize(cols, rows) }) + // Why: a rewrite chunk can enter AND exit the alternate screen in one parse + // (fast-quitting TUI), netting buffer.active.type back to 'normal'; counting + // switches keeps those redraws visible to the atlas-recovery check. + let alternateScreenBufferSwitches = 0 + const onBufferChangeDisposable = pane.terminal.buffer.onBufferChange?.(() => { + alternateScreenBufferSwitches += 1 + }) + // Why: renderer resize forwarding is fire-and-forget. A visible pane can // finish with xterm at the right grid while the PTY silently kept an older // grid, so Codex keeps composing against stale columns. Fit first so xterm's @@ -3777,6 +3785,25 @@ export function connectPanePty( return decision.prefersRenderRefresh } + // Why: Vim-style TUI redraws are plain-ASCII in-place rewrites whose erased + // cells can keep stale WebGL glyphs until the shared atlas rebuilds. Whether + // a rewrite touched the alternate screen is only authoritative once xterm + // parses the chunk (enter/exit sequences can split across PTY chunks), so + // capture the pre-parse state and decide the rest at parse completion. + function alternateScreenRewriteAtlasRecoveryOnParsed(): () => void { + const wasAlternateScreenBuffer = pane.terminal.buffer.active.type === 'alternate' + const switchesBeforeParse = alternateScreenBufferSwitches + return () => { + if ( + wasAlternateScreenBuffer || + alternateScreenBufferSwitches !== switchesBeforeParse || + pane.terminal.buffer.active.type === 'alternate' + ) { + scheduleTerminalWebglAtlasRecovery() + } + } + } + function shouldForceForegroundRenderRefresh(data: string): { refresh: boolean inPlaceRewrite: boolean @@ -3864,6 +3891,13 @@ export function connectPanePty( !foregroundOutput && hiddenOutputNeedsAtlasRecoveryAfterParse(data) const recoverWebglAtlasAfterParse = renderRefreshDecision.recoverWebglAtlasAfterParse || recoverHiddenWebglAtlasAfterParse + // Why: atlas recovery must repaint from the parsed xterm buffer, not a + // pre-write snapshot that a late TUI redraw can immediately stale. + const onParsedAtlasRecovery = recoverWebglAtlasAfterParse + ? scheduleTerminalWebglAtlasRecovery + : renderRefreshDecision.inPlaceRewrite + ? alternateScreenRewriteAtlasRecoveryOnParsed() + : undefined const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh // Why: see nativeWindowsRewriteNeedsFollowupRenderRefresh — Claude Code's // in-place prompt redraws on Windows ConPTY can paint one frame late, so a @@ -3910,9 +3944,7 @@ export function connectPanePty( foregroundRenderRefreshNeeded), followupForegroundRefresh: nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, - // Why: atlas recovery must repaint from the parsed xterm buffer, not - // a pre-write snapshot that a late TUI redraw can immediately stale. - onParsed: recoverWebglAtlasAfterParse ? scheduleTerminalWebglAtlasRecovery : undefined, + onParsed: onParsedAtlasRecovery, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive @@ -5639,6 +5671,7 @@ export function connectPanePty( onDataDisposable.dispose() terminalCapabilityRepliesDisposable.dispose() onResizeDisposable.dispose() + onBufferChangeDisposable?.dispose() pane.container.removeEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) geometryReportObserver?.disconnect() if (pendingGeometryReportRaf !== null) { diff --git a/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts b/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts new file mode 100644 index 000000000..7ce6e3854 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' + +function writeChunk(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +// Pins the xterm contract the alternate-screen atlas recovery relies on: by the +// time a chunk's write callback runs, buffer.active.type reflects any +// alternate-screen enter/exit parsed from that chunk — even when the sequence +// splits across PTY chunk boundaries. +describe('alternate-screen buffer state at write-callback time', () => { + it('reflects an enter sequence split across two chunks', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + await writeChunk(term, '\x1b[?104') + expect(term.buffer.active.type).toBe('normal') + await writeChunk(term, '9h\x1b[2J\x1b[H~\x1b[K') + expect(term.buffer.active.type).toBe('alternate') + term.dispose() + }) + + it('fires onBufferChange for each switch when one chunk enters and exits', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + let switches = 0 + const disposable = term.buffer.onBufferChange(() => { + switches += 1 + }) + await writeChunk(term, '\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') + expect(term.buffer.active.type).toBe('normal') + expect(switches).toBe(2) + disposable.dispose() + term.dispose() + }) + + it('tracks enter, split redraw, and exit from a real captured vim session', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + // Captured from `vim package.json` (macOS, TERM=xterm-256color): startup chunk. + await writeChunk( + term, + '\x1b[?1049h\x1b[>4;2m\x1b[?1h\x1b=\x1b[?2004h\x1b[?1004h\x1b[1;34r\x1b[?12h\x1b[?12l\x1b[22;2t\x1b[22;1t' + ) + expect(term.buffer.active.type).toBe('alternate') + // Mid-session redraw where a 1024-byte PTY read split \x1b[30;5H in two. + await writeChunk(term, '"rules": {\x1b[29;15H\x1b[K\x1b[30') + await writeChunk( + term, + ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' + ) + expect(term.buffer.active.type).toBe('alternate') + // Vim quit: erase the status line and restore the normal buffer in one chunk. + await writeChunk( + term, + '\x1b[23;2t\x1b[23;1t\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1004l\x1b[?2004l\x1b[?1l\x1b>\x1b[?1049l\x1b[?25h\x1b[>4;m' + ) + expect(term.buffer.active.type).toBe('normal') + term.dispose() + }) +})