fix(terminal): skip an escape tail an abandoned hidden-output restore would print (#33905)

* fix(terminal): skip an escape tail an abandoned hidden-output restore would print (#33766)

Main drops a hidden pane's renderer delivery after its model ingests the
bytes, so the first chunk sent on reveal can begin inside an escape whose
start was dropped. When the restore is abandoned, the renderer resets its
parser and writes the queued chunks, and xterm printed the tail (';242m',
the rest of a Codex OSC title).

Main tracks each PTY's escape state over every accepted payload and labels
the first delivery after a hidden or pending-cap drop with the escape it
resumes inside (gapEscapeState). Only the abandon path strips that
continuation; a successful restore keeps every byte so the snapshot's
pending escape tail can complete. No label means no change.

* fix(terminal): make the escape-continuation scan's switch exhaustive (#33766)
This commit is contained in:
Neil
2026-10-26 06:33:23 -07:00
committed by GitHub
parent 0ea1ac609c
commit a93f73c032
19 changed files with 409 additions and 12 deletions
+14 -2
View File
@@ -18,6 +18,11 @@ import { shouldSendInteractiveOutputNow } from './interactive'
import { requestDeliveryResyncForGatedPty } from './accounting'
import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot'
import { clearFlushTimerIfIdle } from './flush'
import {
advancePtyEscapeBoundary,
markPtyRendererStreamGap,
takePtyGapEscapeState
} from './pty-gap-escape-state'
import type { PtyIpcSession } from '../session'
export function rendererPtyIsKnownHidden(id: string): boolean {
@@ -54,6 +59,7 @@ export function acceptPtyDataForRenderer(
if (session.rendererExitingPtyIds.has(payload.id)) {
return
}
const escapeStateBefore = advancePtyEscapeBoundary(payload.id, payload.data)
if (shouldDropHiddenRendererPtyData(payload.id, session.getSettings?.())) {
const droppedChars = payload.data.length
const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars)
@@ -72,6 +78,7 @@ export function acceptPtyDataForRenderer(
if (payload.data.length === 0 && !payload.transformed) {
return
}
const gapEscapeState = takePtyGapEscapeState(payload.id, escapeStateBefore)
const containsBackgroundOutput = rendererPtyIsKnownHidden(payload.id)
const overflowMarkedBeforeAppend = session.pendingOverflowMarkedPtys.has(payload.id)
const pending = appendPendingPtyData(
@@ -83,8 +90,12 @@ export function acceptPtyDataForRenderer(
preservesSeq,
containsBackgroundOutput,
rawLength,
payload.transformed === true
payload.transformed === true,
gapEscapeState
)
if (pending.droppedOutput === true) {
markPtyRendererStreamGap(payload.id)
}
const shouldEmitPendingCapRestoreMarker =
pending.droppedOutput === true &&
!overflowMarkedBeforeAppend &&
@@ -127,7 +138,8 @@ export function acceptPtyDataForRenderer(
: {}),
...(pending.transformed ? { transformed: true } : {}),
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
...(pending.droppedOutput === true ? { droppedOutput: true } : {}),
...(pending.gapEscapeState ? { gapEscapeState: pending.gapEscapeState } : {})
})
} finally {
session.updateProducerFlowControl(payload.id)
+2 -1
View File
@@ -101,7 +101,8 @@ function preparePtyExitForRenderer(
remaining.startSeq,
remaining.containsBackgroundOutput,
remaining.rawLength,
remaining.transformed
remaining.transformed,
remaining.gapEscapeState
)
)
}
+2 -1
View File
@@ -177,7 +177,8 @@ export function flushPendingData(session: PtyIpcSession): void {
pending.startSeq,
pending.containsBackgroundOutput,
pending.rawLength,
pending.transformed
pending.transformed,
pending.gapEscapeState
)
)
if (!sent) {
+5 -1
View File
@@ -10,9 +10,13 @@ export function makePtyDataPayload(
startSeq: number | undefined,
containsBackgroundOutput: boolean | undefined,
rawLength = data.length,
transformed = false
transformed = false,
gapEscapeState?: string
): PtyDataPayload {
const payload: PtyDataPayload = { id, data }
if (gapEscapeState) {
payload.gapEscapeState = gapEscapeState
}
if (typeof startSeq === 'number') {
payload.seq = startSeq + rawLength
}
+7 -3
View File
@@ -107,7 +107,8 @@ export function appendPendingPtyData(
preservesSeq: boolean,
containsBackgroundOutput: boolean,
rawLength = data.length,
transformed = false
transformed = false,
gapEscapeState?: string
): PendingPtyData {
// Once dropped, keep bounded query/mode state while the restore sentinel supersedes ordinary output.
if (existing?.droppedOutput === true) {
@@ -140,7 +141,8 @@ export function appendPendingPtyData(
...(typeof startSeq === 'number' ? { startSeq } : {}),
...(rawLength !== data.length ? { rawLength } : {}),
...(transformed ? { transformed: true } : {}),
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}),
...(gapEscapeState ? { gapEscapeState } : {})
}
return dropOversizedPendingPtyData(session, id, pending)
}
@@ -150,7 +152,9 @@ export function appendPendingPtyData(
...(!preservesSeq || existing.transformed || transformed
? { rawLength: existingRawLength + rawLength, transformed: true as const }
: {}),
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}),
// Describes the batch start; a gap inside an existing batch has no start to label.
...(existing.gapEscapeState ? { gapEscapeState: existing.gapEscapeState } : {})
}
if (typeof existing.startSeq === 'number') {
next.startSeq = existing.startSeq
@@ -0,0 +1,40 @@
/** Label the first delivery after a renderer-stream gap with the escape it resumes inside, so an abandoned restore can skip that tail (#33766). */
import { advanceTerminalEscapeBoundary } from '../../../../shared/terminal-partial-escape-tail'
// Escape-parser state at the end of each PTY's accepted output, delivered or dropped.
const escapeBoundaryByPty = new Map<string, string>()
const gapPendingPtys = new Set<string>()
/** Advance over an accepted payload; returns the state in effect before it. */
export function advancePtyEscapeBoundary(id: string, data: string): string {
const before = escapeBoundaryByPty.get(id) ?? ''
const after = advanceTerminalEscapeBoundary(before, data)
if (after) {
escapeBoundaryByPty.set(id, after)
} else {
escapeBoundaryByPty.delete(id)
}
return before
}
export function markPtyRendererStreamGap(id: string): void {
gapPendingPtys.add(id)
}
/** The open escape a delivery resumes inside, once per gap; undefined when the stream resumes clean. */
export function takePtyGapEscapeState(id: string, stateBefore: string): string | undefined {
if (!gapPendingPtys.delete(id)) {
return undefined
}
return stateBefore || undefined
}
export function clearPtyGapEscapeState(id: string): void {
escapeBoundaryByPty.delete(id)
gapPendingPtys.delete(id)
}
export function _resetPtyGapEscapeStateForTest(): void {
escapeBoundaryByPty.clear()
gapPendingPtys.clear()
}
@@ -1,5 +1,10 @@
/** Drop hidden renderer delivery after model ingestion; reveal restores a snapshot, while sidecar interest keeps raw bytes flowing. */
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
_resetPtyGapEscapeStateForTest,
clearPtyGapEscapeState,
markPtyRendererStreamGap
} from './pty-gap-escape-state'
export type HiddenPtyDeliveryGateSettings = Pick<
GlobalSettings,
@@ -104,6 +109,7 @@ export function recordHiddenRendererPtyDataDrop(
): { shouldEmitRestoreMarker: boolean; hiddenDropEpisode: number } {
droppedHiddenDeliveryChars += chars
droppedHiddenDeliveryChunks += 1
markPtyRendererStreamGap(id)
const latch = droppedSinceHiddenPtys.get(id)
if (latch?.state === 'unrevealed') {
return { shouldEmitRestoreMarker: false, hiddenDropEpisode: latch.episode }
@@ -131,6 +137,7 @@ export function clearHiddenRendererPtyDeliveryState(id: string): void {
runtimeOwnedHiddenRendererPtys.delete(id)
deliveryInterestRendererPtys.delete(id)
droppedSinceHiddenPtys.delete(id)
clearPtyGapEscapeState(id)
}
export type HiddenRendererPtyDeliveryDebug = {
@@ -159,5 +166,6 @@ export function _resetHiddenRendererPtyDeliveryGateForTest(): void {
runtimeOwnedHiddenRendererPtys.clear()
deliveryInterestRendererPtys.clear()
droppedSinceHiddenPtys.clear()
_resetPtyGapEscapeStateForTest()
resetHiddenRendererPtyDeliveryDebugCounters()
}
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from '../../pty-ipc-test-harness'
import { registerPtyHandlers } from '../register-handlers'
vi.mock('electron', () => import('../../pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('../../pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('../../pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('../../pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../../../opencode/hook-service', () =>
import('../../pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../../../mimo/hook-service', () =>
import('../../pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../../../agent-hooks/server', () =>
import('../../pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../../../pi/titlebar-extension-service', () =>
import('../../pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../../../pwsh', () =>
import('../../pty-ipc-mock-registry').then((m) => m.pwshModuleMock())
)
vi.mock('../../../wsl', async (importOriginal) =>
(await import('../../pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('../../../telemetry/client', () =>
import('../../pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../../../telemetry/classify-error', () =>
import('../../pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../../../cli/linux-terminal-orca-cli-shim', () =>
import('../../pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../../../memory/pty-registry', () =>
import('../../pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../../../agent-hooks/migration-unsupported-pty-state', () =>
import('../../pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../../../codex/codex-pane-account-registry', () =>
import('../../pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../../../codex/codex-state-db-backfill-recovery', () =>
import('../../pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)
// The observable daemon provider adopts the spawn's sessionId as the PTY id.
const PTY_ID = 'daemon-session'
describe('the first delivery after a hidden drop (#33766)', () => {
const {
handlers,
mainWindow,
installObservableDaemonTestProvider,
getPtySetHiddenRendererPtyListener
} = setupPtyIpcSuite()
async function hideDropAndResume(dropped: string, resumed: string) {
vi.useFakeTimers()
let seq = 0
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn((_id: string, _data: string, _at: number, chars: number) => (seq += chars)),
getPtyOutputSequence: vi.fn(() => seq),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPreAllocatedHandleForPty: vi.fn()
}
const daemon = installObservableDaemonTestProvider()
try {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window and runtime stubs cover every member registerPtyHandlers reads.
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: PTY_ID })
const id = PTY_ID
const setHidden = getPtySetHiddenRendererPtyListener()
setHidden(null, { id, hidden: true })
daemon.emitData(id, dropped)
vi.advanceTimersByTime(50)
setHidden(null, { id, hidden: false })
mainWindow.webContents.send.mockClear()
daemon.emitData(id, resumed)
vi.advanceTimersByTime(50)
const delivered = mainWindow.webContents.send.mock.calls
.filter(([channel]) => channel === 'pty:data')
.map(([, payload]): Record<string, unknown> => payload)
return { delivered, seq }
} finally {
vi.useRealTimers()
}
}
it.each([
[
'an OSC title',
'done\x1b]0;Sequent',
'ial refactoring PRs | cleanup-dumb-exports\x07> prompt',
'\x1b]'
],
['an SGR sequence', 'diff \x1b[38;5', ';242m+ line', '\x1b['],
['a CSI whose ESC alone was dropped', 'diff \x1b', '[38;5;242m+ line', '\x1b']
])(
'labels the first delivery after a drop that split %s, without trimming it',
async (_name, dropped, resumed, gapEscapeState) => {
const { delivered, seq } = await hideDropAndResume(dropped, resumed)
// A restore whose snapshot carries the open escape still needs every continuation byte.
expect(delivered).toEqual([
{ id: expect.any(String), data: resumed, seq, rawLength: resumed.length, gapEscapeState }
])
}
)
it('leaves a delivery after a clean drop boundary unlabeled, including a bell after text', async () => {
const { delivered } = await hideDropAndResume('hidden\x1b[0m', 'build done\x07')
expect(delivered.map((payload) => payload.data).join('')).toBe('build done\x07')
expect(delivered.some((payload) => 'gapEscapeState' in payload)).toBe(false)
})
})
@@ -7,6 +7,8 @@ export type PendingPtyData = {
rawLength?: number
transformed?: true
containsBackgroundOutput?: boolean
/** Escape the stream was inside when `data` begins, set only right after a delivery gap. */
gapEscapeState?: string
droppedOutput?: true
droppedMode2031Data?: string
droppedMode2031ScanState?: Mode2031ReplyScanState
+2
View File
@@ -28,6 +28,8 @@ export type PtyDataPayload = {
transformed?: boolean
background?: boolean
droppedOutput?: boolean
/** Escape left open by bytes dropped just before this chunk; lets an abandoned restore skip its tail. */
gapEscapeState?: string
}
export type RendererPtyDeliveryAccounting = {
+1
View File
@@ -251,6 +251,7 @@ export type PtyApi = {
transformed?: boolean
background?: boolean
droppedOutput?: boolean
gapEscapeState?: string
}) => void
) => () => void
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
@@ -49,6 +49,7 @@ export const ptyStreamAndSerializationApi = {
transformed?: boolean
background?: boolean
droppedOutput?: boolean
gapEscapeState?: string
}) => void
): (() => void) => {
const listener = (
@@ -61,6 +62,7 @@ export const ptyStreamAndSerializationApi = {
transformed?: boolean
background?: boolean
droppedOutput?: boolean
gapEscapeState?: string
}
) => callback(data)
ipcRenderer.on('pty:data', listener)
@@ -1,7 +1,7 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles'
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
import { flushAsyncTicks, createDeferred, renderHeadlessBuffer } from './pty-connection-test-async'
import {
createMockTransport,
createPane,
@@ -11,6 +11,7 @@ import {
type MockTransport
} from './pty-connection-test-pane-fixtures'
import { buildPaneConnectionDeps, type PaneConnectionDeps } from './pty-connection-test-deps'
import type { PtyDataMeta } from './pty-dispatcher'
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
import type { StoreState } from './pty-connection-test-store-state'
import {
@@ -153,6 +154,7 @@ type PaneDrive = {
deps: PaneConnectionDeps
disposable: { dispose: () => void }
deliver: (data: string, seq: number) => void
deliverWithMeta?: (data: string, meta: PtyDataMeta) => void
setVisible: (visible: boolean) => void
setOutputPaused: (paused: boolean, dropsReported?: boolean) => void
reportPausedOutputDropped: () => void
@@ -528,7 +530,7 @@ describe('hidden-output restore loss disclosure (STA-4869)', () => {
// Gate local hidden output to mirror the host pause exercised by remote routes.
const transport = createMockTransport(LOCAL_PTY_ID)
const captured: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
current: ((data: string, meta?: PtyDataMeta) => void) | null
} = { current: null }
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks?: ConnectCallbacks }) => {
@@ -551,6 +553,7 @@ describe('hidden-output restore loss disclosure (STA-4869)', () => {
deps,
disposable,
deliver: (data, seq) => captured.current?.(data, { seq, rawLength: data.length }),
deliverWithMeta: (data, meta) => captured.current?.(data, meta),
setVisible: (visible) => {
deps.isVisibleRef.current = visible
},
@@ -625,6 +628,86 @@ describe('hidden-output restore loss disclosure (STA-4869)', () => {
expectDisclosedOrRecovered(disclosure)
drive.disposable.dispose()
})
// #33766: main labels the first chunk after its hidden drop with the escape the drop left open.
async function abandonAfterResumedChunk(resumed: string, gapEscapeState?: string) {
vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue(null)
const drive = await connectLocalHiddenPane()
vi.useFakeTimers()
drive.deliver(HIDDEN_FLOOD, HIDDEN_FLOOD.length)
drive.setVisible(true)
const seq = HIDDEN_FLOOD.length + resumed.length
drive.deliverWithMeta?.(resumed, {
seq,
rawLength: resumed.length,
...(gapEscapeState ? { gapEscapeState } : {})
})
await flushAsyncTicks(20)
for (let attempt = 0; attempt < 3; attempt += 1) {
await vi.advanceTimersByTimeAsync(50)
await flushAsyncTicks(20)
}
drive.disposable.dispose()
vi.useRealTimers()
return (await renderHeadlessBuffer(drive.writtenChunks(), 120, 8)).join('\n')
}
it.each([
[
'an OSC title',
'\x1b]',
'ial refactoring PRs | cleanup-dumb-exports\x07> prompt',
'ial refactoring'
],
['an SGR sequence', '\x1b[', ';5;242m> prompt', ';242m']
])(
'skips the tail of %s main says the gap left open',
async (_name, gapEscapeState, resumed, tail) => {
const screen = await abandonAfterResumedChunk(resumed, gapEscapeState)
expect({
banner: screen.includes(BANNER_FRAGMENT),
prompt: screen.includes('> prompt'),
tail: screen.includes(tail)
}).toEqual({ banner: true, prompt: true, tail: false })
}
)
it('keeps a labeled continuation when the snapshot at the gap carries the open escape', async () => {
// The snapshot predates the resumed chunk, so its pending escape needs every continuation byte.
vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({
data: 'history\r\n',
cols: 120,
rows: 40,
seq: HIDDEN_FLOOD.length,
pendingEscapeTailAnsi: '\x1b[38;5'
})
const drive = await connectLocalHiddenPane()
vi.useFakeTimers()
drive.deliver(HIDDEN_FLOOD, HIDDEN_FLOOD.length)
drive.setVisible(true)
const resumed = ';242m+ line'
drive.deliverWithMeta?.(resumed, {
seq: HIDDEN_FLOOD.length + resumed.length,
rawLength: resumed.length,
gapEscapeState: '\x1b['
})
await allowRecoveryWindow()
drive.disposable.dispose()
vi.useRealTimers()
const written = drive.writtenChunks().join('')
const screen = (await renderHeadlessBuffer(drive.writtenChunks(), 120, 8)).join('\n')
expect({
continuationWritten: written.includes('\x1b[38;5') && written.includes(resumed),
line: screen.includes('+ line'),
tail: screen.includes(';242m'),
banner: screen.includes(BANNER_FRAGMENT)
}).toEqual({ continuationWritten: true, line: true, tail: false, banner: false })
})
it('keeps unlabeled output that ends in a bell', async () => {
const screen = await abandonAfterResumedChunk('build done\x07')
expect(screen.includes('build done')).toBe(true)
})
})
// The PTY-swap arm requires a prior successful snapshot, which resets its abandon budget; a deferred repaint must keep any subsequent gap recoverable.
@@ -1,4 +1,5 @@
import { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler'
import { skipOpenEscapeContinuation } from '../../../../../shared/terminal-partial-escape-tail'
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
@@ -76,11 +77,23 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession):
return
}
const replayedSeq = typeof replayingSnapshot?.seq === 'number' ? replayingSnapshot.seq : null
// The reset above aborted any open escape, so a chunk main labels as resuming inside one must skip its tail (#33766).
const followsByteGap = !opts.quiet || !opts.preserveReplayState
let openEscape = ''
let pendingData = ''
for (const chunk of pendingChunks) {
const sliced =
replayedSeq === null ? chunk.data : session.getChunkDataAfterSnapshot(chunk, replayedSeq)
pendingData += sliced ?? chunk.data
let data = sliced ?? chunk.data
if (followsByteGap && chunk.gapEscapeState && data === chunk.data) {
openEscape = chunk.gapEscapeState
}
if (openEscape) {
const skipped = skipOpenEscapeContinuation(openEscape, data)
data = skipped.rest
openEscape = skipped.stillOpen
}
pendingData += data
}
if (replayingSnapshot && replayedSeq !== null) {
session.setRestoredSnapshotBaseline(
@@ -47,6 +47,9 @@ export function bindHiddenOutputRestoreChunk(session: ConnectPanePtySession): vo
if (typeof meta?.rawLength === 'number') {
pending.rawLength = meta.rawLength
}
if (meta?.gapEscapeState) {
pending.gapEscapeState = meta.gapEscapeState
}
session.hiddenOutputRestorePendingChunks.push(pending)
session.hiddenOutputRestorePendingChars += data.length
session.armHiddenOutputRestoreForegroundDeadline()
@@ -20,6 +20,7 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
if (streamGeneration !== session.transportStreamGeneration) {
return
}
const arrivedData = data
if (session.deferredReattachLiveData !== null) {
const ackCredit = takeCurrentTerminalDeliveryCredit()
session.deferredReattachLiveData.enqueue({
@@ -156,7 +157,11 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
if (pendingForegroundQuery?.statefulQueryData) {
session.queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData)
}
session.queueLiveChunkDuringRestore(orderedRendererData, rendererMeta)
session.queueLiveChunkDuringRestore(
orderedRendererData,
// The gap label describes the chunk's original first byte only.
orderedRendererData === arrivedData ? rendererMeta : withoutGapEscapeState(rendererMeta)
)
session.requestHiddenOutputRestoreIfNeeded()
} else if (session.hiddenOutputRestoreInFlight) {
session.hiddenOutputRestoreNeeded = true
@@ -187,3 +192,12 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
}
)
}
function withoutGapEscapeState(meta: PtyDataMeta | undefined): PtyDataMeta | undefined {
if (!meta?.gapEscapeState) {
return meta
}
const next = { ...meta }
delete next.gapEscapeState
return next
}
@@ -2,4 +2,5 @@ export type PendingHiddenOutputRestoreChunk = {
data: string
seq?: number
rawLength?: number
gapEscapeState?: string
}
@@ -54,6 +54,8 @@ export type PtyDataMeta = {
background?: boolean
/** Main dropped this PTY's buffered output at the pending cap; repaint from the main-owned snapshot, not the live stream. */
droppedOutput?: boolean
/** Escape main's dropped bytes left open where this chunk begins; only an abandoned restore uses it. */
gapEscapeState?: string
}
/** Sidecar PTY-data observers, invoked AFTER the primary handler so a side-effect-only watcher can't delay xterm rendering. */
@@ -116,6 +118,7 @@ function handleDispatchedPtyData(payload: {
transformed?: boolean
background?: boolean
droppedOutput?: boolean
gapEscapeState?: string
}): void {
let meta: PtyDataMeta | undefined
if (typeof payload.seq === 'number') {
@@ -138,6 +141,10 @@ function handleDispatchedPtyData(payload: {
meta ??= {}
meta.droppedOutput = true
}
if (typeof payload.gapEscapeState === 'string' && payload.gapEscapeState.length > 0) {
meta ??= {}
meta.gapEscapeState = payload.gapEscapeState
}
const chars = payload.rawLength ?? payload.data.length
const dispatch = (): void => {
if (isPtyDataHandlerShutdownPending(payload.id)) {
@@ -157,6 +157,81 @@ function scanPartialEscapeTail(stream: string, stateOnly: boolean): string {
return stateOnly ? STATE_PREFIX[state] : state === 'ground' ? '' : stream.slice(start)
}
const STATE_BY_PREFIX: Record<string, ScanState> = {
'\x1b': 'esc',
'\x1b ': 'escIntermediate',
'\x1b[': 'csi',
'\x1b]': 'osc',
'\x1bP': 'string'
}
/** Chars of chunk that finish the escape left open by advanceTerminalEscapeBoundary's statePrefix; a new ESC ends it unconsumed. -1 when it stays open. */
export function openEscapeContinuationLength(statePrefix: string, chunk: string): number {
let state: ScanState = STATE_BY_PREFIX[statePrefix] ?? 'ground'
for (let i = 0; i < chunk.length; i++) {
if (state === 'ground') {
return i
}
const code = chunk.charCodeAt(i)
if (code === CAN || code === SUB) {
return i + 1
}
switch (state) {
case 'esc':
if (code === ESC) {
return i
}
state = stateAfterEscByte(code)
if (state === 'ground') {
return i + 1
}
break
case 'escIntermediate':
if (code === ESC) {
return i
}
if (code >= 0x30 && code <= 0x7e) {
return i + 1
}
break
case 'csi':
if (code === ESC) {
return i
}
if (code >= 0x40 && code <= 0x7e) {
return i + 1
}
break
case 'osc':
case 'string':
if (code === BEL && state === 'osc') {
return i + 1
}
// Keep an ESC that may start a new sequence; if it was a split ST, its backslash later completes a no-op ST.
if (code === ESC) {
return chunk.charCodeAt(i + 1) === 0x5c ? i + 2 : i
}
break
// Unreachable from a statePrefix (both map to 'esc'); an ESC inside a string either finishes ST or starts a kept sequence.
case 'oscEsc':
case 'stringEsc':
return code === 0x5c ? i + 1 : i
}
}
return state === 'ground' ? chunk.length : -1
}
/** Drop the part of chunk that continues the escape open in statePrefix; stillOpen carries it when the chunk ends first. */
export function skipOpenEscapeContinuation(
statePrefix: string,
chunk: string
): { rest: string; stillOpen: string } {
const length = openEscapeContinuationLength(statePrefix, chunk)
return length === -1
? { rest: '', stillOpen: advanceTerminalEscapeBoundary(statePrefix, chunk) }
: { rest: chunk.slice(length), stillOpen: '' }
}
/** Advance the incremental tail; abandon it past the cap. */
export function advancePartialEscapeTail(pendingTail: string, chunk: string): string {
// Only ESC leaves ground, so no tail + no ESC avoids concatenation and the full chunk walk.