Merge pull request #14640 from NousResearch/bb/fix-tui-glyph-ghosting

fix(ui-tui): heal post-resize alt-screen drift
This commit is contained in:
brooklyn! 2026-04-23 14:41:05 -05:00 committed by GitHub
commit b6ca3c28dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 240 additions and 15 deletions

View file

@ -257,6 +257,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
if (el) { if (el) {
el.scrollTop ??= 0 el.scrollTop ??= 0
el.notifyScrollChange = notify
} }
}} }}
style={{ style={{

View file

@ -72,6 +72,7 @@ export type DOMElement = {
scrollViewportHeight?: number scrollViewportHeight?: number
scrollViewportTop?: number scrollViewportTop?: number
stickyScroll?: boolean stickyScroll?: boolean
notifyScrollChange?: () => void
// Set by ScrollBox.scrollToElement; render-node-to-output reads // Set by ScrollBox.scrollToElement; render-node-to-output reads
// el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight) // el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight)
// and sets scrollTop = top + offset, then clears this. Unlike an // and sets scrollTop = top + offset, then clears this. Unlike an

View file

@ -245,6 +245,7 @@ export default class Ink {
// microtask. Dims are captured sync in handleResize; only the // microtask. Dims are captured sync in handleResize; only the
// expensive tree rebuild defers. // expensive tree rebuild defers.
private pendingResizeRender = false private pendingResizeRender = false
private resizeSettleTimer: ReturnType<typeof setTimeout> | null = null
// Fold synchronous re-entry (selection fanout, onFrame callback) // Fold synchronous re-entry (selection fanout, onFrame callback)
// into one follow-up microtask instead of stacking renders. // into one follow-up microtask instead of stacking renders.
@ -439,6 +440,11 @@ export default class Ink {
this.drainTimer = null this.drainTimer = null
} }
if (this.resizeSettleTimer !== null) {
clearTimeout(this.resizeSettleTimer)
this.resizeSettleTimer = null
}
// Alt screen: reset frame buffers so the next render repaints from // Alt screen: reset frame buffers so the next render repaints from
// scratch (prevFrameContaminated → every cell written, wrapped in // scratch (prevFrameContaminated → every cell written, wrapped in
// BSU/ESU — old content stays visible until the new frame swaps // BSU/ESU — old content stays visible until the new frame swaps
@ -456,6 +462,20 @@ export default class Ink {
this.resetFramesForAltScreen() this.resetFramesForAltScreen()
this.needsEraseBeforePaint = true 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)
} }
// Already queued: later events in this burst updated dims/alt-screen // Already queued: later events in this burst updated dims/alt-screen
@ -477,6 +497,17 @@ export default class Ink {
this.render(this.currentNode) this.render(this.currentNode)
}) })
} }
private canAltScreenRepaint(): boolean {
return (
!this.isUnmounted &&
!this.isPaused &&
this.altScreenActive &&
!!this.options.stdout.isTTY &&
this.currentNode !== null
)
}
resolveExitPromise: () => void = () => {} resolveExitPromise: () => void = () => {}
rejectExitPromise: (reason?: Error) => void = () => {} rejectExitPromise: (reason?: Error) => void = () => {}
unsubscribeExit: () => void = () => {} unsubscribeExit: () => void = () => {}
@ -1935,6 +1966,11 @@ export default class Ink {
this.drainTimer = null this.drainTimer = null
} }
if (this.resizeSettleTimer !== null) {
clearTimeout(this.resizeSettleTimer)
this.resizeSettleTimer = null
}
reconciler.updateContainerSync(null, this.container, null, noop) reconciler.updateContainerSync(null, this.container, null, noop)
reconciler.flushSyncWork() reconciler.flushSyncWork()
instances.delete(this.options.stdout) instances.delete(this.options.stdout)

View file

@ -0,0 +1,115 @@
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<LogUpdate['render']>) =>
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: 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.
//
// 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 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(prev, w, h), mkFrame(next, w, h), true, false)
expect(stdoutOnly(diff)).toBe('')
expect(diff.some(p => p.type === 'clearTerminal')).toBe(false)
})
})

View file

@ -761,6 +761,7 @@ function renderNodeToOutput(
// active text selection by the same delta (native terminal behavior: // active text selection by the same delta (native terminal behavior:
// view keeps scrolling, highlight walks up with the text). // view keeps scrolling, highlight walks up with the text).
const scrollTopBeforeFollow = node.scrollTop ?? 0 const scrollTopBeforeFollow = node.scrollTop ?? 0
const stickyBeforeFollow = node.stickyScroll
const sticky = node.stickyScroll ?? Boolean(node.attributes['stickyScroll']) const sticky = node.stickyScroll ?? Boolean(node.attributes['stickyScroll'])
@ -863,6 +864,10 @@ function renderNodeToOutput(
scrollDrainNode = node scrollDrainNode = node
} }
if ((node.scrollTop ?? 0) !== scrollTopBeforeFollow || node.stickyScroll !== stickyBeforeFollow) {
node.notifyScrollChange?.()
}
scrollTop = clamped scrollTop = clamped
if (content && contentYoga) { if (content && contentYoga) {

View file

@ -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, 8, 16, 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, 16, 20, false)).toBe('current prompt')
})
})

View file

@ -658,11 +658,34 @@ export function useMainApp(gw: GatewayClient) {
[cols, composerActions, composerState, empty, pagerPageSize, submit] [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, showStreamingArea: Boolean(turn.streaming) }),
[turn, showProgressArea] [turn, showProgressArea]
) )
const frozenProgressRef = useRef(liveProgress)
// Freeze the offscreen live tail so scroll doesn't rebuild unseen streaming UI.
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 cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd()
const gitBranch = useGitBranch(cwd) const gitBranch = useGitBranch(cwd)

View file

@ -249,22 +249,15 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }:
useSyncExternalStore( useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => { () => {
const s = scrollRef.current const { atBottom, top } = getStickyViewport(scrollRef.current)
if (!s) { return atBottom ? -1 - top : top
return NaN
}
const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
return s.isSticky() ? -1 - top : top
}, },
() => NaN () => NaN
) )
const s = scrollRef.current const { atBottom, bottom, top } = getStickyViewport(scrollRef.current)
const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom)
const text = stickyPromptFromViewport(messages, offsets, top, s?.isSticky() ?? true)
useEffect(() => onChange(text), [onChange, text]) useEffect(() => onChange(text), [onChange, text])
@ -389,3 +382,15 @@ interface TranscriptScrollbarProps {
scrollRef: RefObject<ScrollBoxHandle | null> scrollRef: RefObject<ScrollBoxHandle | null>
t: Theme 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
}
}

View file

@ -237,6 +237,8 @@ const ComposerPane = memo(function ComposerPane({
)} )}
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}> {ui.status}</Text>} {!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}> {ui.status}</Text>}
<StatusRulePane at="bottom" composer={composer} status={status} />
</NoSelect> </NoSelect>
) )
}) })
@ -320,8 +322,6 @@ export const AppLayout = memo(function AppLayout({
/> />
<ComposerPane actions={actions} composer={composer} status={status} /> <ComposerPane actions={actions} composer={composer} status={status} />
<StatusRulePane at="bottom" composer={composer} status={status} />
</> </>
)} )}
</Box> </Box>

View file

@ -19,6 +19,7 @@ export const stickyPromptFromViewport = (
messages: readonly Msg[], messages: readonly Msg[],
offsets: ArrayLike<number>, offsets: ArrayLike<number>,
top: number, top: number,
bottom: number,
sticky: boolean sticky: boolean
) => { ) => {
if (sticky || !messages.length) { if (sticky || !messages.length) {
@ -26,8 +27,15 @@ export const stickyPromptFromViewport = (
} }
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) 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') { if (messages[i]?.role !== 'user') {
continue continue
} }