From bc9518f660c75244b45d47f0a7a87f6cd067be62 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 11:44:27 -0500 Subject: [PATCH 01/14] fix(ui-tui): force full xterm.js alt-screen repaints - force full alt-screen damage in xterm.js hosts to avoid stale glyph artifacts - skip incremental scroll optimization there and repaint from a cleared screen atomically --- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 1543dc7fc..5f50be157 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -70,7 +70,7 @@ import { startSelection, updateSelection } from './selection.js' -import { supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js' +import { isXtermJs, supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js' import { CURSOR_HOME, cursorMove, @@ -728,12 +728,17 @@ export default class Ink { } } + // xterm.js occasionally leaves stale glyphs behind during incremental + // alt-screen updates. Force full repaint there; native terminals keep + // the cheaper diff path unless layout/overlay state says otherwise. + const forceFullAltScreenRepaint = this.altScreenActive && isXtermJs() + // Full-damage backstop: applies on BOTH alt-screen and main-screen. // Layout shifts (spinner appears, status line resizes) can leave stale // cells at sibling boundaries that per-node damage tracking misses. // Selection/highlight overlays write via setCellStyleId which doesn't // track damage. prevFrameContaminated covers the cleanup frame. - if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated) { + if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated || forceFullAltScreenRepaint) { frame.screen.damage = { x: 0, y: 0, @@ -771,7 +776,7 @@ export default class Ink { // renders the scrolled-but-not-yet-repainted intermediate state. // tmux is the main case (re-emits DECSTBM with its own timing and // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false). - SYNC_OUTPUT_SUPPORTED + SYNC_OUTPUT_SUPPORTED && !forceFullAltScreenRepaint ) const diffMs = performance.now() - tDiff @@ -824,7 +829,9 @@ export default class Ink { // erase+paint lands, then swaps in one go. Writing ERASE_SCREEN // synchronously in handleResize would blank the screen for the ~80ms // render() takes. - if (this.needsEraseBeforePaint) { + const eraseBeforePaint = this.needsEraseBeforePaint || forceFullAltScreenRepaint + + if (eraseBeforePaint) { this.needsEraseBeforePaint = false optimized.unshift(ERASE_THEN_HOME_PATCH) } else { From 071bdb5a3f099be5a7c824906315d483ee5b003d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 11:55:09 -0500 Subject: [PATCH 02/14] Revert "fix(ui-tui): force full xterm.js alt-screen repaints" This reverts commit bc9518f660c75244b45d47f0a7a87f6cd067be62. --- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 5f50be157..1543dc7fc 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -70,7 +70,7 @@ import { startSelection, updateSelection } from './selection.js' -import { isXtermJs, supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js' +import { supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js' import { CURSOR_HOME, cursorMove, @@ -728,17 +728,12 @@ export default class Ink { } } - // xterm.js occasionally leaves stale glyphs behind during incremental - // alt-screen updates. Force full repaint there; native terminals keep - // the cheaper diff path unless layout/overlay state says otherwise. - const forceFullAltScreenRepaint = this.altScreenActive && isXtermJs() - // Full-damage backstop: applies on BOTH alt-screen and main-screen. // Layout shifts (spinner appears, status line resizes) can leave stale // cells at sibling boundaries that per-node damage tracking misses. // Selection/highlight overlays write via setCellStyleId which doesn't // track damage. prevFrameContaminated covers the cleanup frame. - if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated || forceFullAltScreenRepaint) { + if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated) { frame.screen.damage = { x: 0, y: 0, @@ -776,7 +771,7 @@ export default class Ink { // renders the scrolled-but-not-yet-repainted intermediate state. // tmux is the main case (re-emits DECSTBM with its own timing and // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false). - SYNC_OUTPUT_SUPPORTED && !forceFullAltScreenRepaint + SYNC_OUTPUT_SUPPORTED ) const diffMs = performance.now() - tDiff @@ -829,9 +824,7 @@ export default class Ink { // erase+paint lands, then swaps in one go. Writing ERASE_SCREEN // synchronously in handleResize would blank the screen for the ~80ms // render() takes. - const eraseBeforePaint = this.needsEraseBeforePaint || forceFullAltScreenRepaint - - if (eraseBeforePaint) { + if (this.needsEraseBeforePaint) { this.needsEraseBeforePaint = false optimized.unshift(ERASE_THEN_HOME_PATCH) } else { From 2e7546006697c87ded650573dbbad52d505b63f4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 12:08:23 -0500 Subject: [PATCH 03/14] test(ui-tui): add log-update diff contract tests - steady-state diff skips unchanged rows - width change emits clearTerminal before repaint - drift repro: prev.screen desync from terminal leaves orphaned cells no code path can reach --- .../hermes-ink/src/ink/log-update.test.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 ui-tui/packages/hermes-ink/src/ink/log-update.test.ts diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts new file mode 100644 index 000000000..2802faf24 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' + +import type { Frame } from './frame.js' +import { LogUpdate } from './log-update.js' +import { + CellWidth, + CharPool, + createScreen, + HyperlinkPool, + type Screen, + setCellAt, + StylePool +} from './screen.js' + +/** + * Contract tests for LogUpdate.render() — the diff-to-ANSI path that owns + * whether the terminal picks up each React commit correctly. + * + * These tests pin down a few load-bearing invariants so that any fix for + * the "scattered letters after rapid resize" artifact in xterm.js hosts + * can be grounded against them. + */ + +const stylePool = new StylePool() +const charPool = new CharPool() +const hyperlinkPool = new HyperlinkPool() + +const mkScreen = (w: number, h: number) => createScreen(w, h, stylePool, charPool, hyperlinkPool) + +const paint = (screen: Screen, y: number, text: string) => { + for (let x = 0; x < text.length; x++) { + setCellAt(screen, x, y, { + char: text[x]!, + styleId: stylePool.none, + width: CellWidth.Narrow, + hyperlink: undefined + }) + } +} + +const mkFrame = (screen: Screen, viewportW: number, viewportH: number): Frame => ({ + screen, + viewport: { width: viewportW, height: viewportH }, + cursor: { x: 0, y: 0, visible: true } +}) + +const stdoutOnly = (diff: ReturnType) => + diff + .filter(p => p.type === 'stdout') + .map(p => (p as { type: 'stdout'; content: string }).content) + .join('') + +describe('LogUpdate.render diff contract', () => { + it('emits only changed cells when most rows match', () => { + const w = 20 + const h = 4 + const prev = mkScreen(w, h) + paint(prev, 0, 'HELLO') + paint(prev, 1, 'WORLD') + paint(prev, 2, 'STAYSHERE') + + const next = mkScreen(w, h) + paint(next, 0, 'HELLO') + paint(next, 1, 'CHANGE') + paint(next, 2, 'STAYSHERE') + next.damage = { x: 0, y: 0, width: w, height: h } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, w, h), mkFrame(next, w, h), true, false) + + const written = stdoutOnly(diff) + expect(written).toContain('CHANGE') + expect(written).not.toContain('HELLO') + expect(written).not.toContain('STAYSHERE') + }) + + it('width change emits a clearTerminal patch before repainting', () => { + const prevW = 20 + const nextW = 15 + const h = 3 + + const prev = mkScreen(prevW, h) + paint(prev, 0, 'thiswaswiderrow') + + const next = mkScreen(nextW, h) + paint(next, 0, 'shorterrownow') + next.damage = { x: 0, y: 0, width: nextW, height: h } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, prevW, h), mkFrame(next, nextW, h), true, false) + + expect(diff.some(p => p.type === 'clearTerminal')).toBe(true) + expect(stdoutOnly(diff)).toContain('shorterrownow') + }) + + it('drift repro: if terminal has content that prev.screen does not know about, diff leaves it orphaned', () => { + // Simulates prev/terminal desync: the physical terminal has STALE + // content at row 2 from a prior frame that was never reconciled into + // prev.screen. next.screen is blank at row 2. Diff finds prev==next + // (both blank at row 2), emits nothing → the stale content survives + // on the terminal as an artifact. + // + // This is the load-bearing theory for the rapid-resize scattered-letter + // bug: whenever the ink renderer believes prev.screen is authoritative + // but the physical terminal was mutated out-of-band (resize-induced + // reflow writing past the prev-frame's tracked cells), those cells + // drift and artifacts appear at that row on subsequent frames. + const w = 20 + const h = 3 + const prevAsInk = mkScreen(w, h) + paint(prevAsInk, 0, 'same') + // row 2 in prevAsInk is blank — but pretend the terminal has stale + // characters there. ink has no way to know. + const terminalReally = mkScreen(w, h) + paint(terminalReally, 0, 'same') + paint(terminalReally, 2, 'orphaned') + + const next = mkScreen(w, h) + paint(next, 0, 'same') + next.damage = { x: 0, y: 0, width: w, height: h } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prevAsInk, w, h), mkFrame(next, w, h), true, false) + + const written = stdoutOnly(diff) + expect(written).not.toContain('orphaned') + expect(diff.some(p => p.type === 'clearTerminal')).toBe(false) + // Verdict: in this configuration the renderer cannot heal the drift. + // The only recovery path from ink's side is fullResetSequence — which + // triggers only on viewport resize or scrollback-change detection, + // neither of which fires on a pure drift. A fix has to either (a) + // defensively emit a full repaint on every xterm.js frame where + // prevFrameContaminated is set, or (b) close the drift window at the + // renderer level so the in-memory prev.screen cannot diverge. + }) +}) From f7e86577bc258985ddf9cc328f7e7343585ff382 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 12:21:09 -0500 Subject: [PATCH 04/14] fix(ui-tui): heal xterm.js resize-burst render drift --- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 1543dc7fc..4e0c54ec9 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -70,7 +70,7 @@ import { startSelection, updateSelection } from './selection.js' -import { supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js' +import { isXtermJs, supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js' import { CURSOR_HOME, cursorMove, @@ -245,6 +245,7 @@ export default class Ink { // microtask. Dims are captured sync in handleResize; only the // expensive tree rebuild defers. private pendingResizeRender = false + private resizeSettleTimer: ReturnType | null = null // Fold synchronous re-entry (selection fanout, onFrame callback) // into one follow-up microtask instead of stacking renders. @@ -439,6 +440,11 @@ export default class Ink { this.drainTimer = null } + if (this.resizeSettleTimer !== null) { + clearTimeout(this.resizeSettleTimer) + this.resizeSettleTimer = null + } + // Alt screen: reset frame buffers so the next render repaints from // scratch (prevFrameContaminated → every cell written, wrapped in // BSU/ESU — old content stays visible until the new frame swaps @@ -456,6 +462,20 @@ export default class Ink { this.resetFramesForAltScreen() this.needsEraseBeforePaint = true + + if (isXtermJs()) { + this.resizeSettleTimer = setTimeout(() => { + this.resizeSettleTimer = null + + if (this.isUnmounted || this.isPaused || !this.altScreenActive || !this.options.stdout.isTTY) { + return + } + + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + this.scheduleRender() + }, 160) + } } // Already queued: later events in this burst updated dims/alt-screen From 3e01de0b092c7b14842c5165d09146b45c140066 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 12:40:39 -0500 Subject: [PATCH 05/14] fix(ui-tui): preserve composer after resize-burst healing - run the xterm.js settle-heal pass through a full render commit instead of diff-only scheduleRender - guard against overlapping resize renders and clear settle timers on unmount --- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 28 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 4e0c54ec9..036954a77 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -467,13 +467,29 @@ export default class Ink { this.resizeSettleTimer = setTimeout(() => { this.resizeSettleTimer = null - if (this.isUnmounted || this.isPaused || !this.altScreenActive || !this.options.stdout.isTTY) { + if ( + this.isUnmounted || + this.isPaused || + !this.altScreenActive || + !this.options.stdout.isTTY || + this.currentNode === null || + this.pendingResizeRender + ) { return } - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true - this.scheduleRender() + this.pendingResizeRender = true + queueMicrotask(() => { + this.pendingResizeRender = false + + if (this.isUnmounted || this.isPaused || !this.altScreenActive || !this.options.stdout.isTTY || this.currentNode === null) { + return + } + + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + this.render(this.currentNode) + }) }, 160) } } @@ -1954,6 +1970,10 @@ export default class Ink { clearTimeout(this.drainTimer) this.drainTimer = null } + if (this.resizeSettleTimer !== null) { + clearTimeout(this.resizeSettleTimer) + this.resizeSettleTimer = null + } reconciler.updateContainerSync(null, this.container, null, noop) reconciler.flushSyncWork() From 60d1edc38a0e1773193a4c7738781ffa1b724bbc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 12:44:56 -0500 Subject: [PATCH 06/14] fix(ui-tui): keep bottom statusbar in composer layout Render the bottom status bar inside the composer pane so aggressive resize + streaming churn cannot cull the input row via sibling overlap. --- ui-tui/src/components/appLayout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index cdac992d3..164ef5dd4 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -237,6 +237,8 @@ const ComposerPane = memo(function ComposerPane({ )} {!composer.empty && !ui.sid && ⚕ {ui.status}} + + ) }) @@ -320,8 +322,6 @@ export const AppLayout = memo(function AppLayout({ /> - - )} From 7c4dd7d660f3ea3872c7a9fea873ecb388738e5e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 12:49:49 -0500 Subject: [PATCH 07/14] refactor(ui-tui): collapse xterm.js resize settle dance Replace 28-line guard + nested queueMicrotask + pendingResizeRender flag-reuse with a named canAltScreenRepaint predicate and a single flat paint. setTimeout already drained the burst coalescer; the nested defer and flag dance were paranoia. --- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 036954a77..5a32ceeff 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -463,33 +463,21 @@ export default class Ink { this.resetFramesForAltScreen() this.needsEraseBeforePaint = true + // xterm.js burst-drift healer: 160ms after the last resize, force one + // full reconcile so Yoga/React catch up to the final viewport. No flag + // dance — setTimeout already drained the burst coalescer; a concurrent + // render would be idempotent. if (isXtermJs()) { this.resizeSettleTimer = setTimeout(() => { this.resizeSettleTimer = null - if ( - this.isUnmounted || - this.isPaused || - !this.altScreenActive || - !this.options.stdout.isTTY || - this.currentNode === null || - this.pendingResizeRender - ) { + if (!this.canAltScreenRepaint()) { return } - this.pendingResizeRender = true - queueMicrotask(() => { - this.pendingResizeRender = false - - if (this.isUnmounted || this.isPaused || !this.altScreenActive || !this.options.stdout.isTTY || this.currentNode === null) { - return - } - - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true - this.render(this.currentNode) - }) + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + this.render(this.currentNode!) }, 160) } } @@ -513,6 +501,17 @@ export default class Ink { this.render(this.currentNode) }) } + + private canAltScreenRepaint(): boolean { + return ( + !this.isUnmounted && + !this.isPaused && + this.altScreenActive && + !!this.options.stdout.isTTY && + this.currentNode !== null + ) + } + resolveExitPromise: () => void = () => {} rejectExitPromise: (reason?: Error) => void = () => {} unsubscribeExit: () => void = () => {} @@ -1970,6 +1969,7 @@ export default class Ink { clearTimeout(this.drainTimer) this.drainTimer = null } + if (this.resizeSettleTimer !== null) { clearTimeout(this.resizeSettleTimer) this.resizeSettleTimer = null From f28f07e98eda5533abbaebbe9b0640f465bb581a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 13:03:06 -0500 Subject: [PATCH 08/14] test(ui-tui): drop dead terminalReally from drift repro Copilot flagged the variable as unused. LogUpdate.render only sees prev/next, so a simulated "physical terminal" has no hook in the public API. Kept the narrative in the comment and tightened the assertion to demonstrate the test's actual invariant: identical prev/next emits no heal patches. --- .../hermes-ink/src/ink/log-update.test.ts | 43 +++++++------------ 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts index 2802faf24..55a1362f7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts @@ -93,44 +93,31 @@ describe('LogUpdate.render diff contract', () => { expect(stdoutOnly(diff)).toContain('shorterrownow') }) - it('drift repro: if terminal has content that prev.screen does not know about, diff leaves it orphaned', () => { - // Simulates prev/terminal desync: the physical terminal has STALE - // content at row 2 from a prior frame that was never reconciled into - // prev.screen. next.screen is blank at row 2. Diff finds prev==next - // (both blank at row 2), emits nothing → the stale content survives - // on the terminal as an artifact. + it('drift repro: identical prev/next emits no heal, even when the physical terminal is stale', () => { + // Load-bearing theory for the rapid-resize scattered-letter bug: if the + // physical terminal has stale cells that prev.screen doesn't know about + // (e.g. resize-induced reflow wrote past ink's tracked range), the + // renderer has no signal to heal them. LogUpdate.render only sees + // prev/next — no view of the physical terminal — so when prev==next, + // it emits nothing and any orphaned glyphs survive. // - // This is the load-bearing theory for the rapid-resize scattered-letter - // bug: whenever the ink renderer believes prev.screen is authoritative - // but the physical terminal was mutated out-of-band (resize-induced - // reflow writing past the prev-frame's tracked cells), those cells - // drift and artifacts appear at that row on subsequent frames. + // The fix path is upstream of this diff: either (a) defensively + // full-repaint on xterm.js frames where prevFrameContaminated is set, + // or (b) close the drift window so prev.screen cannot diverge. const w = 20 const h = 3 - const prevAsInk = mkScreen(w, h) - paint(prevAsInk, 0, 'same') - // row 2 in prevAsInk is blank — but pretend the terminal has stale - // characters there. ink has no way to know. - const terminalReally = mkScreen(w, h) - paint(terminalReally, 0, 'same') - paint(terminalReally, 2, 'orphaned') + + const prev = mkScreen(w, h) + paint(prev, 0, 'same') const next = mkScreen(w, h) paint(next, 0, 'same') next.damage = { x: 0, y: 0, width: w, height: h } const log = new LogUpdate({ isTTY: true, stylePool }) - const diff = log.render(mkFrame(prevAsInk, w, h), mkFrame(next, w, h), true, false) + const diff = log.render(mkFrame(prev, w, h), mkFrame(next, w, h), true, false) - const written = stdoutOnly(diff) - expect(written).not.toContain('orphaned') + expect(stdoutOnly(diff)).toBe('') expect(diff.some(p => p.type === 'clearTerminal')).toBe(false) - // Verdict: in this configuration the renderer cannot heal the drift. - // The only recovery path from ink's side is fullResetSequence — which - // triggers only on viewport resize or scrollback-change detection, - // neither of which fires on a pure drift. A fix has to either (a) - // defensively emit a full repaint on every xterm.js frame where - // prevFrameContaminated is set, or (b) close the drift window at the - // renderer level so the in-memory prev.screen cannot diverge. }) }) From 1e445b2547c5f83a4632358f44ba4e51497eb050 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 13:10:52 -0500 Subject: [PATCH 09/14] fix(ui-tui): heal post-resize alt-screen drift Broaden the settle repaint from xterm.js-only to all alt-screen terminals. Ink upstream and ConPTY/xterm reports point to resize/reflow desync as a general stale-cell class, not a host-specific quirk. --- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 31 +++++++++++----------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 5a32ceeff..4cfb435c7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -70,7 +70,7 @@ import { startSelection, updateSelection } from './selection.js' -import { isXtermJs, supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js' +import { supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js' import { CURSOR_HOME, cursorMove, @@ -463,23 +463,22 @@ export default class Ink { this.resetFramesForAltScreen() this.needsEraseBeforePaint = true - // xterm.js burst-drift healer: 160ms after the last resize, force one - // full reconcile so Yoga/React catch up to the final viewport. No flag - // dance — setTimeout already drained the burst coalescer; a concurrent - // render would be idempotent. - if (isXtermJs()) { - this.resizeSettleTimer = setTimeout(() => { - this.resizeSettleTimer = null + // Post-resize drift healer: 160ms after the last resize, force one full + // reconcile so Yoga/React catch up to the final viewport and any stale + // terminal cells from host-side reflow get repainted away. Ink upstream + // and ConPTY/xterm reports point to this as a general resize/reflow + // desync class, not an xterm.js-only quirk. + this.resizeSettleTimer = setTimeout(() => { + this.resizeSettleTimer = null - if (!this.canAltScreenRepaint()) { - return - } + if (!this.canAltScreenRepaint()) { + return + } - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true - this.render(this.currentNode!) - }, 160) - } + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + this.render(this.currentNode!) + }, 160) } // Already queued: later events in this burst updated dims/alt-screen From c8ff70fe03f5c0fb5726392bed9586544b1d8b15 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 13:16:18 -0500 Subject: [PATCH 10/14] perf(ui-tui): freeze offscreen live tail during scroll When the viewport is away from the bottom, keep the last visible progress snapshot instead of rebuilding the streaming/thinking subtree on every turn-store update. This cuts scroll-time churn while preserving live updates near the tail and on turn completion. --- ui-tui/src/app/useMainApp.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 39c4b534c..fdfdd8d54 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -22,7 +22,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' -import { type GatewayRpc, type TranscriptRow } from './interfaces.js' +import { type AppLayoutProgressProps, type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { $turnState, patchTurnState } from './turnStore.js' @@ -658,11 +658,36 @@ export function useMainApp(gw: GatewayClient) { [cols, composerActions, composerState, empty, pagerPageSize, submit] ) - const appProgress = useMemo( + const liveTailVisible = (() => { + const s = scrollRef.current + + if (!s) { + return true + } + + const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) + const vp = Math.max(0, s.getViewportHeight()) + const total = Math.max(vp, s.getScrollHeight()) + + return top + vp >= total - 3 + })() + + const liveProgress = useMemo( () => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), [turn, showProgressArea] ) + const frozenProgressRef = useRef(liveProgress) + + // When the live tail is offscreen, freeze its snapshot so scroll work doesn't + // keep rebuilding the streaming/thinking subtree the user can't see. Thaw as + // soon as the viewport comes back near the bottom or the turn finishes. + if (liveTailVisible || !ui.busy) { + frozenProgressRef.current = liveProgress + } + + const appProgress = liveTailVisible || !ui.busy ? liveProgress : frozenProgressRef.current + const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd() const gitBranch = useGitBranch(cwd) From aa47812edfb9cd945822a2a69a260467e8136926 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 14:19:32 -0500 Subject: [PATCH 11/14] fix(ui-tui): clear sticky prompt when follow snaps to bottom Renderer-driven follow-to-bottom was restoring the viewport to the tail without notifying ScrollBox subscribers, so StickyPromptTracker could stay stale-visible. Notify on render-time scroll/sticky changes and treat near-bottom as bottom for prompt hiding. --- .../hermes-ink/src/ink/components/ScrollBox.tsx | 1 + ui-tui/packages/hermes-ink/src/ink/dom.ts | 1 + .../hermes-ink/src/ink/render-node-to-output.ts | 5 +++++ ui-tui/src/components/appChrome.tsx | 10 ++++++++-- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx index aac8f2b33..ed4239cef 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -257,6 +257,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren< if (el) { el.scrollTop ??= 0 + el.notifyScrollChange = notify } }} style={{ diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 6c4b19830..735ab0b0c 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -72,6 +72,7 @@ export type DOMElement = { scrollViewportHeight?: number scrollViewportTop?: number stickyScroll?: boolean + notifyScrollChange?: () => void // Set by ScrollBox.scrollToElement; render-node-to-output reads // el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight) // and sets scrollTop = top + offset, then clears this. Unlike an diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index dd7372a09..12d689c16 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -761,6 +761,7 @@ function renderNodeToOutput( // active text selection by the same delta (native terminal behavior: // view keeps scrolling, highlight walks up with the text). const scrollTopBeforeFollow = node.scrollTop ?? 0 + const stickyBeforeFollow = node.stickyScroll const sticky = node.stickyScroll ?? Boolean(node.attributes['stickyScroll']) @@ -863,6 +864,10 @@ function renderNodeToOutput( scrollDrainNode = node } + if ((node.scrollTop ?? 0) !== scrollTopBeforeFollow || node.stickyScroll !== stickyBeforeFollow) { + node.notifyScrollChange?.() + } + scrollTop = clamped if (content && contentYoga) { diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index d12a4debf..8b1f816ce 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -256,15 +256,21 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: } const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) + const vp = Math.max(0, s.getViewportHeight()) + const total = Math.max(vp, s.getScrollHeight()) + const atBottom = s.isSticky() || top + vp >= total - 2 - return s.isSticky() ? -1 - top : top + return atBottom ? -1 - top : top }, () => NaN ) const s = scrollRef.current const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) - const text = stickyPromptFromViewport(messages, offsets, top, s?.isSticky() ?? true) + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + const total = Math.max(vp, s?.getScrollHeight() ?? vp) + const atBottom = (s?.isSticky() ?? true) || top + vp >= total - 2 + const text = stickyPromptFromViewport(messages, offsets, top, atBottom) useEffect(() => onChange(text), [onChange, text]) From 9a885fba31e5ae8a8a24b7f5dcf8ea19dedddf5c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 14:32:29 -0500 Subject: [PATCH 12/14] fix(ui-tui): hide stale sticky prompt when newer prompt is visible Sticky prompt selection only considered the top edge of the viewport, so it could keep showing an older user prompt even when a newer one was already visible lower down. Suppress sticky output whenever a user message is visible in the viewport and cover it with a regression test. --- ui-tui/src/__tests__/viewport.test.ts | 31 +++++++++++++++++++++++++++ ui-tui/src/components/appChrome.tsx | 2 +- ui-tui/src/domain/viewport.ts | 10 ++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/__tests__/viewport.test.ts diff --git a/ui-tui/src/__tests__/viewport.test.ts b/ui-tui/src/__tests__/viewport.test.ts new file mode 100644 index 000000000..0a949e44c --- /dev/null +++ b/ui-tui/src/__tests__/viewport.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { stickyPromptFromViewport } from '../domain/viewport.js' + +describe('stickyPromptFromViewport', () => { + it('hides the sticky prompt when a newer user message is already visible', () => { + const messages = [ + { role: 'user' as const, text: 'older prompt' }, + { role: 'assistant' as const, text: 'older answer' }, + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'current answer' } + ] + + const offsets = [0, 2, 10, 12, 20] + + expect(stickyPromptFromViewport(messages, offsets, 16, 8, false)).toBe('') + }) + + it('shows the latest user message above the viewport when no user message is visible', () => { + const messages = [ + { role: 'user' as const, text: 'older prompt' }, + { role: 'assistant' as const, text: 'older answer' }, + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'current answer' } + ] + + const offsets = [0, 2, 10, 12, 20] + + expect(stickyPromptFromViewport(messages, offsets, 20, 16, false)).toBe('current prompt') + }) +}) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 8b1f816ce..d7974d533 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -270,7 +270,7 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: const vp = Math.max(0, s?.getViewportHeight() ?? 0) const total = Math.max(vp, s?.getScrollHeight() ?? vp) const atBottom = (s?.isSticky() ?? true) || top + vp >= total - 2 - const text = stickyPromptFromViewport(messages, offsets, top, atBottom) + const text = stickyPromptFromViewport(messages, offsets, top + vp, top, atBottom) useEffect(() => onChange(text), [onChange, text]) diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 788f94269..3a358eb6f 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -18,6 +18,7 @@ const upperBound = (offsets: ArrayLike, target: number) => { export const stickyPromptFromViewport = ( messages: readonly Msg[], offsets: ArrayLike, + bottom: number, top: number, sticky: boolean ) => { @@ -26,8 +27,15 @@ export const stickyPromptFromViewport = ( } const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) + const last = Math.max(first, Math.min(messages.length - 1, upperBound(offsets, bottom) - 1)) - for (let i = first; i >= 0; i--) { + for (let i = first; i <= last; i++) { + if (messages[i]?.role === 'user') { + return '' + } + } + + for (let i = first - 1; i >= 0; i--) { if (messages[i]?.role !== 'user') { continue } From 9bf6e1cd6eeecf83ddd4fe97b7c756d6bf2f34cb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 14:37:00 -0500 Subject: [PATCH 13/14] refactor(ui-tui): clean touched resize and sticky prompt paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim comment noise, remove redundant typing, normalize sticky prompt viewport args to top→bottom order, and reuse one sticky viewport helper instead of duplicating the math. --- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 7 ++--- ui-tui/src/__tests__/viewport.test.ts | 4 +-- ui-tui/src/app/useMainApp.ts | 11 +++----- ui-tui/src/components/appChrome.tsx | 31 +++++++++++----------- ui-tui/src/domain/viewport.ts | 2 +- 5 files changed, 23 insertions(+), 32 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 4cfb435c7..8e43f60ea 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -463,11 +463,8 @@ export default class Ink { this.resetFramesForAltScreen() this.needsEraseBeforePaint = true - // Post-resize drift healer: 160ms after the last resize, force one full - // reconcile so Yoga/React catch up to the final viewport and any stale - // terminal cells from host-side reflow get repainted away. Ink upstream - // and ConPTY/xterm reports point to this as a general resize/reflow - // desync class, not an xterm.js-only quirk. + // 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 diff --git a/ui-tui/src/__tests__/viewport.test.ts b/ui-tui/src/__tests__/viewport.test.ts index 0a949e44c..d8500c8d2 100644 --- a/ui-tui/src/__tests__/viewport.test.ts +++ b/ui-tui/src/__tests__/viewport.test.ts @@ -13,7 +13,7 @@ describe('stickyPromptFromViewport', () => { const offsets = [0, 2, 10, 12, 20] - expect(stickyPromptFromViewport(messages, offsets, 16, 8, false)).toBe('') + expect(stickyPromptFromViewport(messages, offsets, 8, 16, false)).toBe('') }) it('shows the latest user message above the viewport when no user message is visible', () => { @@ -26,6 +26,6 @@ describe('stickyPromptFromViewport', () => { const offsets = [0, 2, 10, 12, 20] - expect(stickyPromptFromViewport(messages, offsets, 20, 16, false)).toBe('current prompt') + expect(stickyPromptFromViewport(messages, offsets, 16, 20, false)).toBe('current prompt') }) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index fdfdd8d54..75fe73c86 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -22,7 +22,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' -import { type AppLayoutProgressProps, type GatewayRpc, type TranscriptRow } from './interfaces.js' +import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { $turnState, patchTurnState } from './turnStore.js' @@ -672,16 +672,11 @@ export function useMainApp(gw: GatewayClient) { return top + vp >= total - 3 })() - const liveProgress = useMemo( - () => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), - [turn, showProgressArea] - ) + const liveProgress = useMemo(() => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), [turn, showProgressArea]) const frozenProgressRef = useRef(liveProgress) - // When the live tail is offscreen, freeze its snapshot so scroll work doesn't - // keep rebuilding the streaming/thinking subtree the user can't see. Thaw as - // soon as the viewport comes back near the bottom or the turn finishes. + // Freeze the offscreen live tail so scroll doesn't rebuild unseen streaming UI. if (liveTailVisible || !ui.busy) { frozenProgressRef.current = liveProgress } diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index d7974d533..8de2a6301 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -249,28 +249,15 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: useSyncExternalStore( useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), () => { - const s = scrollRef.current - - if (!s) { - return NaN - } - - const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) - const vp = Math.max(0, s.getViewportHeight()) - const total = Math.max(vp, s.getScrollHeight()) - const atBottom = s.isSticky() || top + vp >= total - 2 + const { atBottom, top } = getStickyViewport(scrollRef.current) return atBottom ? -1 - top : top }, () => NaN ) - const s = scrollRef.current - const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) - const vp = Math.max(0, s?.getViewportHeight() ?? 0) - const total = Math.max(vp, s?.getScrollHeight() ?? vp) - const atBottom = (s?.isSticky() ?? true) || top + vp >= total - 2 - const text = stickyPromptFromViewport(messages, offsets, top + vp, top, atBottom) + const { atBottom, bottom, top } = getStickyViewport(scrollRef.current) + const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom) useEffect(() => onChange(text), [onChange, text]) @@ -395,3 +382,15 @@ interface TranscriptScrollbarProps { scrollRef: RefObject t: Theme } + +function getStickyViewport(s?: ScrollBoxHandle | null) { + const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + const total = Math.max(vp, s?.getScrollHeight() ?? vp) + + return { + atBottom: (s?.isSticky() ?? true) || top + vp >= total - 2, + bottom: top + vp, + top + } +} diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 3a358eb6f..48d7427fd 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -18,8 +18,8 @@ const upperBound = (offsets: ArrayLike, target: number) => { export const stickyPromptFromViewport = ( messages: readonly Msg[], offsets: ArrayLike, - bottom: number, top: number, + bottom: number, sticky: boolean ) => { if (sticky || !messages.length) { From 882278520ba9de4e1219a0575313a19e5e8b67de Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 14:37:27 -0500 Subject: [PATCH 14/14] chore: uptick --- ui-tui/packages/hermes-ink/src/ink/log-update.test.ts | 10 +--------- ui-tui/src/app/useMainApp.ts | 5 ++++- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts index 55a1362f7..be2b711ec 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts @@ -2,15 +2,7 @@ import { describe, expect, it } from 'vitest' import type { Frame } from './frame.js' import { LogUpdate } from './log-update.js' -import { - CellWidth, - CharPool, - createScreen, - HyperlinkPool, - type Screen, - setCellAt, - StylePool -} from './screen.js' +import { CellWidth, CharPool, createScreen, HyperlinkPool, type Screen, setCellAt, StylePool } from './screen.js' /** * Contract tests for LogUpdate.render() — the diff-to-ANSI path that owns diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 75fe73c86..41edcc828 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -672,7 +672,10 @@ export function useMainApp(gw: GatewayClient) { return top + vp >= total - 3 })() - const liveProgress = useMemo(() => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), [turn, showProgressArea]) + const liveProgress = useMemo( + () => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), + [turn, showProgressArea] + ) const frozenProgressRef = useRef(liveProgress)