Merge pull request #19307 from NousResearch/bb/fix-terminal-resize-jumble

fix(tui): clear Apple Terminal resize artifacts
This commit is contained in:
brooklyn!
2026-05-03 10:17:15 -07:00
committed by GitHub
3 changed files with 40 additions and 9 deletions

View File

@@ -73,7 +73,13 @@ import {
startSelection, startSelection,
updateSelection updateSelection
} from './selection.js' } 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 { import {
CURSOR_HOME, CURSOR_HOME,
cursorMove, cursorMove,
@@ -82,7 +88,8 @@ import {
DISABLE_MODIFY_OTHER_KEYS, DISABLE_MODIFY_OTHER_KEYS,
ENABLE_KITTY_KEYBOARD, ENABLE_KITTY_KEYBOARD,
ENABLE_MODIFY_OTHER_KEYS, ENABLE_MODIFY_OTHER_KEYS,
ERASE_SCREEN ERASE_SCREEN,
ERASE_SCROLLBACK
} from './termio/csi.js' } from './termio/csi.js'
import { import {
DBP, DBP,
@@ -121,6 +128,11 @@ const ERASE_THEN_HOME_PATCH = Object.freeze({
content: ERASE_SCREEN + CURSOR_HOME 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 // Cached per-Ink-instance, invalidated on resize. frame.cursor.y for
// alt-screen is always terminalRows - 1 (renderer.ts). // alt-screen is always terminalRows - 1 (renderer.ts).
function makeAltScreenParkPatch(terminalRows: number) { function makeAltScreenParkPatch(terminalRows: number) {
@@ -863,17 +875,17 @@ export default class Ink {
// position independently. Parking at bottom (not 0,0) keeps the guide // position independently. Parking at bottom (not 0,0) keeps the guide
// where the user's attention is. // 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 // that changed; cells where new=blank and prev-buffer=blank get skipped
// — but the physical terminal still has stale content there (shorter // — but the physical terminal still has stale content there (shorter
// lines at new width leave old-width text tails visible). ERASE inside // lines at new width leave old-width text tails visible). Apple Terminal
// BSU/ESU is atomic: old content stays visible until the whole // can also preserve alt-screen reflow artifacts in scrollback during
// erase+paint lands, then swaps in one go. Writing ERASE_SCREEN // resize, so it gets CSI 3J in this one recovery path. When BSU/ESU is
// synchronously in handleResize would blank the screen for the ~80ms // supported, the clear+paint lands atomically; otherwise the final state
// render() takes. // is still healed even if the repaint is visible.
if (this.needsEraseBeforePaint) { if (this.needsEraseBeforePaint) {
this.needsEraseBeforePaint = false this.needsEraseBeforePaint = false
optimized.unshift(ERASE_THEN_HOME_PATCH) optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH)
} else { } else {
optimized.unshift(CURSOR_HOME_PATCH) 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 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 // Terminals known to correctly implement the Kitty keyboard protocol
// (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter> // (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
// disambiguation. We previously enabled unconditionally (#23350), assuming // disambiguation. We previously enabled unconditionally (#23350), assuming