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 index e4e109c7221..1ce4ba06661 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts @@ -46,7 +46,105 @@ describe('Ink resize healing', () => { ink.onRender() await tick() - expect(stdout.chunks.join('')).toContain(ERASE_SCREEN + CURSOR_HOME) + // The heal may also erase scrollback (CSI 3J interposed between 2J and H) + // depending on which recovery path runs, so assert the invariant — screen + // erased, then content repainted after — rather than an exact byte run. + const out = stdout.chunks.join('') + expect(out).toContain(ERASE_SCREEN) + expect(out).toContain(CURSOR_HOME) + expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello')) + + ink.unmount() + }) + + // Regression for issue #18449: dragging the terminal back and forth quickly + // emits a BURST of resize events (the single-event test above only covers one + // tick). Each tick resets the frame buffers and arms needsEraseBeforePaint, so + // the burst must still converge to a clean erase+repaint — a stacked event + // must never consume the erase and leave the final paint as a partial diff + // that lets stale glyphs survive. + it('converges to a clean erased frame after a rapid resize burst', 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 = [] + + // Wobble the dimensions like a drag — widen, shrink, grow rows — then + // settle back on the STARTING geometry. Even though the net dimensions are + // unchanged, a host reflow during the burst can have scattered glyphs, so + // the renderer must still heal rather than treat the end state as a no-op. + const wobble: Array<[number, number]> = [ + [30, 5], + [12, 9], + [25, 4], + [20, 5] + ] + + for (const [columns, rows] of wobble) { + stdout.columns = columns + stdout.rows = rows + stdout.emit('resize') + } + + ink.onRender() + await tick() + + // The heal can erase scrollback too (CSI 3J interposed), so assert the + // semantic invariant rather than an exact byte sequence: the screen was + // erased and the content was repainted AFTER the erase — i.e. the final + // frame is a clean repaint, not a partial diff over drifted cells. + const out = stdout.chunks.join('') + expect(out).toContain(ERASE_SCREEN) + expect(out).toContain(CURSOR_HOME) + expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello')) + + ink.unmount() + }) + + // The burst above ends on a same-dimension event; this isolates that worst + // case on its own — a resize event whose dims equal the last known geometry + // (the terminal restored the buffer / reflowed without a net size change) + // must still arm the erase, because the physical screen may carry drift the + // diff path cannot see (see log-update "drift repro"). + it('heals a same-dimension resize even when no React commit changes the tree', 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 = [] + + // Dimensions are identical to the initial render — the tree never changes. + stdout.emit('resize') + ink.onRender() + await tick() + + const out = stdout.chunks.join('') + expect(out).toContain(ERASE_SCREEN) + expect(out).toContain(CURSOR_HOME) + expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello')) ink.unmount() }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 6443493c7b6..33ab9b2f3e3 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' +import { RESIZE_COALESCE_MS } from '../config/timing.js' import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js' import { SECTION_NAMES, sectionMode } from '../domain/details.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' diff --git a/ui-tui/src/config/timing.ts b/ui-tui/src/config/timing.ts index e1811e830dc..9a18796e168 100644 --- a/ui-tui/src/config/timing.ts +++ b/ui-tui/src/config/timing.ts @@ -4,3 +4,11 @@ export const STREAM_SCROLL_BATCH_MS = 96 export const STREAM_TYPING_BATCH_MS = 80 export const TYPING_IDLE_MS = 250 export const REASONING_PULSE_MS = 700 + +// A drag-resize fires a burst of SIGWINCH events (one per pixel step in some +// hosts). Each distinct terminal width remounts the visible transcript rows so +// yoga re-measures off live geometry, so reflowing on every tick stutters the +// drag. Coalesce the burst to at most one reflow per this window (~30fps): +// responsive enough to track the drag, cheap enough to stay smooth, and the +// trailing edge always lands the final width so the settled layout is exact. +export const RESIZE_COALESCE_MS = 32