diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index c3ba29bb542..8d79ecfab67 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -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. diff --git a/mobile/src/terminal/terminal-webview-init-surface.test.ts b/mobile/src/terminal/terminal-webview-init-surface.test.ts index fdef4f5b4df..b32d7e6858e 100644 --- a/mobile/src/terminal/terminal-webview-init-surface.test.ts +++ b/mobile/src/terminal/terminal-webview-init-surface.test.ts @@ -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. diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 09aeb82d3f7..cf16a93c0e6 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -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) diff --git a/src/main/daemon/terminal-history-seed-segments.test.ts b/src/main/daemon/terminal-history-seed-segments.test.ts index 7852a7eac89..ec022a94b8b 100644 --- a/src/main/daemon/terminal-history-seed-segments.test.ts +++ b/src/main/daemon/terminal-history-seed-segments.test.ts @@ -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` + ) + }) }) diff --git a/src/main/daemon/terminal-history-seed-segments.ts b/src/main/daemon/terminal-history-seed-segments.ts index 670cef152b8..09cd94e8348 100644 --- a/src/main/daemon/terminal-history-seed-segments.ts +++ b/src/main/daemon/terminal-history-seed-segments.ts @@ -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] : [])] } diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts index 77e3603d4f5..ac4e34897b6 100644 --- a/src/main/daemon/terminal-mode-rehydrate-sequences.ts +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 597d5e54b58..8f6915f07a6 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index c3807cffeac..a9be5af9817 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -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. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts index debbd7f46be..8e29afb8763 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts @@ -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') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts index 38644059cc3..a1c71fdbce4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts index 668f50b99ab..0e130d0b00e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts index c3dd399431e..fa110cdfe0e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts index 98deb8897f0..39191d5d36a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts index e49ec7d52e5..bf4d4a34feb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts @@ -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).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) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 6aa84444817..397d5bf84a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts b/src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts new file mode 100644 index 00000000000..63c6bc72c2f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts @@ -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 { + 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 { + 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[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) + }) +}) diff --git a/src/shared/terminal-mode-reset-profiles.test.ts b/src/shared/terminal-mode-reset-profiles.test.ts index cef6d77a03b..4026be7edcd 100644 --- a/src/shared/terminal-mode-reset-profiles.test.ts +++ b/src/shared/terminal-mode-reset-profiles.test.ts @@ -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') } diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index 5c912cb3da2..01a7f62acc3 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -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