fix(tui): clear Apple Terminal resize artifacts

Use a deeper alt-screen clear for Apple Terminal resize repaints so host reflow artifacts do not survive the recovery frame.
This commit is contained in:
Brooklyn Nicholson 2026-05-03 12:11:24 -05:00
parent e527240b27
commit 279b656adc
3 changed files with 40 additions and 9 deletions

View file

@ -73,7 +73,13 @@ import {
startSelection,
updateSelection
} from './selection.js'
import { supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js'
import {
needsAltScreenResizeScrollbackClear,
supportsExtendedKeys,
SYNC_OUTPUT_SUPPORTED,
type Terminal,
writeDiffToTerminal
} from './terminal.js'
import {
CURSOR_HOME,
cursorMove,
@ -82,7 +88,8 @@ import {
DISABLE_MODIFY_OTHER_KEYS,
ENABLE_KITTY_KEYBOARD,
ENABLE_MODIFY_OTHER_KEYS,
ERASE_SCREEN
ERASE_SCREEN,
ERASE_SCROLLBACK
} from './termio/csi.js'
import {
DBP,
@ -121,6 +128,11 @@ const ERASE_THEN_HOME_PATCH = Object.freeze({
content: ERASE_SCREEN + CURSOR_HOME
})
const DEEP_ERASE_THEN_HOME_PATCH = Object.freeze({
type: 'stdout' as const,
content: ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
})
// Cached per-Ink-instance, invalidated on resize. frame.cursor.y for
// alt-screen is always terminalRows - 1 (renderer.ts).
function makeAltScreenParkPatch(terminalRows: number) {
@ -863,17 +875,17 @@ export default class Ink {
// position independently. Parking at bottom (not 0,0) keeps the guide
// where the user's attention is.
//
// After resize, prepend ERASE_SCREEN too. The diff only writes cells
// After resize, prepend a clear too. The diff only writes cells
// that changed; cells where new=blank and prev-buffer=blank get skipped
// — but the physical terminal still has stale content there (shorter
// lines at new width leave old-width text tails visible). ERASE inside
// BSU/ESU is atomic: old content stays visible until the whole
// erase+paint lands, then swaps in one go. Writing ERASE_SCREEN
// synchronously in handleResize would blank the screen for the ~80ms
// render() takes.
// lines at new width leave old-width text tails visible). Apple Terminal
// can also preserve alt-screen reflow artifacts in scrollback during
// resize, so it gets CSI 3J in this one recovery path. When BSU/ESU is
// supported, the clear+paint lands atomically; otherwise the final state
// is still healed even if the repaint is visible.
if (this.needsEraseBeforePaint) {
this.needsEraseBeforePaint = false
optimized.unshift(ERASE_THEN_HOME_PATCH)
optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH)
} else {
optimized.unshift(CURSOR_HOME_PATCH)
}

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { needsAltScreenResizeScrollbackClear } from './terminal.js'
describe('terminal resize quirks', () => {
it('uses a deeper alt-screen resize clear for Apple Terminal', () => {
expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(true)
expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: ' Apple_Terminal ' })).toBe(true)
})
it('keeps the normal resize repaint path for modern terminals', () => {
expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: 'vscode' })).toBe(false)
expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: 'iTerm.app' })).toBe(false)
})
})

View file

@ -168,6 +168,10 @@ export function isXtermJs(): boolean {
return xtversionName?.startsWith('xterm.js') ?? false
}
export function needsAltScreenResizeScrollbackClear(env: NodeJS.ProcessEnv = process.env): boolean {
return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal'
}
// Terminals known to correctly implement the Kitty keyboard protocol
// (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
// disambiguation. We previously enabled unconditionally (#23350), assuming