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 be2b711ecc..35c99f7e0a 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 @@ -30,10 +30,10 @@ const paint = (screen: Screen, y: number, text: string) => { } } -const mkFrame = (screen: Screen, viewportW: number, viewportH: number): Frame => ({ +const mkFrame = (screen: Screen, viewportW: number, viewportH: number, cursorY = 0): Frame => ({ screen, viewport: { width: viewportW, height: viewportH }, - cursor: { x: 0, y: 0, visible: true } + cursor: { x: 0, y: cursorY, visible: true } }) const stdoutOnly = (diff: ReturnType) => @@ -112,4 +112,46 @@ describe('LogUpdate.render diff contract', () => { expect(stdoutOnly(diff)).toBe('') expect(diff.some(p => p.type === 'clearTerminal')).toBe(false) }) + + it('ignores main-screen scrollback-only changes instead of resetting repeatedly', () => { + const w = 20 + const viewportH = 5 + const h = 8 + + const prev = mkScreen(w, h) + paint(prev, 0, 'timer 1s') + paint(prev, 6, 'visible prompt') + + const next = mkScreen(w, h) + paint(next, 0, 'timer 2s') + paint(next, 6, 'visible prompt') + next.damage = { x: 0, y: 0, width: w, height: h } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, w, viewportH, h), mkFrame(next, w, viewportH, h), false, false) + + expect(diff.some(p => p.type === 'clearTerminal')).toBe(false) + expect(stdoutOnly(diff)).not.toContain('timer2s') + }) + + it('keeps alt-screen full reset for unreachable scrollback row changes', () => { + const w = 20 + const viewportH = 5 + const h = 8 + + const prev = mkScreen(w, h) + paint(prev, 0, 'timer 1s') + paint(prev, 6, 'visible prompt') + + const next = mkScreen(w, h) + paint(next, 0, 'timer 2s') + paint(next, 6, 'visible prompt') + next.damage = { x: 0, y: 0, width: w, height: h } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, w, viewportH, h), mkFrame(next, w, viewportH, h), true, false) + + expect(diff.some(p => p.type === 'clearTerminal')).toBe(true) + expect(stdoutOnly(diff)).toContain('timer2s') + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.ts index e4dc3dc7a4..9a377c2c6f 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.ts @@ -226,7 +226,13 @@ export class LogUpdate { return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool) } - if (prev.screen.height >= prev.viewport.height && prev.screen.height > 0 && cursorAtBottom && !isGrowing) { + if ( + altScreen && + prev.screen.height >= prev.viewport.height && + prev.screen.height > 0 && + cursorAtBottom && + !isGrowing + ) { // viewportY = rows in scrollback from content overflow // +1 for the row pushed by cursor-restore scroll const viewportY = prev.screen.height - prev.viewport.height @@ -330,8 +336,15 @@ export class LogUpdate { } // If the cell outside the viewport range has changed, we need to reset - // because we can't move the cursor there to draw. + // because we can't move the cursor there to draw. In main-screen mode, + // those rows are already in terminal scrollback and invisible; resetting + // on every scrollback-only update can loop when a resize changes the + // physical buffer. Shrink-to-visible cases are handled above. if (y < viewportY) { + if (!altScreen) { + return + } + needsFullReset = true resetTriggerY = y