fix(tui): coalesce drag-resize reflow + harden resize-burst heal coverage

Two resize fixes for a steadier TUI under aggressive terminal resizing.

1. Drag-resize flicker (useMainApp): `cols` was synced to
   `stdout.columns` synchronously on every 'resize' event. Each distinct
   width remounts the visible transcript rows (they're keyed on cols so
   yoga re-measures off live geometry), so a drag — which fires a burst of
   resize events — turned into a per-tick remount storm that flickers and
   stutters. Throttle the sync with a leading+trailing edge: the first
   event reflows immediately (stays responsive), the rest collapse to at
   most one reflow per RESIZE_COALESCE_MS (~30fps), and the trailing edge
   always applies the final width so the settled layout is exact.

2. Resize-burst heal coverage (#18449): the existing ink-resize test only
   exercised a single same-dimension event. Add two regressions that drive
   a rapid resize *burst* (wobbling dims that settle back to the start, and
   an isolated same-dimension event with no tree change) and assert the
   renderer converges to a clean erased repaint — screen erased, then
   content repainted after — rather than a partial diff over drifted cells.

   This also relaxes the pre-existing single-event assertion, which
   hard-coded the exact bytes `ESC[2J ESC[H`; the heal legitimately
   interposes `ESC[3J` (erase scrollback) on some recovery paths, so all
   three tests now assert the semantic invariant instead of a byte run.
This commit is contained in:
talmax1124 2026-06-30 18:25:06 -04:00 committed by Teknium
parent 671b1b058e
commit d2c7760ceb
3 changed files with 108 additions and 1 deletions

View file

@ -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()
})

View file

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

View file

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