From 252e5f6af5ede30bc25318a74f32e3f15eae4792 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:57:14 -0700 Subject: [PATCH] Avoid clearing terminal scrollback for metadata-only eager replays (#7012) Previously, the terminal pane would always perform a clear screen on PTY attach before replaying the eager buffer. This cleared restored scrollback unconditionally, even when there was no active terminal content to replay (e.g., when the buffer was empty or only contained window titles and agent status metadata). Now, eager buffers are parsed to determine if they contain display-affecting content. The attach-time clear is skipped for empty or metadata-only replays, preserving the restored scrollback history. Trailing partial control sequences are also trimmed to prevent swallowing live terminal output. --- .../terminal-pane/pty-connection.test.ts | 65 ++++++- .../terminal-pane/pty-connection.ts | 34 ++-- .../terminal-pane/pty-transport-types.ts | 2 +- .../terminal-pane/pty-transport.test.ts | 159 +++++++++++++++-- .../components/terminal-pane/pty-transport.ts | 60 +++---- .../terminal-output-visibility.test.ts | 54 ++++++ .../terminal-output-visibility.ts | 166 ++++++++++++++++++ 7 files changed, 487 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-output-visibility.ts 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 +}