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
@@ -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)
})
})