mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
671b1b058e
commit
d2c7760ceb
3 changed files with 108 additions and 1 deletions
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue