Fix Vim alternate-screen terminal redraw recovery (#7142)

* Fix Vim alternate-screen terminal redraw recovery

* Decide alternate-screen atlas recovery from parsed buffer state

Replace the raw-chunk scan for alternate-screen enter sequences with the
xterm parser's own verdict: in-place rewrite chunks check
buffer.active.type (plus a parse-time buffer-switch count) inside the
write callback, where the buffer is authoritative. Covers enter/exit
sequences split across PTY chunk boundaries and full enter-exit cycles
coalesced into one write, and deletes the hand-rolled CSI parser.

Adds regression tests from a real captured vim session (including a
CSI sequence split at a 1024-byte PTY read boundary) and a contract
pin against the real @xterm/headless parser.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-02 21:59:17 -07:00 committed by GitHub
parent f7ec1c727e
commit 4c4cbf2d76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 396 additions and 3 deletions

View File

@ -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 {

View File

@ -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) {

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/headless'
function writeChunk(term: Terminal, data: string): Promise<void> {
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()
})
})