mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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.
|
||||
|
||||
+21
-5
@@ -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') })
|
||||
|
||||
+21
-7
@@ -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) {
|
||||
|
||||
+36
-7
@@ -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()
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+5
-2
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user