fix(terminal): clear the SGR pen on hidden-output restore and abandon (#14241)

* fix(terminal): clear the SGR pen on hidden-output restore and abandon

The hidden-delivery gate drops renderer-bound PTY bytes while a pane has no
visible view. The renderer's xterm is a separate emulator from the daemon
model, so when the dropped span contains the sequence closing an attribute run
(e.g. the ESC[22m ending a bold run) the renderer's pen stays latched while the
daemon model stays correct. Neither recovery path cleared it:

- buildMainModelSnapshotReplayWrites reset the pen on the two alt-screen
  branches but not on the normal-buffer branch, and replayed scrollbackAnsi
  ahead of the reset it did emit, so replayed content inherited the stale pen.
- abandonHiddenOutputRestoreAndDrainPendingForeground declares the dropped
  bytes unrecoverable (it writes a user-visible warning) and then drained the
  queued foreground chunks straight into xterm under that same unknown pen.

Add RESET_GRAPHIC_RENDITION and emit it ahead of replayed content in every
branch, and on both abandon exits. The existing profiles all clear DEC mode
bits and none touched SGR.

* fix(terminal): also restore charset designation after a dropped-byte gap

A gap can strand more than the pen: a dropped `ESC(B` leaves line-drawing
selected and ordinary text renders as box characters. Route both recovery
paths through one RESET_AFTER_BYTE_GAP profile covering SGR + charset.

Deliberately not a soft reset (DECSTR): xterm's DECSTR wipes kitty flags and
stacks (terminal-kitty-keyboard-mode-tracker applySoftReset), which would
silence Option chords for a live agent that negotiates them only at startup.
Reset what a gap strands and no running TUI re-asserts on its own; leave the
rest to its next repaint.

* fix(terminal): close the emulator state gap where the drop is announced

The restore-needed marker is the single point where "renderer-bound bytes
were dropped" is known. The handler already resets the transport's
cross-chunk parser state there for exactly this reason — a partial escape
spanning the gap would corrupt the next chunk. The emulator carries state
across chunks in the same way, so reset it in the same place.

That makes restore, abandon and overflow all start from a known pen by
construction, instead of each recovery path having to remember.

* fix(terminal): fully ground byte-gap recovery state

* fix(terminal): reset state when remote restore re-arms

* fix(terminal): keep the gap reset on the warning abandon path

The reset had been folded into an else of the unavailable-warning branch, so
the primary abandon path relied on the marker's earlier reset still standing.
It does not always: this function captures a replayingSnapshot, so it can run
after a partially-applied replay has already moved the pen, and the warning
itself is plain text carrying no SGR. Restore the unconditional write, guarded
only against the remote re-arm which writes its own.

* fix(terminal): scope the byte-gap reset to the pen and skip it under flood

Two regression risks in the widened recovery reset, both removed:

- The profile had grown to cancel partial escapes, close OSC 8 and re-designate
  all four ISO 2022 registers. Each changes what a live TUI sees on a path that
  runs in production, and none has a reported symptom behind it — a legitimately
  line-drawing TUI that does not re-designate after recovery would render box
  characters as ASCII. Scope back to SGR, which is what the field reports show.

- The marker-time reset ran before the flood-backpressure guard, so a flood
  wrote one reset per marker in exactly the case that guard exists to damp. Move
  it after; the flood path repaints through buildMainModelSnapshotReplayWrites,
  which grounds the pen itself, so no coverage is lost.

Coverage verified non-vacuous: blanking RESET_AFTER_BYTE_GAP fails 5 tests
across all four paths (replay branches, marker, abandon-with-warning, remote
re-arm).
This commit is contained in:
Brennan Benson
2026-08-13 12:11:20 -07:00
committed by GitHub
parent cbca291aa7
commit 0824ed39ea
6 changed files with 252 additions and 24 deletions
+1 -1
View File
@@ -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.
@@ -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<typeof vi.fn>).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(
@@ -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
}
@@ -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<void> {
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)
}
})
})
@@ -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]
}
@@ -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')