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 477a60aa44d..426cbf15656 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -141,7 +141,7 @@ type StoreState = { type ConnectCallbacks = { onData?: (data: string, meta?: { seq?: number; rawLength?: number }) => void - onReplayData?: (data: string) => void + onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void } @@ -7981,6 +7981,69 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('does not clear restored scrollback when eager metadata replay opts out', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps() + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.('\x1b]0;Restored title\x07', { clearBeforeReplay: false }) + await flushAsyncTicks(6) + + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b[2J\x1b[3J\x1b[H', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b]0;Restored title\x07', + expect.any(Function) + ) + disposable.dispose() + }) + + it('does not write a clear or reset for empty eager metadata replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps() + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.('', { clearBeforeReplay: false }) + await flushAsyncTicks(6) + + expect(pane.terminal.write).not.toHaveBeenCalled() + disposable.dispose() + }) + it('coalesces remote replay payloads that overlap before parsing starts', async () => { const { connectPanePty } = await import('./pty-connection') enableActiveRuntimeEnvironment() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 27d52275cbe..00db0461cdf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3263,18 +3263,27 @@ export function connectPanePty( } let replayWriteQueue = Promise.resolve() - let pendingReplayData: string | null = null + type PendingReplayData = { + data: string + clearBeforeReplay: boolean + } + + let pendingReplayData: PendingReplayData | null = null let replayDrainQueued = false const drainReplayDataQueue = async (): Promise => { while (pendingReplayData !== null) { - const data = pendingReplayData + const { data, clearBeforeReplay } = pendingReplayData pendingReplayData = null - // Relay replay buffer holds the last 100 KB of output, which may - // overlap with content already rendered in xterm before the - // disconnect. Clear first to prevent duplication on SSH reconnect. - await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') + // Relay replay buffers may overlap with content already rendered in + // xterm. Local eager replay decides this earlier so metadata-only frames + // can keep restored scrollback while still using the replay guard. + if (clearBeforeReplay) { + await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') + } await writeReplayDataAsync(data) - await writeReplayDataAsync(POST_REPLAY_REATTACH_RESET) + if (clearBeforeReplay || data.length > 0) { + await writeReplayDataAsync(POST_REPLAY_REATTACH_RESET) + } if (disposed) { pendingReplayData = null return @@ -3285,8 +3294,11 @@ export function connectPanePty( manager.rebuildPaneWebgl(pane.id) } } - const replayDataCallback = (data: string): void => { - pendingReplayData = data + const replayDataCallback = (data: string, meta: { clearBeforeReplay?: boolean } = {}): void => { + pendingReplayData = { + data, + clearBeforeReplay: meta.clearBeforeReplay !== false + } if (replayDrainQueued) { return } @@ -3297,7 +3309,9 @@ export function connectPanePty( .finally(() => { replayDrainQueued = false if (pendingReplayData !== null) { - replayDataCallback(pendingReplayData) + replayDataCallback(pendingReplayData.data, { + clearBeforeReplay: pendingReplayData.clearBeforeReplay + }) } }) } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 6d51e15ecc6..f1e3b1b67b8 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -38,7 +38,7 @@ type PtyCallbacks = { onConnect?: () => void onDisconnect?: () => void onData?: (data: string, meta?: PtyDataMeta) => void - onReplayData?: (data: string) => void + onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onStatus?: (shell: string) => void onError?: (message: string, errors?: string[]) => void onExit?: (code: number) => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index c61776a125c..5e9f845b6d0 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -715,7 +715,7 @@ describe('createIpcPtyTransport', () => { expect(onDataCallback).not.toHaveBeenCalledWith(bufferedPayload) }) - it('clears before replaying eager-buffered output so hidden automation terminals do not open blank', async () => { + it('replays display-bearing eager-buffered output with default clear semantics', async () => { const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') const bufferedPayload = '\x1b[?1049hAutomation agent is running' @@ -735,11 +735,121 @@ describe('createIpcPtyTransport', () => { } }) - const clear = '\x1b[2J\x1b[3J\x1b[H' - expect(onReplayData.mock.calls.map(([data]) => data)).toEqual([clear, bufferedPayload]) + expect(onReplayData.mock.calls).toEqual([[bufferedPayload]]) }) - it('routes the attach-time clear sequence through onReplayData for non-alternate-screen sessions', async () => { + it('does not clear before replaying title-only eager-buffered output', async () => { + const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') + + const bufferedPayload = '\x1b]0;Restored title\x07' + registerEagerPtyBuffer('pty-title-only', vi.fn()) + onData?.({ + id: 'pty-title-only', + data: bufferedPayload + }) + + const onTitleChange = vi.fn() + const transport = createIpcPtyTransport({ onTitleChange }) + const onReplayData = vi.fn() + + transport.attach({ + existingPtyId: 'pty-title-only', + callbacks: { + onReplayData + } + }) + + // Why: title/control frames restore metadata but do not redraw a terminal + // frame; clearing before them would erase the persisted scrollback. + const clear = '\x1b[2J\x1b[3J\x1b[H' + expect(onReplayData.mock.calls).toEqual([[bufferedPayload, { clearBeforeReplay: false }]]) + expect(onReplayData).not.toHaveBeenCalledWith(clear) + expect(onTitleChange).toHaveBeenCalledWith('Restored title', 'Restored title') + }) + + it('does not write an unterminated title-only eager buffer into replay', async () => { + const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') + + const bufferedPayload = '\x1b]0;partial restored title' + registerEagerPtyBuffer('pty-partial-title', vi.fn()) + onData?.({ + id: 'pty-partial-title', + data: bufferedPayload + }) + + const transport = createIpcPtyTransport() + const onReplayData = vi.fn() + + transport.attach({ + existingPtyId: 'pty-partial-title', + callbacks: { + onReplayData + } + }) + + const clear = '\x1b[2J\x1b[3J\x1b[H' + expect(onReplayData.mock.calls).toEqual([['', { clearBeforeReplay: false }]]) + expect(onReplayData).not.toHaveBeenCalledWith(clear) + }) + + it('does not let an unterminated OSC 9999 eager buffer swallow live output', async () => { + const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') + + registerEagerPtyBuffer('pty-partial-status', vi.fn()) + onData?.({ + id: 'pty-partial-status', + data: '\x1b]9999;{"state":"working"' + }) + + const transport = createIpcPtyTransport({ onAgentStatus: vi.fn() }) + const onReplayData = vi.fn() + const onDataCallback = vi.fn() + + transport.attach({ + existingPtyId: 'pty-partial-status', + callbacks: { + onData: onDataCallback, + onReplayData + } + }) + + expect(onReplayData.mock.calls).toEqual([['', { clearBeforeReplay: false }]]) + + onData?.({ + id: 'pty-partial-status', + data: 'live output' + }) + + expect(onDataCallback).toHaveBeenCalledWith('live output') + }) + + it('does not clear before replaying OSC 9999-only eager-buffered output', async () => { + const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') + + registerEagerPtyBuffer('pty-status-only', vi.fn()) + onData?.({ + id: 'pty-status-only', + data: '\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07' + }) + + const transport = createIpcPtyTransport() + const onReplayData = vi.fn() + + transport.attach({ + existingPtyId: 'pty-status-only', + callbacks: { + onReplayData + } + }) + + // Why: OSC 9999 is stripped before xterm receives replay data. A non-empty + // raw status frame must not clear restored scrollback and replay nothing. + const clear = '\x1b[2J\x1b[3J\x1b[H' + expect(onReplayData.mock.calls).toEqual([['', { clearBeforeReplay: false }]]) + expect(onReplayData).not.toHaveBeenCalledWith(clear) + }) + + it('does not clear on attach when there is no eager-buffered output', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport() @@ -754,15 +864,43 @@ describe('createIpcPtyTransport', () => { } }) - // Why: the clear preamble must travel the replay path so any subsequent - // snapshot bytes sit under the same replay guard in pty-connection.ts. - const clear = '\x1b[2J\x1b[3J\x1b[H' - expect(onReplayData).toHaveBeenCalledWith(clear) - expect(onDataCallback).not.toHaveBeenCalledWith(clear) + // Why: restored scrollback may already be in xterm before attach. An + // empty eager buffer must not erase it and leave the pane cursor-only. + expect(onReplayData).not.toHaveBeenCalled() + expect(onDataCallback).not.toHaveBeenCalled() + }) + + it('does not clear on attach when the eager buffer is empty', async () => { + const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') + + registerEagerPtyBuffer('pty-attached', vi.fn()) + const transport = createIpcPtyTransport() + const onDataCallback = vi.fn() + const onReplayData = vi.fn() + + transport.attach({ + existingPtyId: 'pty-attached', + callbacks: { + onData: onDataCallback, + onReplayData + } + }) + + // Why: a live PTY can have an eager handle before any bytes arrive. Clearing + // here would destroy the scrollback restored by TerminalPane mount. + expect(onReplayData).not.toHaveBeenCalled() + expect(onDataCallback).not.toHaveBeenCalled() }) it('skips the attach-time clear sequence for alternate-screen sessions', async () => { - const { createIpcPtyTransport } = await import('./pty-transport') + const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') + + const bufferedPayload = '\x1b[?1049hAlternate screen is already restored' + registerEagerPtyBuffer('pty-alt-screen', vi.fn()) + onData?.({ + id: 'pty-alt-screen', + data: bufferedPayload + }) const transport = createIpcPtyTransport() const onDataCallback = vi.fn() @@ -780,6 +918,7 @@ describe('createIpcPtyTransport', () => { // Why: alternate-screen snapshots already fill the viewport; emitting the // clear would erase the restored content. Neither path should see it. const clear = '\x1b[2J\x1b[3J\x1b[H' + expect(onReplayData.mock.calls).toEqual([[bufferedPayload, { clearBeforeReplay: false }]]) expect(onReplayData).not.toHaveBeenCalledWith(clear) expect(onDataCallback).not.toHaveBeenCalledWith(clear) }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 4ab7f8471a8..474ee267832 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -26,6 +26,10 @@ import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handle import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createBellDetector } from './bell-detector' +import { + hasTerminalDisplayContent, + trimIncompleteTerminalControlTail +} from './terminal-output-visibility' import { createAgentStatusOscProcessor, type ProcessedAgentStatusChunk @@ -81,6 +85,7 @@ type PtyOutputProcessorOptions = Pick< type ProcessPtyOutputOptions = { replayingBufferedData?: boolean suppressAttentionEvents?: boolean + clearBeforeReplay?: boolean } type PendingPtySideEffect = { @@ -398,7 +403,11 @@ export function createPtyOutputProcessor({ // session into the live store. The parser still consumes the bytes so they // do not leak into xterm, we just suppress the callback. if (options.replayingBufferedData && callbacks.onReplayData) { - callbacks.onReplayData(data) + if (options.clearBeforeReplay === false) { + callbacks.onReplayData(data, { clearBeforeReplay: false }) + } else { + callbacks.onReplayData(data) + } } else { if (meta) { callbacks.onData?.(data, { ...meta, rawLength }) @@ -491,12 +500,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ptyReplayHandlers.delete(id) } - // Why: true while we're replaying buffered/attach-time bytes into the - // terminal. Routes those bytes through onReplayData so the renderer can - // engage the replay guard — otherwise xterm auto-replies to embedded - // query sequences leak into the shell as stray input. - let replayingBufferedData = false - // Why: shared by connect() and attach() to avoid duplicating title/bell/exit // logic across the two code paths that register a PTY. function registerPtyDataHandler(id: string): void { @@ -521,7 +524,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra data, storedCallbacks, { - replayingBufferedData, suppressAttentionEvents }, meta @@ -818,26 +820,21 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return } - // Why: hidden automation PTYs may have already rendered their TUI into - // the eager buffer. Clear stale pane contents before replaying that - // buffer; clearing afterward erases the only visible frame and opens a - // blank terminal until the TUI happens to repaint. - if (!options.isAlternateScreen) { - const clear = '\x1b[2J\x1b[3J\x1b[H' - if (storedCallbacks.onReplayData) { - storedCallbacks.onReplayData(clear) - } else { - storedCallbacks.onData?.(clear) - } - } - - // Why: replay buffered data through the real handler so title/bell/agent - // tracking (including OSC 9999 agent status) processes the output — - // otherwise restored tabs keep a default title. const bufferHandle = getEagerPtyBufferHandle(id) if (bufferHandle) { const buffered = bufferHandle.flush() if (buffered) { + const replayData = trimIncompleteTerminalControlTail(buffered) + const shouldClearBeforeReplay = + !options.isAlternateScreen && hasTerminalDisplayContent(replayData) + // Why: hidden automation PTYs may have already rendered their TUI into + // the eager buffer. Clear stale pane contents before replaying + // terminal-visible bytes, but keep scrollback for control-only frames. + if (shouldClearBeforeReplay && !storedCallbacks.onReplayData) { + const clear = '\x1b[2J\x1b[3J\x1b[H' + storedCallbacks.onData?.(clear) + } + // Why: eager-buffered bytes are raw PTY output captured before the // pane mounted — often from the previous app session. We replay // them so titles/scrollback restore correctly, but must silence @@ -845,20 +842,21 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // or completion captured from the prior session must not produce // a fresh bell on the freshly mounted pane. // - // replayingBufferedData additionally routes the bytes through - // onReplayData so the renderer engages the replay guard — xterm's - // auto-replies to embedded query sequences would otherwise leak - // into the shell's stdin. + // The replay option routes the bytes through onReplayData so the + // renderer engages the replay guard — xterm's auto-replies to + // embedded query sequences would otherwise leak into shell stdin. suppressAttentionEvents = true - replayingBufferedData = true try { - ptyDataHandlers.get(id)?.(buffered) + outputProcessor.processData(replayData, storedCallbacks, { + replayingBufferedData: true, + suppressAttentionEvents: true, + clearBeforeReplay: shouldClearBeforeReplay + }) } finally { // Why: replay side effects are intentionally deferred for live // output, but replay cleanup must observe them before resetting // parser state or a partial OSC can swallow the next live BEL. outputProcessor.flushPendingSideEffects() - replayingBufferedData = false suppressAttentionEvents = false // Why: replaying eager-buffered bytes may have observed a "working" title // without a follow-up title, starting a stale-title timer. That timer would diff --git a/src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts b/src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts new file mode 100644 index 00000000000..e52215cb0f0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { + hasTerminalDisplayContent, + trimIncompleteTerminalControlTail +} from './terminal-output-visibility' + +describe('hasTerminalDisplayContent', () => { + it('treats title and agent-status OSC frames as metadata-only', () => { + expect(hasTerminalDisplayContent('\x1b]0;Restored title\x07')).toBe(false) + expect(hasTerminalDisplayContent('\x1b]9999;{"state":"working","agentType":"codex"}\x07')).toBe( + false + ) + }) + + it('treats unterminated metadata/control frames as metadata-only', () => { + expect(hasTerminalDisplayContent('\x1b]0;partial title')).toBe(false) + expect(hasTerminalDisplayContent('\x1b]9999;{"state":"working"')).toBe(false) + expect(hasTerminalDisplayContent('\x1b[31')).toBe(false) + }) + + it('treats styling-only control sequences as metadata-only', () => { + expect(hasTerminalDisplayContent('\x1b[31m\x1b[0m')).toBe(false) + expect(hasTerminalDisplayContent('\x1b[0 q\x1b[?25h')).toBe(false) + }) + + it('treats printable text and whitespace redraws as display content', () => { + expect(hasTerminalDisplayContent('hello')).toBe(true) + expect(hasTerminalDisplayContent(' ')).toBe(true) + expect(hasTerminalDisplayContent('\r ')).toBe(true) + expect(hasTerminalDisplayContent('\n')).toBe(true) + }) + + it('treats erase and alternate-screen sequences as display-affecting', () => { + expect(hasTerminalDisplayContent('\x1b[2J\x1b[H')).toBe(true) + expect(hasTerminalDisplayContent('\x1b[?1049h')).toBe(true) + expect(hasTerminalDisplayContent('\x1b#8')).toBe(true) + }) +}) + +describe('trimIncompleteTerminalControlTail', () => { + it('drops trailing partial control frames before replay', () => { + expect(trimIncompleteTerminalControlTail('\x1b]0;partial title')).toBe('') + expect(trimIncompleteTerminalControlTail('visible\x1b]0;partial title')).toBe('visible') + expect(trimIncompleteTerminalControlTail('visible\x1b[31')).toBe('visible') + }) + + it('keeps complete metadata and display frames intact', () => { + expect(trimIncompleteTerminalControlTail('\x1b]0;Restored title\x07')).toBe( + '\x1b]0;Restored title\x07' + ) + expect(trimIncompleteTerminalControlTail('\x1b[31mvisible')).toBe('\x1b[31mvisible') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-output-visibility.ts b/src/renderer/src/components/terminal-pane/terminal-output-visibility.ts new file mode 100644 index 00000000000..57c868ee845 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-output-visibility.ts @@ -0,0 +1,166 @@ +// Why: attach-time clear is only safe before bytes that draw a replacement +// frame; metadata/control-only replay must preserve restored scrollback. +export function hasTerminalDisplayContent(chunk: string): boolean { + for (let index = 0; index < chunk.length; index += 1) { + const code = chunk.charCodeAt(index) + const control = parseTerminalControlSequence(chunk, index) + if (control !== undefined) { + if (control === null) { + return false + } + if (control.affectsDisplay) { + return true + } + index = control.end + continue + } + if (isIgnoredControlCode(code)) { + continue + } + if (isDisplayAffectingControlCode(code) || (code >= 0x20 && code !== 0x7f)) { + return true + } + } + + return false +} + +export function trimIncompleteTerminalControlTail(chunk: string): string { + for (let index = 0; index < chunk.length; index += 1) { + const control = parseTerminalControlSequence(chunk, index) + if (control === undefined) { + continue + } + if (control === null) { + return chunk.slice(0, index) + } + index = control.end + } + + return chunk +} + +type ParsedTerminalControlSequence = { + end: number + affectsDisplay: boolean +} + +function parseTerminalControlSequence( + value: string, + index: number +): ParsedTerminalControlSequence | null | undefined { + const code = value.charCodeAt(index) + if (code === 0x1b) { + return parseEscControlSequence(value, index) + } + if (code === 0x9b) { + return parseCsiSequence(value, index + 1) + } + if (code === 0x9d) { + return parseStringControlSequence(value, index + 1, { affectsDisplay: false }) + } + if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) { + return parseStringControlSequence(value, index + 1, { + affectsDisplay: false, + belTerminates: false + }) + } + return undefined +} + +function parseEscControlSequence( + value: string, + escapeIndex: number +): ParsedTerminalControlSequence | null { + const introducer = value[escapeIndex + 1] + if (!introducer) { + return null + } + if (introducer === '[') { + return parseCsiSequence(value, escapeIndex + 2) + } + if (introducer === ']') { + return parseStringControlSequence(value, escapeIndex + 2, { affectsDisplay: false }) + } + if (isStTerminatedStringControlIntroducer(introducer)) { + return parseStringControlSequence(value, escapeIndex + 2, { + affectsDisplay: false, + belTerminates: false + }) + } + if (introducer === '#') { + return value.length > escapeIndex + 2 ? { end: escapeIndex + 2, affectsDisplay: true } : null + } + if (isEscIntermediateIntroducer(introducer)) { + return value.length > escapeIndex + 2 ? { end: escapeIndex + 2, affectsDisplay: false } : null + } + return { end: escapeIndex + 1, affectsDisplay: true } +} + +function parseCsiSequence(value: string, startIndex: number): ParsedTerminalControlSequence | null { + for (let index = startIndex; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code >= 0x40 && code <= 0x7e) { + return { + end: index, + affectsDisplay: csiSequenceAffectsDisplay(value.slice(startIndex, index), value[index]) + } + } + } + return null +} + +function parseStringControlSequence( + value: string, + startIndex: number, + options: { affectsDisplay: boolean; belTerminates?: boolean } +): ParsedTerminalControlSequence | null { + const belTerminates = options.belTerminates !== false + for (let index = startIndex; index < value.length; index += 1) { + if (belTerminates && value[index] === '\u0007') { + return { end: index, affectsDisplay: options.affectsDisplay } + } + if (value[index] === '\u001b' && value[index + 1] === '\\') { + return { end: index + 1, affectsDisplay: options.affectsDisplay } + } + if (value.charCodeAt(index) === 0x9c) { + return { end: index, affectsDisplay: options.affectsDisplay } + } + } + return null +} + +function csiSequenceAffectsDisplay(parametersAndIntermediates: string, final: string): boolean { + if (final === 'm') { + return false + } + if (final === 'q' && parametersAndIntermediates.includes(' ')) { + return false + } + if (final === 'h' || final === 'l') { + return csiModeSequenceAffectsDisplay(parametersAndIntermediates) + } + return true +} + +function csiModeSequenceAffectsDisplay(parametersAndIntermediates: string): boolean { + const modeNumbers = parametersAndIntermediates.match(/\d+/g) ?? [] + return modeNumbers.some((mode) => mode === '47' || mode === '1047' || mode === '1049') +} + +function isIgnoredControlCode(code: number): boolean { + return code === 0x7f || code < 0x08 || (code > 0x0d && code < 0x20) +} + +function isDisplayAffectingControlCode(code: number): boolean { + return (code >= 0x08 && code <= 0x0d) || code === 0x84 || code === 0x85 || code === 0x8d +} + +function isStTerminatedStringControlIntroducer(introducer: string): boolean { + return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_' +} + +function isEscIntermediateIntroducer(introducer: string): boolean { + const code = introducer.charCodeAt(0) + return code >= 0x20 && code <= 0x2f +}