From 566d8f0d75049e5e4e4e3e3fde7f8c766ae235d6 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 15 May 2026 20:08:24 -0500 Subject: [PATCH] fix(tui): keep DECSTBM scroll region off bottom row (#26683) Avoid shifting the terminal's last visible row in the alt-screen DECSTBM fast path, which can leave transient scroll bleed/discoloration artifacts around the status lane until a repaint. Add regression tests to preserve the fast path when safe and skip it when the hint touches the bottom row. --- .../hermes-ink/src/ink/log-update.test.ts | 42 +++++++++++++++++++ .../packages/hermes-ink/src/ink/log-update.ts | 5 ++- 2 files changed, 46 insertions(+), 1 deletion(-) 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 35c99f7e0a2..a11a028e771 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 @@ -42,6 +42,8 @@ const stdoutOnly = (diff: ReturnType) => .map(p => (p as { type: 'stdout'; content: string }).content) .join('') +const hasDecstbm = (text: string) => /\x1b\[\d+;\d+r/.test(text) + describe('LogUpdate.render diff contract', () => { it('emits only changed cells when most rows match', () => { const w = 20 @@ -154,4 +156,44 @@ describe('LogUpdate.render diff contract', () => { expect(diff.some(p => p.type === 'clearTerminal')).toBe(true) expect(stdoutOnly(diff)).toContain('timer2s') }) + + it('keeps DECSTBM fast-path when scroll region stays above bottom row', () => { + const w = 12 + const h = 6 + const prev = mkScreen(w, h) + const next = mkScreen(w, h) + + paint(prev, 1, 'row one') + paint(next, 1, 'row one') + + const prevFrame = mkFrame(prev, w, h) + const nextFrame: Frame = { + ...mkFrame(next, w, h), + scrollHint: { top: 1, bottom: 4, delta: 1 } + } + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(prevFrame, nextFrame, true, true) + + expect(hasDecstbm(stdoutOnly(diff))).toBe(true) + }) + + it('skips DECSTBM when scroll region touches the bottom row', () => { + const w = 12 + const h = 6 + const prev = mkScreen(w, h) + const next = mkScreen(w, h) + + paint(prev, 1, 'row one') + paint(next, 1, 'row one') + + const prevFrame = mkFrame(prev, w, h) + const nextFrame: Frame = { + ...mkFrame(next, w, h), + scrollHint: { top: 1, bottom: 5, delta: 1 } + } + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(prevFrame, nextFrame, true, true) + + expect(hasDecstbm(stdoutOnly(diff))).toBe(false) + }) }) 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 9a377c2c6f6..0f36d4641e7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.ts @@ -175,7 +175,10 @@ export class LogUpdate { if (altScreen && next.scrollHint && decstbmSafe) { const { top, bottom, delta } = next.scrollHint - if (top >= 0 && bottom < prev.screen.height && bottom < next.screen.height) { + // Keep DECSTBM away from the terminal's last visible row. In alt-screen + // layouts we reserve that lane for status/cursor parking, and scrolling + // it can leave transient ghosting/bleed artifacts until a later repaint. + if (top >= 0 && bottom < prev.screen.height - 1 && bottom < next.screen.height - 1) { shiftRows(prev.screen, top, bottom, delta) scrollPatch = [ {