From 4813aaf0ba5902ea185b1927d30a59647b4c769a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Sat, 2 May 2026 02:17:53 +0800 Subject: [PATCH] fix(ui-tui): heal same-dimension alt-screen resize drift - Treat same-dimension resize events in alt-screen mode as a repaint signal, because terminal hosts can reflow or restore the physical buffer without changing columns/rows. - Ensure pending resize erases are emitted even when the virtual diff is empty, so stale physical glyphs are still cleared. - Extract alt-screen resize repaint into prepareAltScreenResizeRepaint() for readability. - Add defensive clearTimeout in prepareAltScreenResizeRepaint so rapid resize bursts don't stack redundant delayed repaints. - Add a focused regression test for same-dimension alt-screen resize healing. Addresses #18449 Related to #17961 --- .../hermes-ink/src/ink/ink-resize.test.ts | 50 ++++++++++++ ui-tui/packages/hermes-ink/src/ink/ink.tsx | 77 +++++++++++-------- 2 files changed, 97 insertions(+), 30 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts diff --git a/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts b/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts new file mode 100644 index 00000000000..31039491f89 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts @@ -0,0 +1,50 @@ +import { EventEmitter } from 'events' +import React from 'react' +import { describe, expect, it } from 'vitest' + +import Text from './components/Text.js' +import Ink from './ink.js' +import { CURSOR_HOME, ERASE_SCREEN } from './termio/csi.js' + +class FakeTty extends EventEmitter { + chunks: string[] = [] + columns = 20 + rows = 5 + isTTY = true + + write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean { + this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + cb?.() + return true + } +} + +const tick = () => new Promise(resolve => queueMicrotask(resolve)) + +describe('Ink resize healing', () => { + it('heals same-dimension alt-screen resize events with an erase before repaint', async () => { + const stdout = new FakeTty() + const stdin = new FakeTty() + const stderr = new FakeTty() + const ink = new Ink({ + exitOnCtrlC: false, + patchConsole: false, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + }) + + ink.setAltScreenActive(true) + ink.render(React.createElement(Text, null, 'hello')) + ink.onRender() + stdout.chunks = [] + + stdout.emit('resize') + ink.onRender() + await tick() + + expect(stdout.chunks.join('')).toContain(ERASE_SCREEN + CURSOR_HOME) + + ink.unmount() + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 8a8603cf573..8cdfe781395 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -484,17 +484,22 @@ export default class Ink { private handleResize = () => { const cols = this.options.stdout.columns || 80 const rows = this.options.stdout.rows || 24 + const dimsChanged = cols !== this.terminalColumns || rows !== this.terminalRows - // Terminals often emit 2+ resize events for one user action (window - // settling). Same-dimension events are no-ops; skip to avoid redundant - // frame resets and renders. - if (cols === this.terminalColumns && rows === this.terminalRows) { + // Terminals often emit 2+ resize events for one user action + // (window settling). Same-dimension events are usually no-ops, + // but in alt-screen mode a same-dimension resize can signal a + // terminal host reflow or buffer restore that leaves stale glyphs + // on the physical screen — treat it as a repaint signal. + if (!dimsChanged && !(this.altScreenActive && !this.isPaused && this.options.stdout.isTTY)) { return } - this.terminalColumns = cols - this.terminalRows = rows - this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + if (dimsChanged) { + this.terminalColumns = cols + this.terminalRows = rows + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + } // Pending throttled/drain work captured stale dims — cancel so // the upcoming microtask owns the next frame. @@ -521,26 +526,7 @@ export default class Ink { // doesn't exit alt-screen. Do NOT write ERASE_SCREEN: render() below // can take ~80ms; erasing first leaves the screen blank that whole time. if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) { - if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING) - } - - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true - - // One last repaint after the resize burst settles closes any host-side - // reflow drift the normal diff path can't see. - this.resizeSettleTimer = setTimeout(() => { - this.resizeSettleTimer = null - - if (!this.canAltScreenRepaint()) { - return - } - - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true - this.render(this.currentNode!) - }, 160) + this.prepareAltScreenResizeRepaint() } // Already queued: later events in this burst updated dims/alt-screen @@ -573,6 +559,36 @@ export default class Ink { ) } + private prepareAltScreenResizeRepaint(): void { + // Clear any pending settle timer from a previous resize burst so + // rapid events don't stack redundant delayed repaints. (handleResize + // also clears this, but the defensive clear keeps the method safe + // if it's ever called from other code paths.) + if (this.resizeSettleTimer !== null) { + clearTimeout(this.resizeSettleTimer) + this.resizeSettleTimer = null + } + + if (this.altScreenMouseTracking) { + this.options.stdout.write(ENABLE_MOUSE_TRACKING) + } + + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + + this.resizeSettleTimer = setTimeout(() => { + this.resizeSettleTimer = null + + if (!this.canAltScreenRepaint()) { + return + } + + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + this.render(this.currentNode!) + }, 160) + } + resolveExitPromise: () => void = () => {} rejectExitPromise: (reason?: Error) => void = () => {} unsubscribeExit: () => void = () => {} @@ -919,8 +935,9 @@ export default class Ink { const optimized = optimize(diff) const optimizeMs = performance.now() - tOptimize const hasDiff = optimized.length > 0 + const needsAltScreenErase = this.altScreenActive && this.needsEraseBeforePaint - if (this.altScreenActive && hasDiff) { + if (this.altScreenActive && (hasDiff || needsAltScreenErase)) { // Prepend CSI H to anchor the physical cursor to (0,0) so // log-update's relative moves compute from a known spot (self-healing // against out-of-band cursor drift, see the ALT_SCREEN_ANCHOR_CURSOR @@ -940,7 +957,7 @@ export default class Ink { // 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) { + if (needsAltScreenErase) { this.needsEraseBeforePaint = false optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH) } else { @@ -1062,7 +1079,7 @@ export default class Ink { this.lastDrainMs = 0 // Only track drain on TTY. Piped/non-TTY stdout bypasses flow control. - const trackDrain = this.options.stdout.isTTY && hasDiff + const trackDrain = this.options.stdout.isTTY && optimized.length > 0 const drainStart = trackDrain ? tWrite : 0 if (trackDrain) {