Reset terminal SGR state around restored output (#14700)

* fix(terminal): reset SGR around restored output

* fix(terminal): preserve live replay styling

* fix(terminal): ground dead reattach fallback
This commit is contained in:
Brennan Benson
2026-08-16 17:08:10 -07:00
committed by GitHub
parent c73e5a2f59
commit a324ee20d4
18 changed files with 374 additions and 66 deletions
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import { HeadlessEmulator } from './headless-emulator'
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles'
import {
COLD_RESTORE_SEED_MODE_RESET,
RESET_GRAPHIC_RENDITION
} from '../../shared/terminal-mode-reset-profiles'
import type { ColdRestoreInfo } from './terminal-history-cold-restore-info'
import type { TerminalModes } from './types'
@@ -38,7 +41,7 @@ describe('getRecoveredHistorySeedSegments', () => {
restoreInfo({ pendingEscapeTailAnsi: '\x1b[3' })
)
expect(segments).toEqual([
'\x1b[?1003h\x1b[?1006h',
`${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h`,
'user@host ~ $ \x1b[?1003h',
MOUSE_OFF,
'\x1b[3'
@@ -50,7 +53,7 @@ describe('getRecoveredHistorySeedSegments', () => {
getRecoveredHistorySeedSegments(
restoreInfo({ modes: { ...ARMED_MODES, alternateScreen: true } })
)
).toEqual(['user@host ~ $ ', MOUSE_OFF])
).toEqual([`${RESET_GRAPHIC_RENDITION}user@host ~ $ `, MOUSE_OFF])
})
it('stays empty when there is no recovered normal buffer', () => {
@@ -116,4 +119,10 @@ describe('getRecoveredHistorySeedSegments', () => {
// snapshots must keep re-arming or an alt-screen TUI loses scroll forever.
expect(buildRehydrateSequences(ARMED_MODES)).toBe('\x1b[?1003h\x1b[?1006h')
})
it('grounds the pen before re-entering the alternate screen', () => {
expect(buildRehydrateSequences({ ...ARMED_MODES, alternateScreen: true })).toBe(
`${RESET_GRAPHIC_RENDITION}\x1b[?1049h\x1b[?1003h\x1b[?1006h`
)
})
})
@@ -1,5 +1,8 @@
import type { ColdRestoreInfo } from './terminal-history-cold-restore-info'
import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles'
import {
COLD_RESTORE_SEED_MODE_RESET,
RESET_GRAPHIC_RENDITION
} from '../../shared/terminal-mode-reset-profiles'
// Why the reset belongs in the seed and not only at replay: the recovered stream
// re-arms mouse reporting from two independent sources (rehydrateSequences AND
@@ -12,7 +15,9 @@ import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-p
export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): readonly string[] {
if (restoreInfo.modes.alternateScreen) {
const normalBuffer = restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi
return normalBuffer ? [normalBuffer, COLD_RESTORE_SEED_MODE_RESET] : []
return normalBuffer
? [`${RESET_GRAPHIC_RENDITION}${normalBuffer}`, COLD_RESTORE_SEED_MODE_RESET]
: []
}
const recovered = [restoreInfo.rehydrateSequences, restoreInfo.snapshotAnsi].filter(
(segment) => segment.length > 0
@@ -25,5 +30,9 @@ export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): r
}
// Why after the snapshot: it must undo the snapshot's own mode trailer, and
// pendingEscapeTailAnsi is a torn escape that has to stay at the very end.
return [...recovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])]
const [firstRecovered, ...remainingRecovered] = recovered
const groundedRecovered = firstRecovered
? [`${RESET_GRAPHIC_RENDITION}${firstRecovered}`, ...remainingRecovered]
: []
return [...groundedRecovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])]
}
@@ -1,4 +1,5 @@
import type { TerminalModes } from './types'
import { RESET_GRAPHIC_RENDITION } from '../../shared/terminal-mode-reset-profiles'
// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and
// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C
@@ -10,7 +11,7 @@ export function buildRehydrateSequences(modes: TerminalModes): string {
if (modes.alternateScreen) {
// Why: normal-buffer serialization can leave its pen active, while the
// separately serialized alt body assumes it starts from default SGR.
seqs.push('\x1b[0m\x1b[?1049h')
seqs.push(`${RESET_GRAPHIC_RENDITION}\x1b[?1049h`)
}
if (modes.bracketedPaste) {
seqs.push('\x1b[?2004h')