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
@@ -7,6 +7,7 @@ import {
POST_REPLAY_MODE_RESET,
POST_REPLAY_REATTACH_RESET,
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
RESET_GRAPHIC_RENDITION,
RESET_MOUSE_REPORTING,
buildPostReplayLiveAgentReattachReset,
replayPayloadEndsWithCursorHidden
@@ -23,14 +24,15 @@ describe('terminal mode reset profiles', () => {
it('pins the fresh-shell profile', () => {
expect(POST_REPLAY_MODE_RESET).toBe(
'\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l'
'\x1b[0m\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l\x1b7'
)
})
it('pins the daemon-reattach profile, which keeps bracketed paste', () => {
expect(POST_REPLAY_REATTACH_RESET).toBe(
'\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l'
'\x1b[0m\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b7'
)
expect(POST_REPLAY_REATTACH_RESET).toContain(RESET_GRAPHIC_RENDITION)
expect(POST_REPLAY_REATTACH_RESET).not.toContain('\x1b[?2004l')
})
@@ -46,8 +48,8 @@ describe('terminal mode reset profiles', () => {
})
// Why: #12101 — a cold-restored seed re-arms mouse reporting for a dead TUI.
it('disarms mouse reporting on the cold-restore seed', () => {
expect(COLD_RESTORE_SEED_MODE_RESET).toBe(RESET_MOUSE_REPORTING)
it('clears the pen and disarms mouse reporting on the cold-restore seed', () => {
expect(COLD_RESTORE_SEED_MODE_RESET).toBe(`${RESET_GRAPHIC_RENDITION}${RESET_MOUSE_REPORTING}`)
})
// Why: the seed also feeds the daemon emulator and is re-serialized from it, so
@@ -73,6 +75,7 @@ describe('terminal mode reset profiles', () => {
POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET,
POST_REPLAY_LIVE_SNAPSHOT_RESET
]) {
expect(profile).not.toContain(RESET_GRAPHIC_RENDITION)
expect(profile).not.toContain('\x1b[?1000l')
expect(profile).not.toContain('\x1b[?2004l')
}
+10 -15
View File
@@ -6,17 +6,22 @@
// Why: SerializeAddon replays mode bits assuming reattach to a live TUI, but Orca restores against a fresh shell with none, so stale bits (e.g. focus reporting rings the bell on click) must be reset.
export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q'
export const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u'
// Why: abandoned byte-gap replay drains live chunks, so a dropped intensity reset must not style them (STA-4042).
export const RESET_GRAPHIC_RENDITION = '\x1b[0m'
// Last so a dead process cannot leave stale attributes in the DECSC register.
const SAVE_GROUNDED_CURSOR = '\x1b7'
// Every mouse mode the daemon can re-arm from a snapshot: protocols 9/1000/1002/1003 + SGR encodings 1006/1016.
export const RESET_MOUSE_REPORTING =
'\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l'
export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l\x1b[?2004l`
// Why: serialized panes can end with a live pen, but the following shell assumes default attributes.
export const POST_REPLAY_MODE_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l\x1b[?2004l${SAVE_GROUNDED_CURSOR}`
// Why: same-session live replay; keep cursor/focus cleanup but preserve Kitty flags the running TUI relies on.
export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l`
// Why: daemon reattach hits a live session, so skip the full reset; still clear cursor/focus/mouse/Kitty bits harmful to a plain shell after a bad TUI exit — safe for live TUIs since the post-reattach SIGWINCH repaints the cursor.
export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l`
// Why: the normal-buffer fallback can follow a dead TUI, so its stale pen and saved pen must not reach the surviving shell.
export const POST_REPLAY_REATTACH_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l${SAVE_GROUNDED_CURSOR}`
// Why: an alt-screen reattach replays the daemon's rehydrateSequences, which re-arm the live TUI's
// mouse modes; wiping them one write later hands drags back to xterm's row selection (#8291).
@@ -29,16 +34,8 @@ export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_ST
// Why: a live agent owns cursor/focus here; forcing ?25h/?1004l breaks a parked agent that only arms ?1004h at startup.
export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE
/** Dead-TUI bytes feed a fresh shell; clear mouse modes here and renderer-owned modes later. */
export const COLD_RESTORE_SEED_MODE_RESET = RESET_MOUSE_REPORTING
// Why separate from every profile above: those clear DEC *mode* bits and none of
// them touches SGR. A recovery path that declares bytes unrecoverable has by
// definition lost whatever turned the pen on, so the pen must be cleared too —
// otherwise a dropped `ESC[22m` leaves bold applied to everything written after
// (STA-4042: hidden-delivery gate drops the reset, the abandoned restore then
// drains queued foreground chunks under the stale pen).
export const RESET_GRAPHIC_RENDITION = '\x1b[0m'
/** Dead-TUI bytes feed a fresh shell; clear their pen and mouse modes before re-serialization. */
export const COLD_RESTORE_SEED_MODE_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_MOUSE_REPORTING}`
// CAN, not a bare ESC: xterm dispatches OSC/DCS/APC with
// `success = code !== 0x18 && code !== 0x1a`, so ESC grounds the parser but
@@ -72,8 +69,6 @@ const REPLAY_BASELINE_BUFFER_RESET = '\x1b[r'
// Last, so the saved-cursor register holds grounded state — otherwise a stranded
// `ESC 7` is reachable through the live TUI's next `ESC 8`. Only a floor: a
// snapshot carrying the model's own DECSC epilogue overwrites it.
const SAVE_GROUNDED_CURSOR = '\x1b7'
/**
* Prologue that puts a pane on `targetAlternateScreen` and grounds it for a
* serialized snapshot. Shared because the parity/fuzz harnesses replay the same