diff --git a/src/main/daemon/AGENTS.md b/src/main/daemon/AGENTS.md index 84b6e7fa20a..ff8a0649229 100644 --- a/src/main/daemon/AGENTS.md +++ b/src/main/daemon/AGENTS.md @@ -168,7 +168,7 @@ hands → probe once more → `rename` in one syscall → verify we kept it. - **Do not identify an entry by `birthtimeMs`.** Node documents it as sometimes holding the ctime, filesystems without a birth time report the epoch, and its granularity is often coarser than the events it must separate. Three attempts to patch around this produced three more defects; inode - recycling is now settled by asking whether anything is *serving*. + recycling is now settled by asking whether anything is _serving_. - **Do not add a sweeper.** Deciding whether someone else's leftover is safe to delete is the question this design retired; the last one produced five defects, including deleting a live listener's only pathname. Every actor removes its own scratch name on each non-crash path. diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5ae88131d13..591f8856f55 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -9,6 +9,7 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, + RESET_AFTER_BYTE_GAP, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' @@ -10564,7 +10565,7 @@ describe('connectPanePty', () => { claim: true }) expect(written).toContain(viewportClear) - expect(written).not.toContain('\x1b[2J\x1b[3J\x1b[H') + expect(written).not.toContain(`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`) expect(written).toEqual( expect.arrayContaining([coldScrollback, POST_REPLAY_MODE_RESET, blankViewport]) ) @@ -12457,6 +12458,26 @@ describe('connectPanePty', () => { return { transport, pane, dataCallback: capturedDataCallback.current!, binding } } + // STA-4042 root: the restore-needed marker is the ONE point where "bytes + // were dropped" is known. The emulator carries state across chunks just like + // the cross-chunk parser the handler already resets, so the gap is closed + // here rather than left to each recovery path to remember. + it('closes the emulator state gap when a drop is announced', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + pane.terminal.write.mockClear() + + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + await flushAsyncTicks(4) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const gapReset = written.find((data) => data === RESET_AFTER_BYTE_GAP) + expect(gapReset).toBeDefined() + }) + it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => { enableMainAuthority() const deps = createDeps({ isVisibleRef: { current: false } }) @@ -15819,7 +15840,10 @@ describe('connectPanePty', () => { expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) expect(pane.terminal.resize).toHaveBeenCalledWith(100, 30) - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, + expect.any(Function) + ) expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) disposable.dispose() @@ -15870,7 +15894,7 @@ describe('connectPanePty', () => { expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( @@ -15878,16 +15902,16 @@ describe('connectPanePty', () => { expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, expect.any(Function) ) const writes = (pane.terminal.write as ReturnType).mock.calls.map( (call) => call[0] ) expect(writes.indexOf('preserved-shell-history\r\n')).toBeLessThan( - writes.indexOf('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + writes.indexOf(`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`) ) - expect(writes.indexOf('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + expect(writes.indexOf(`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`)).toBeLessThan( writes.indexOf('altscreen-snapshot\r\n') ) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) @@ -16089,7 +16113,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('abandons a stalled hidden restore and drains pending foreground chunks warning-first', async () => { + it('abandons a stalled hidden restore with reset, warning, then pending foreground', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -16151,6 +16175,7 @@ describe('connectPanePty', () => { const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable')) const combinedLiveIndex = written.indexOf(firstLive + secondLive) expect(warningIndex).toBeGreaterThanOrEqual(0) + expect(written[warningIndex - 1]).toBe(RESET_AFTER_BYTE_GAP) expect(combinedLiveIndex).toBeGreaterThan(warningIndex) snapshot.resolve({ @@ -16168,6 +16193,107 @@ describe('connectPanePty', () => { disposable.dispose() }) + // STA-4042: the hidden-delivery gate drops renderer-bound bytes, so the span it + // ate can contain the `ESC[22m` closing a bold run. Abandoning the restore means + // no snapshot will rebuild the buffer, so unless the pen is cleared here every + // drained and subsequent cell inherits bold — the "regular text renders bold" + // field report. + it('clears the SGR pen before draining abandoned foreground chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() + getMainBufferSnapshot.mockReturnValue(snapshot.promise) + // The dropped span is where `ESC[22m` would have been; the pen is left bold. + const hidden = '\x1b[1mbold-run-opened-while-hidden\r\n' + const live = 'live-after-reveal\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + vi.useFakeTimers() + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(4) + + // Let the foreground deadline expire so the restore is abandoned. + vi.advanceTimersByTime(750) + vi.advanceTimersByTime(0) + await flushAsyncTicks(10) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const resetIndex = written.indexOf(RESET_AFTER_BYTE_GAP) + const liveIndex = written.findIndex((data) => data.includes('live-after-reveal')) + expect(resetIndex).toBeGreaterThanOrEqual(0) + expect(liveIndex).toBeGreaterThanOrEqual(0) + expect(resetIndex).toBeLessThan(liveIndex) + disposable.dispose() + }) + + it('grounds byte-gap state before a remote restore re-arms', async () => { + const { connectPanePty } = await import('./pty-connection') + const remotePtyId = 'remote:env-1@@terminal-rearm' + const transport = createMockTransport(remotePtyId) + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return remotePtyId + }) + const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() + transport.serializeBuffer = vi.fn().mockReturnValue(snapshot.promise) + transportFactoryQueue.push(transport) + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'remote-live-after-rearm\r\n' + + const pane = createPane(1) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + vi.useFakeTimers() + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(4) + + vi.advanceTimersByTime(750) + await flushAsyncTicks(10) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const resetIndex = written.indexOf(RESET_AFTER_BYTE_GAP) + const liveIndex = written.indexOf(live) + expect(resetIndex).toBeGreaterThanOrEqual(0) + expect(liveIndex).toBeGreaterThan(resetIndex) + expect(written.join('')).not.toContain('main recovery was unavailable') + + disposable.dispose() + }) + it('falls back after repeated null hidden restore retries and drains blocked foreground', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -16684,7 +16810,7 @@ describe('connectPanePty', () => { expect(pane.terminal.clear).toHaveBeenCalled() expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, expect.any(Function) ) disposable.dispose() @@ -17582,7 +17708,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 9fbeee199e2..b5800e1bdc1 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_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, + RESET_AFTER_BYTE_GAP, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' @@ -414,7 +415,7 @@ const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250 // Why: this is only shown if hidden renderer output was skipped and main-owned // terminal state is unavailable, so the user has an explicit loss signal. const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = - '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n' + '\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n' type E2eTerminalPtyDataInjectionApi = { inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean keys: () => string[] @@ -6208,6 +6209,17 @@ export function connectPanePty( noteHiddenOutputRestoreFloodBackpressure() return } + // Why the emulator too: it carries state across chunks exactly like the + // parser does. If the gap swallowed the `ESC[22m` closing a bold run, + // every cell written afterwards inherits it. This marker is the one point + // where "bytes were dropped" is known, so ground the pen here rather than + // relying on each recovery path to remember (STA-4042). + // Why after the backpressure return and not before: under flood these + // markers arrive continuously, and writing per marker would add work in + // exactly the case that guard exists to damp. The flood path repaints via + // buildMainModelSnapshotReplayWrites, which grounds the pen itself, so + // nothing is lost by skipping it here. + writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) // Why: a marker during an in-flight restore means that snapshot may predate the drop, so a fresh one must follow; capture BEFORE the mark, which starts a restore synchronously on a visible pane. const restoreWasInFlight = hiddenOutputRestoreInFlight !== null markHiddenOutputRestoreNeeded() @@ -7108,6 +7120,7 @@ export function connectPanePty( cycle: hiddenOutputRestoreRemoteAbandonCycles }) noteHiddenOutputRestoreFloodBackpressure() + writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) return true } @@ -7161,6 +7174,13 @@ export function connectPanePty( clearHiddenOutputRestoreFloodRepaintTimer() writeRestoreUnavailableWarning() } + // Why not an else: the unavailable warning is plain text and carries no SGR + // of its own, so folding the reset into the other branch skipped it on the + // primary abandon path — the one that declares the bytes unrecoverable. + // Guarded only against the remote re-arm, which writes its own reset. + if (!rearmedRemoteRestore) { + writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) + } if (hadPendingOverflow) { return } @@ -7301,6 +7321,8 @@ export function connectPanePty( } function writeRestoreUnavailableWarning(): void { + // The reset must parse before both the warning and any foreground drain. + writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts index 042f6bed3d8..d917b17799e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' +import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, @@ -11,6 +12,29 @@ function writeTerminal(terminal: Terminal, data: string): Promise { return new Promise((resolve) => terminal.write(data, resolve)) } +describe('RESET_AFTER_BYTE_GAP', () => { + // Real emulator rather than a mock: the guarantee is that a cell written after + // the gap reset carries none of the pen the gap stranded (STA-4042). Scoped to + // SGR — see RESET_AFTER_BYTE_GAP for why the wider grounding was dropped. + it('grounds the SGR pen so post-gap cells are not bold', async () => { + const terminal = new Terminal({ cols: 40, rows: 2, allowProposedApi: true }) + try { + // Bold opened and never closed — exactly what a dropped `ESC[22m` leaves. + await writeTerminal(terminal, '\x1b[1mBOLD') + await writeTerminal(terminal, `${RESET_AFTER_BYTE_GAP}after`) + + const line = terminal.buffer.active.getLine(0) + expect(line?.translateToString(true)).toBe('BOLDafter') + // The pre-gap run keeps its bold; only what follows the reset is grounded. + expect(line?.getCell(0)?.isBold()).not.toBe(0) + expect(line?.getCell(4)?.isBold()).toBe(0) + expect(line?.getCell(8)?.isBold()).toBe(0) + } finally { + terminal.dispose() + } + }) +}) + describe('hasPositiveTerminalDimensions', () => { it('accepts only finite positive numeric pairs', () => { expect(hasPositiveTerminalDimensions(80, 24)).toBe(true) @@ -42,7 +66,7 @@ describe('resolvePositiveTerminalDimensions', () => { describe('buildMainModelSnapshotReplayWrites', () => { it('clears normal buffer + scrollback before a normal-buffer snapshot', () => { expect(buildMainModelSnapshotReplayWrites({ data: 'shell-output' })).toEqual([ - '\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, 'shell-output' ]) }) @@ -59,9 +83,9 @@ describe('buildMainModelSnapshotReplayWrites', () => { scrollbackAnsi: 'normal-history' }) ).toEqual([ - '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, 'normal-history', - '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'alt-frame' ]) }) @@ -69,7 +93,7 @@ describe('buildMainModelSnapshotReplayWrites', () => { it('enters a cleared alt screen when no split scrollback is available', () => { expect( buildMainModelSnapshotReplayWrites({ data: 'alt-frame', alternateScreen: true }) - ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'alt-frame']) + ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'alt-frame']) }) }) @@ -153,9 +177,9 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { { skipAltFrame: true } ) ).toEqual([ - '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, 'normal-history', - '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'complete-live-state' ]) }) @@ -172,7 +196,7 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { }, { skipAltFrame: true } ) - ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'complete-live-state']) + ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'complete-live-state']) }) it('keeps composed data when an older producer omits the mode boundary', () => { @@ -181,12 +205,33 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { { data: 'legacy-modes-and-frame', alternateScreen: true }, { skipAltFrame: true } ) - ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'legacy-modes-and-frame']) + ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'legacy-modes-and-frame']) }) it('never drops a normal-buffer snapshot, whose rows reflow correctly', () => { expect( buildMainModelSnapshotReplayWrites({ data: 'shell-output' }, { skipAltFrame: true }) - ).toEqual(['\x1b[2J\x1b[3J\x1b[H', 'shell-output']) + ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, 'shell-output']) + }) + + // STA-4042: a replay only runs because renderer-bound bytes were dropped, so + // the pen that the drop interrupted is unknown. Every branch must clear it + // BEFORE replaying content, or the whole restored buffer inherits it — the + // "regular text renders bold" field report. + it('clears the SGR pen before any replayed content in every branch', () => { + const branches = [ + buildMainModelSnapshotReplayWrites({ data: 'normal-buffer' }), + buildMainModelSnapshotReplayWrites({ + data: 'alt-frame', + alternateScreen: true, + scrollbackAnsi: 'scrollback' + }), + buildMainModelSnapshotReplayWrites({ data: 'alt-frame', alternateScreen: true }) + ] + for (const writes of branches) { + expect(writes[0].startsWith(RESET_AFTER_BYTE_GAP)).toBe(true) + // Nothing may be replayed ahead of the first reset. + expect(writes.indexOf('scrollback')).not.toBe(0) + } }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index aa41ff6d645..81fa4b6b525 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -1,5 +1,6 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' import { readProposedPaneFitDimensions } from '@/lib/pane-manager/pane-fit' +import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' /** * Shared guards and write choreography for painting a main-model snapshot into @@ -81,7 +82,11 @@ export function buildMainModelSnapshotReplayWrites( if (!snapshot.alternateScreen) { // Why: \x1b[3J wipes xterm scrollback; safe here because a normal-buffer // snapshot carries its own history in data (mirrors pty-transport.ts). - return ['\x1b[2J\x1b[3J\x1b[H', snapshot.data] + // Why the leading SGR reset: a replay only happens because renderer-bound + // bytes were dropped, so the pen the drop interrupted is unknown — without + // clearing it the whole replayed buffer inherits it (STA-4042). The + // alt-screen branches below already reset; this one did not. + return [`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, snapshot.data] } // Older snapshot producers do not expose the mode/frame boundary. Keep their // composed data rather than dropping terminal modes together with the frame. @@ -92,15 +97,18 @@ export function buildMainModelSnapshotReplayWrites( if (snapshot.scrollbackAnsi !== undefined) { // Why: main serializes normal + alt buffers separately; rebuild normal // while active, then return to a clean alt frame. + // Why the reset moved to the front too: scrollbackAnsi was replayed BEFORE + // the existing \x1b[0m, so the normal-buffer history inherited the stale pen + // even though the alt frame after it did not (STA-4042). return [ - '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, snapshot.scrollbackAnsi, - '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, ...altFrame ] } // Why: the snapshot's ?1049h no-ops when already on alt screen and skips // blank cells; clear the alt buffer so the pre-hide frame can't bleed // through blank cells (spares normal-buffer scrollback). - return ['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', ...altFrame] + return [`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, ...altFrame] } diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index 74287a4e17c..08fb62a35fe 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -32,6 +32,33 @@ 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' + +/** + * State to re-establish when renderer-bound bytes were dropped and the gap + * cannot be replayed away. + * + * Scoped to the pen on purpose. A gap can in principle strand other carried + * state — an ISO 2022 charset designation, an open OSC 8 hyperlink, a partial + * escape — and earlier revisions of this reset covered those too. They are + * dropped here because each one changes what a live TUI sees on a path that + * runs in production, none of them has a reported symptom behind it, and the + * pen is what the field reports actually show (STA-4042). Widen this only with + * a symptom to point at. + * + * Deliberately NOT a soft reset (DECSTR) either: xterm's DECSTR wipes kitty + * flags and stacks (see terminal-kitty-keyboard-mode-tracker applySoftReset), + * which would silence Option chords for a live agent that negotiates them only + * at startup. + */ +export const RESET_AFTER_BYTE_GAP = RESET_GRAPHIC_RENDITION + // Why: DECTCEM applies in emission order, so the payload's last ?25l/?25h is the cursor state the TUI left. export function replayPayloadEndsWithCursorHidden(payload: string): boolean { const hideIndex = payload.lastIndexOf('\x1b[?25l')