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.
This commit is contained in:
brooklyn! 2026-05-15 20:08:24 -05:00 committed by GitHub
parent 6784c80794
commit 566d8f0d75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 46 additions and 1 deletions

View file

@ -42,6 +42,8 @@ const stdoutOnly = (diff: ReturnType<LogUpdate['render']>) =>
.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)
})
})

View file

@ -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 = [
{