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 -1
View File
@@ -752,7 +752,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
if (typeof replayData === 'string' && replayData.length > 0) {
enqueueWrite(replayData);
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
enqueueWrite(ESC + '[0m' + replayData);
}
// Why: reset eviction tracking + attach observers for the new term.
@@ -26,7 +26,7 @@ type RegisteredWindowListener = {
type: string
}
function makeTerminal(writeCallbacks: Array<() => void>) {
function makeTerminal(writeCallbacks: Array<() => void>, writes: string[]) {
const terminal = {
cols: 80,
rows: 24,
@@ -45,7 +45,8 @@ function makeTerminal(writeCallbacks: Array<() => void>) {
getLine: () => null
}
},
write(_data: string, callback?: () => void) {
write(data: string, callback?: () => void) {
writes.push(data)
if (callback) {
writeCallbacks.push(callback)
}
@@ -93,6 +94,7 @@ describe('terminal WebView init surface replacement', () => {
let terminalOptions: TerminalOptions[]
let terminals: TerminalStub[]
let writeCallbacks: Array<() => void>
let writes: string[]
beforeEach(() => {
animationFrames = []
@@ -100,6 +102,7 @@ describe('terminal WebView init surface replacement', () => {
terminalOptions = []
terminals = []
writeCallbacks = []
writes = []
const addWindowEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation(((
type: string,
@@ -121,7 +124,7 @@ describe('terminal WebView init surface replacement', () => {
}
webWindow.Terminal = function (options: TerminalOptions) {
terminalOptions.push(options)
const terminal = makeTerminal(writeCallbacks)
const terminal = makeTerminal(writeCallbacks, writes)
terminals.push(terminal)
return terminal
} as unknown as new (options: TerminalOptions) => TerminalStub
@@ -153,6 +156,13 @@ describe('terminal WebView init surface replacement', () => {
}
})
it('grounds the initial replay without clearing the host live pen', () => {
dispatchInit(80, '\x1b[1mBOLD-RUN-LEFT-OPEN')
animationFrames.shift()?.()
expect(writes).toEqual(['\x1b[0m\x1b[1mBOLD-RUN-LEFT-OPEN'])
})
it('commits only the newest surface when phone-fit init calls overlap', () => {
// Why: restored terminals can receive desktop scrollback, a phone resize,
// and phone scrollback before any xterm replay callback has completed.
@@ -144,7 +144,7 @@ describe('TerminalWebView text zoom', () => {
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)")
expect(open).toBeGreaterThanOrEqual(0)
expect(unicode).toBeGreaterThan(open)
expect(replay).toBeGreaterThan(unicode)
@@ -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')
@@ -37,6 +37,7 @@ import {
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
POST_REPLAY_MODE_RESET,
replayPayloadEndsWithCursorHidden,
RESET_GRAPHIC_RENDITION,
RESET_KITTY_KEYBOARD_PROTOCOL,
RESET_TERMINAL_CURSOR_STYLE
} from '../../../../shared/terminal-mode-reset-profiles'
@@ -456,7 +457,10 @@ describe('restoreScrollbackBuffers', () => {
restoredViewportBlankingPanesRef
)
expect(writes).toEqual(['restored output', '\r\n', POST_REPLAY_MODE_RESET])
expect(writes).toEqual([
`${RESET_GRAPHIC_RENDITION}restored output${RESET_GRAPHIC_RENDITION}\r\n`,
POST_REPLAY_MODE_RESET
])
expect(manager.hasWebglRenderer).toHaveBeenCalledWith(1)
expect(restoredViewportBlankingPanesRef.current.has(1)).toBe(true)
expect(replayingPanesRef.current.size).toBe(0)
@@ -4,7 +4,10 @@ import type {
TerminalPaneSplitDirection
} from '../../../../shared/terminal-tab-types'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { POST_REPLAY_MODE_RESET } from '../../../../shared/terminal-mode-reset-profiles'
import {
POST_REPLAY_MODE_RESET,
RESET_GRAPHIC_RENDITION
} from '../../../../shared/terminal-mode-reset-profiles'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport'
@@ -179,9 +182,13 @@ export function restoreScrollbackBuffers(
}
if (buf.length > 0) {
// replayIntoTerminal: buffer queries (DA1/DECRQM/CPR) would auto-reply into the new shell's stdin. See replay-guard.ts.
replayIntoTerminal(pane, replayingPanesRef, buf, renderOptions)
// Newline first so the new shell prompt doesn't trigger zsh's PROMPT_EOL_MARK (%) indicator.
replayIntoTerminal(pane, replayingPanesRef, '\r\n', renderOptions)
replayIntoTerminal(
pane,
replayingPanesRef,
`${RESET_GRAPHIC_RENDITION}${buf}${RESET_GRAPHIC_RENDITION}\r\n`,
renderOptions
)
// The grounded newline avoids both the prompt marker and background-color erase from the captured pen.
// Clear mode bits the buffer replayed: the fresh shell has no TUI to consume them. See POST_REPLAY_MODE_RESET.
replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET, renderOptions)
// Why: connection resolution runs after layout replay; only fresh-shell paths move these rows into scrollback.
@@ -1,6 +1,7 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles'
import { flushAsyncTicks } from './pty-connection-test-async'
import { UUID_RE } from './pty-connection-test-constants'
import {
@@ -195,7 +196,10 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_GRAPHIC_RENDITION}cold-payload`,
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
expect.stringContaining('--- session restored ---'),
expect.any(Function)
@@ -286,7 +290,10 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_GRAPHIC_RENDITION}cold-payload`,
expect.any(Function)
)
expect(transport.sendInput).not.toHaveBeenCalled()
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({
@@ -361,7 +368,10 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_GRAPHIC_RENDITION}cold-payload`,
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
expect.stringContaining('--- session restored ---'),
expect.any(Function)
@@ -512,7 +522,10 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_GRAPHIC_RENDITION}cold-payload`,
expect.any(Function)
)
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'restored')
expect(transport.sendInput).not.toHaveBeenCalled()
expect(transport.connect).toHaveBeenCalledWith(
@@ -589,7 +602,10 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_GRAPHIC_RENDITION}cold-payload`,
expect.any(Function)
)
expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled()
expect(transport.connect).not.toHaveBeenCalledWith(
expect.objectContaining({ command: expect.stringContaining('resume') })
@@ -1,6 +1,9 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { POST_REPLAY_MODE_RESET } from '../../../../shared/terminal-mode-reset-profiles'
import {
POST_REPLAY_MODE_RESET,
RESET_GRAPHIC_RENDITION
} from '../../../../shared/terminal-mode-reset-profiles'
import { Terminal } from '@xterm/headless'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
import {
@@ -180,7 +183,10 @@ describe('connectPanePty', () => {
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-payload', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_GRAPHIC_RENDITION}snapshot-payload`,
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith('replay-payload', expect.any(Function))
})
@@ -215,7 +221,10 @@ describe('connectPanePty', () => {
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
expect(pane.terminal.write).toHaveBeenCalledWith('replay-payload', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_GRAPHIC_RENDITION}replay-payload`,
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith('cold-payload', expect.any(Function))
// Why: the replay branch supersedes cold-restore but must still ack, or the daemon redelivers the cold-restore payload next reattach.
expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty')
@@ -286,7 +295,8 @@ describe('connectPanePty', () => {
const recoveredCols = 20
const recoveredRows = 3
const coldScrollback = '\x1b[1;1HCOLD\x1b[1;15HEND\r\nCOLD_SOURCE_ROW_02'
const viewportClear = '\x1b[2J\x1b[H'
const groundedColdScrollback = `${RESET_GRAPHIC_RENDITION}${coldScrollback}`
const viewportClear = `${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H`
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return {
@@ -362,10 +372,10 @@ describe('connectPanePty', () => {
expect(written).toContain(viewportClear)
expect(written).not.toContain(NORMAL_BUFFER_PROLOGUE)
expect(written).toEqual(
expect.arrayContaining([coldScrollback, POST_REPLAY_MODE_RESET, blankViewport])
expect.arrayContaining([groundedColdScrollback, POST_REPLAY_MODE_RESET, blankViewport])
)
expect(written.indexOf(viewportClear)).toBeLessThan(written.indexOf(coldScrollback))
expect(written.indexOf(coldScrollback)).toBeLessThan(written.indexOf(blankViewport))
expect(written.indexOf(viewportClear)).toBeLessThan(written.indexOf(groundedColdScrollback))
expect(written.indexOf(groundedColdScrollback)).toBeLessThan(written.indexOf(blankViewport))
const viewportClearOperation = operations.findIndex(
(operation) => operation.kind === 'write' && operation.data === viewportClear
)
@@ -388,6 +398,7 @@ describe('connectPanePty', () => {
rendered,
'KEEP_1\r\nKEEP_2\r\nOLD_ROW_1\r\nOLD_ROW_2\r\nOLD_ROW_3\r\nOLD_ROW_4\r\nOLD_ROW_5'
)
await writeHeadlessTerminal(rendered, '\x1b[44m')
let replayedAtRecoveredGrid = false
let sourceGridLines: string[] = []
for (const operation of operations) {
@@ -410,6 +421,9 @@ describe('connectPanePty', () => {
}
} else {
await writeHeadlessTerminal(rendered, operation.data)
if (operation.data === viewportClear) {
expect(rendered.buffer.active.getLine(0)?.getCell(0)?.getBgColor()).toBe(-1)
}
}
}
if (sourceGridLines.length === 0) {
@@ -2,9 +2,11 @@ import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
POST_REPLAY_MODE_RESET,
POST_REPLAY_REATTACH_RESET
POST_REPLAY_REATTACH_RESET,
RESET_GRAPHIC_RENDITION
} from '../../../../shared/terminal-mode-reset-profiles'
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
import { Terminal } from '@xterm/headless'
import { flushAsyncTicks, createDeferred, writeHeadlessTerminal } from './pty-connection-test-async'
import { createRect } from './pty-connection-test-dom'
import {
LEAF_1,
@@ -149,12 +151,13 @@ describe('connectPanePty', () => {
await restoreTerminalTestGlobals()
})
it('resets reattach renderer state after daemon snapshot replay without applying the full mode reset', async () => {
it('clears the captured pen for a normal-buffer fallback reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('tab-pty')
const snapshot = 'ORCA-SGR-REPRO \x1b[1mBOLD-RUN-LEFT-OPEN\x1b[1;34H'
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return { id: sessionId, snapshot: '\x1b[?1004hrestored snapshot' }
return { id: sessionId, snapshot }
}
return null
})
@@ -179,9 +182,12 @@ describe('connectPanePty', () => {
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
'\x1b[?1004hrestored snapshot',
`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`,
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_GRAPHIC_RENDITION}${snapshot}`,
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith(
@@ -192,6 +198,26 @@ describe('connectPanePty', () => {
POST_REPLAY_MODE_RESET,
expect.any(Function)
)
const rendered = new Terminal({ cols: 40, rows: 6, allowProposedApi: true })
try {
await writeHeadlessTerminal(rendered, '\x1b[1;44mDIRTY')
for (const [data] of pane.terminal.write.mock.calls) {
if (data) {
await writeHeadlessTerminal(rendered, data)
}
}
await writeHeadlessTerminal(rendered, 'PLAIN')
const line = rendered.buffer.active.getLine(rendered.buffer.active.baseY)
const plainColumn = line?.translateToString(true).indexOf('PLAIN') ?? -1
expect(line?.getCell(plainColumn)?.isBold()).toBe(0)
expect(line?.getCell(plainColumn)?.getFgColor()).toBe(-1)
expect(line?.getCell(plainColumn)?.getBgColor()).toBe(-1)
expect(rendered.buffer.active.getLine(5)?.getCell(39)?.getBgColor()).toBe(-1)
} finally {
rendered.dispose()
}
})
it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => {
@@ -397,6 +423,7 @@ describe('connectPanePty', () => {
expect(writes.join('')).toContain('PREFIX-SCROLLBACK')
expect(writes.join('')).toContain('RESTORE-LIVE-STATE')
expect(writes.join('')).not.toContain('ALT-FRAME-BODY')
expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}PREFIX-SCROLLBACKRESTORE-LIVE-STATE`)
expect(writes).toContain(POST_REPLAY_MODE_RESET)
})
@@ -443,7 +470,9 @@ describe('connectPanePty', () => {
}
)
const snapshotWriteCall = pane.terminal.write.mock.invocationCallOrder.find(
(_order, index) => pane.terminal.write.mock.calls[index][0] === '\x1b[?1004hrestored snapshot'
(_order, index) =>
pane.terminal.write.mock.calls[index][0] ===
`${RESET_GRAPHIC_RENDITION}\x1b[?1004hrestored snapshot`
)
expect(resizeToSnapshotCall).toBeDefined()
expect(snapshotWriteCall).toBeDefined()
@@ -1,6 +1,7 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles'
import { flushAsyncTicks } from './pty-connection-test-async'
import {
LEAF_1,
@@ -260,7 +261,7 @@ describe('connectPanePty', () => {
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(20)
const snapshotIndex = writes.indexOf('authoritative-snapshot')
const snapshotIndex = writes.indexOf(`${RESET_GRAPHIC_RENDITION}authoritative-snapshot`)
expect(snapshotIndex).toBeGreaterThanOrEqual(0)
expect(writes).not.toContain('post-snapshot-live')
for (let step = 0; step < 40; step += 1) {
@@ -1,6 +1,9 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { POST_REPLAY_REATTACH_RESET } from '../../../../shared/terminal-mode-reset-profiles'
import {
POST_REPLAY_REATTACH_RESET,
RESET_GRAPHIC_RENDITION
} from '../../../../shared/terminal-mode-reset-profiles'
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
@@ -204,7 +207,7 @@ describe('connectPanePty', () => {
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'leaf-session')
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'leaf-session')
// Why: the relay's replay buffer holds full history, so clear xterm before writing to avoid duplicating prior-session content.
expect(writes).toContain('\x1b[2J\x1b[3J\x1b[H')
expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`)
expect(writes).toContain('restored-ssh-output')
expect(writes).toContain(POST_REPLAY_REATTACH_RESET)
expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH')
@@ -5,6 +5,7 @@ import {
POST_REPLAY_MODE_RESET,
POST_REPLAY_REATTACH_RESET,
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
RESET_GRAPHIC_RENDITION,
RESET_KITTY_KEYBOARD_PROTOCOL,
RESET_TERMINAL_CURSOR_STYLE
} from '../../../../shared/terminal-mode-reset-profiles'
@@ -415,6 +416,9 @@ describe('connectPanePty', () => {
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map(
([data]) => data as string
)
expect(writes).toContain(
`${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ `
)
expect(writes).toContain(POST_REPLAY_MODE_RESET)
expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)
})
@@ -125,6 +125,7 @@ import {
POST_REPLAY_REATTACH_RESET,
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
RESET_AFTER_BYTE_GAP,
RESET_GRAPHIC_RENDITION,
RESET_KITTY_KEYBOARD_PROTOCOL,
RESET_TERMINAL_CURSOR_STYLE
} from '../../../../shared/terminal-mode-reset-profiles'
@@ -8272,7 +8273,7 @@ export function connectPanePty(
suppressStructuralReplayPtyResize = false
}
}
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`)
// Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload.
applySnapshotKittyKeyboardModes(daemonSnapshotReplay, {
kittyKeyboardFlags: connectResult.snapshotKittyKeyboardFlags,
@@ -8288,10 +8289,16 @@ export function connectPanePty(
connectResult.snapshotCols,
readProposedTerminalCols(pane)
)
const groundDaemonSnapshot =
Boolean(connectResult.coldRestore) ||
(!shouldPreserveAgentReattachModes() &&
!(connectResult.isAlternateScreen ?? kittyKeyboardModes.isAlternateScreen))
writeReplayData(
daemonAltFrameSkippable
? snapshotPrefixAnsi + snapshotFrameRestoreAnsi
: daemonSnapshotReplay
`${groundDaemonSnapshot ? RESET_GRAPHIC_RENDITION : ''}${
daemonAltFrameSkippable
? snapshotPrefixAnsi + snapshotFrameRestoreAnsi
: daemonSnapshotReplay
}`
)
writeReplayData(
reattachReplayResetSequence(
@@ -8382,7 +8389,7 @@ export function connectPanePty(
} else if (connectResult?.replay) {
rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true })
// Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`)
// Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack.
// A constructor-fresh mirror (window reload) first demotes to unproven:
// the replay window proves nothing about negotiations that predate it.
@@ -8390,7 +8397,9 @@ export function connectPanePty(
kittyKeyboardModes.resetForSnapshot()
}
kittyKeyboardModes.scanReplay(connectResult.replay)
writeReplayData(connectResult.replay)
writeReplayData(
`${connectResult.coldRestore ? RESET_GRAPHIC_RENDITION : ''}${connectResult.replay}`
)
writeReplayData(
reattachReplayResetSequence(
connectResult.replay,
@@ -8420,7 +8429,7 @@ export function connectPanePty(
// The current xterm grid remains a safe lower bound for blanking.
}
// Why: shrinking first would promote clipped stale viewport rows into scrollback, beyond the reach of a later viewport-only clear.
writeReplayData('\x1b[2J\x1b[H')
writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H`)
await waitForTerminalReplayWritesParsed(pane.terminal)
if (!isCurrentReattachPayload()) {
return
@@ -8443,7 +8452,7 @@ export function connectPanePty(
}
}
// Why: recorded scrollback is raw PTY output that may hold query sequences; xterm.write would auto-reply into the new shell's stdin. See replay-guard.ts.
writeReplayData(connectResult.coldRestore.scrollback)
writeReplayData(`${RESET_GRAPHIC_RENDITION}${connectResult.coldRestore.scrollback}`)
const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup()
const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup)
if (didPrepareResume) {
@@ -0,0 +1,193 @@
import { SerializeAddon } from '@xterm/addon-serialize'
import { Terminal } from '@xterm/headless'
import { afterEach, describe, expect, it } from 'vitest'
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
import {
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
POST_REPLAY_MODE_RESET,
POST_REPLAY_REATTACH_RESET,
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE
} from '../../../../shared/terminal-mode-reset-profiles'
import { restoreScrollbackBuffers } from './layout-serialization'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const UNCLOSED_BOLD_FIXTURE = 'ORCA-SGR-REPRO \x1b[1mBOLD-RUN-LEFT-OPEN\x1b[1;34H'
const terminals: Terminal[] = []
function createTerminal(): Terminal {
const terminal = new Terminal({ cols: 40, rows: 6, scrollback: 20, allowProposedApi: true })
terminals.push(terminal)
return terminal
}
function writeTerminal(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, resolve))
}
function boldAtText(terminal: Terminal, text: string): number {
const buffer = terminal.buffer.normal
for (let row = 0; row < buffer.length; row += 1) {
const line = buffer.getLine(row)
const column = line?.translateToString(true).indexOf(text) ?? -1
if (line && column >= 0) {
return line.getCell(column)?.isBold() ?? 0
}
}
throw new Error(`Missing terminal text: ${text}`)
}
function foregroundAtText(terminal: Terminal, text: string): number {
const buffer = terminal.buffer.normal
for (let row = 0; row < buffer.length; row += 1) {
const line = buffer.getLine(row)
const column = line?.translateToString(true).indexOf(text) ?? -1
if (line && column >= 0) {
return line.getCell(column)?.getFgColor() ?? -1
}
}
throw new Error(`Missing terminal text: ${text}`)
}
async function restoreBuffer(
buffer: string,
options: { initialState?: string; followingOutput?: string } = {}
): Promise<Terminal> {
const terminal = createTerminal()
if (options.initialState) {
await writeTerminal(terminal, options.initialState)
}
const pane = { id: 1, terminal }
const manager = {
getPanes: () => [pane],
hasWebglRenderer: () => true
}
restoreScrollbackBuffers(
manager as unknown as Parameters<typeof restoreScrollbackBuffers>[0],
{ [LEAF_ID]: buffer },
new Map([[LEAF_ID, pane.id]]),
{ current: new Map() }
)
await writeTerminal(terminal, options.followingOutput ?? 'fresh-shell')
return terminal
}
function serialize(data: string): { terminal: Terminal; addon: SerializeAddon; data: string } {
const terminal = createTerminal()
const addon = new SerializeAddon()
terminal.loadAddon(addon)
return { terminal, addon, data }
}
afterEach(() => {
for (const terminal of terminals.splice(0)) {
terminal.dispose()
}
})
describe('fresh-shell terminal restore SGR state', () => {
it('grounds the pen before replaying normal-buffer cells', async () => {
const restored = await restoreBuffer('plain-history', { initialState: '\x1b[1m' })
expect(boldAtText(restored, 'plain-history')).toBe(0)
})
it('clears an unclosed bold run before fresh shell output', async () => {
const restored = await restoreBuffer('\x1b[1mBOLD')
expect(boldAtText(restored, 'BOLD')).not.toBe(0)
expect(boldAtText(restored, 'fresh-shell')).toBe(0)
})
it('grounds the erase attributes before the restored newline scrolls', async () => {
const restored = await restoreBuffer('\x1b[6;1H\x1b[41mX', { followingOutput: '' })
const buffer = restored.buffer.active
const bottomLine = buffer.getLine(buffer.baseY + restored.rows - 1)
expect(bottomLine?.getCell(20)?.getBgColor()).toBe(-1)
})
it('clears the serialized live pen before fresh shell output', async () => {
const source = serialize('\x1b[1mBOLD')
await writeTerminal(source.terminal, source.data)
const restored = await restoreBuffer(source.addon.serialize())
expect(boldAtText(restored, 'BOLD')).not.toBe(0)
expect(boldAtText(restored, 'fresh-shell')).toBe(0)
})
it('clears the captured pen after normal-buffer daemon reattach', async () => {
const terminal = createTerminal()
await writeTerminal(terminal, UNCLOSED_BOLD_FIXTURE)
await writeTerminal(terminal, POST_REPLAY_REATTACH_RESET)
await writeTerminal(terminal, 'PLAIN-TEXT-NO-SGR-WHATSOEVER')
expect(boldAtText(terminal, 'BOLD-RUN-LEFT-OPEN')).not.toBe(0)
expect(boldAtText(terminal, 'PLAIN')).toBe(0)
})
it.each([
['live agent', POST_REPLAY_LIVE_AGENT_REATTACH_RESET],
['alternate-screen TUI', POST_REPLAY_REATTACH_RESET_KEEP_MOUSE]
])('preserves a %s pen across daemon reattach', async (_kind, reset) => {
const terminal = createTerminal()
await writeTerminal(terminal, 'ORCA-SGR-REPRO \x1b[1;34mBOLD-RUN-LEFT-OPEN\x1b[1;34H')
await writeTerminal(terminal, reset)
await writeTerminal(terminal, 'LIVE-CONTINUATION')
expect(boldAtText(terminal, 'BOLD-RUN-LEFT-OPEN')).not.toBe(0)
expect(boldAtText(terminal, 'LIVE')).not.toBe(0)
expect(foregroundAtText(terminal, 'LIVE')).toBe(4)
})
it('clears the captured pen and saved cursor for a fresh shell', async () => {
const terminal = createTerminal()
await writeTerminal(terminal, '\x1b[1mBOLD-RUN-LEFT-OPEN\x1b7')
await writeTerminal(terminal, POST_REPLAY_MODE_RESET)
await writeTerminal(terminal, '\x1b8PLAIN')
expect(boldAtText(terminal, 'PLAIN')).toBe(0)
expect(foregroundAtText(terminal, 'PLAIN')).toBe(-1)
})
it('keeps the synthetic saved-cursor register from restoring bold', async () => {
const source = serialize('\x1b[1mBOLD')
await writeTerminal(source.terminal, source.data)
const snapshot = serializeWithAbsoluteCursor(source.addon, source.terminal, undefined, {
x: 10,
y: 0,
originMode: false
})
const restored = await restoreBuffer(snapshot, { followingOutput: '\x1b8after-restore' })
expect(boldAtText(restored, 'after-restore')).toBe(0)
})
it('grounds the synthetic saved cursor after normal-buffer daemon reattach', async () => {
const source = serialize('\x1b[1mBOLD')
await writeTerminal(source.terminal, source.data)
const snapshot = serializeWithAbsoluteCursor(source.addon, source.terminal, undefined, {
x: 10,
y: 0,
originMode: false
})
const restored = createTerminal()
await writeTerminal(restored, snapshot)
await writeTerminal(restored, POST_REPLAY_REATTACH_RESET)
await writeTerminal(restored, '\x1b8after-reattach')
expect(boldAtText(restored, 'after-reattach')).toBe(0)
})
it('restores alt-screen scrollback without leaking its bold frame', async () => {
const source = serialize('shell-history\x1b[?1049h\x1b[1mBOLD-TUI')
await writeTerminal(source.terminal, source.data)
const restored = await restoreBuffer(source.addon.serialize())
expect(boldAtText(restored, 'shell-history')).toBe(0)
expect(boldAtText(restored, 'fresh-shell')).toBe(0)
})
})
@@ -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