diff --git a/src/renderer/src/components/terminal-pane/hidden-query-carry-osc-introducer-leak.repro.test.ts b/src/renderer/src/components/terminal-pane/hidden-query-carry-osc-introducer-leak.repro.test.ts new file mode 100644 index 00000000000..3ea86a365d5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hidden-query-carry-osc-introducer-leak.repro.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { flushAsyncTicks, writeHeadlessTerminal } from './pty-connection-test-async' +import { + createManager, + createMockTransport, + createPane, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) + +// Why: a hidden carry ending mid-CSI must not swallow the next chunk's `ESC ]` (#31410). +const HIDDEN_STRAGGLER_ENDING_MID_SGR = '\x1b[?2026h\x1b[H\x1b[38;2;177;1' +const CLAUDE_PALETTE_RESET = '\x1b]104;255\x07' +const PROMPT_SNAPSHOT = { data: '\x1b[H❯ ', cols: 120, rows: 40, seq: 1000 } + +describe('hidden query carry vs a following OSC', () => { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('does not paint an OSC payload after the reveal restore when a hidden chunk ended mid-CSI', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const dataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + getMainBufferSnapshot.mockResolvedValue(PROMPT_SNAPSHOT) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const deps = buildPaneConnectionDeps(() => mockStoreState, { + isVisibleRef, + startup: { command: 'claude' } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [pane, createManager(1), deps] as unknown as Parameters + const binding = connectPanePty(...args) + const rendered = new Terminal({ cols: 120, rows: 40, allowProposedApi: true }) + try { + await flushAsyncTicks(6) + + dataCallback.current?.(HIDDEN_STRAGGLER_ENDING_MID_SGR, { + seq: 1000, + rawLength: HIDDEN_STRAGGLER_ENDING_MID_SGR.length + }) + isVisibleRef.current = true + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'unhide', markerSeq: 1000 }) + await flushAsyncTicks(40) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + dataCallback.current?.(CLAUDE_PALETTE_RESET, { + seq: 1000 + CLAUDE_PALETTE_RESET.length, + rawLength: CLAUDE_PALETTE_RESET.length + }) + await flushAsyncTicks(40) + + const writes = pane.terminal.write.mock.calls.map(([data]) => data).join('') + await writeHeadlessTerminal(rendered, writes) + const promptLine = rendered.buffer.active.getLine(0)?.translateToString(true) + expect(promptLine).toBe('❯ ') + } finally { + rendered.dispose() + binding.dispose() + } + }) +}) diff --git a/src/shared/terminal-reply-query-extraction.test.ts b/src/shared/terminal-reply-query-extraction.test.ts new file mode 100644 index 00000000000..1d42e0df997 --- /dev/null +++ b/src/shared/terminal-reply-query-extraction.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + containsStatefulRendererQuery, + extractHiddenStartupRendererQueryData, + findCsiFinalByteIndex +} from './terminal-reply-query-extraction' + +describe('findCsiFinalByteIndex', () => { + it('returns the final byte of a complete CSI', () => { + expect(findCsiFinalByteIndex('\x1b[38;2;1;2;3m', 2)).toBe(12) + }) + + it('keeps a CSI with no final byte open', () => { + expect(findCsiFinalByteIndex('\x1b[38;2;177;1', 2)).toBe(-1) + }) + + it('ends a CSI where ESC, CAN or SUB aborts it, as xterm does', () => { + // Why: without the abort, the `]` of the next OSC read as this CSI's final byte. + expect(findCsiFinalByteIndex('\x1b[38;2;177;1\x1b]104;255\x07', 2)).toBe(11) + expect(findCsiFinalByteIndex('\x1b[38;2\x18A', 2)).toBe(5) + expect(findCsiFinalByteIndex('\x1b[38;2\x1aA', 2)).toBe(5) + }) +}) + +describe('reply query extraction after an aborted CSI', () => { + it('finds a CPR that follows a carried partial CSI', () => { + const result = extractHiddenStartupRendererQueryData('\x1b[6n', '\x1b[38;2;177;1') + + expect(result.statefulQueryData).toBe('\x1b[6n') + expect(result.pending).toBe('') + }) + + it('reports a CPR that follows a truncated CSI in the same chunk', () => { + expect(containsStatefulRendererQuery('\x1b[38;2;1\x1b[6n')).toBe(true) + }) +}) diff --git a/src/shared/terminal-reply-query-extraction.ts b/src/shared/terminal-reply-query-extraction.ts index 0ff95b943aa..2c20780a95a 100644 --- a/src/shared/terminal-reply-query-extraction.ts +++ b/src/shared/terminal-reply-query-extraction.ts @@ -133,12 +133,16 @@ export function containsStatefulRendererQuery(data: string): boolean { return false } +// Why: ESC/CAN/SUB abort a CSI (as in xterm), so a carried partial can't eat the next sequence's introducer. export function findCsiFinalByteIndex(data: string, offset: number): number { for (let index = offset; index < data.length; index++) { const code = data.charCodeAt(index) if (code >= 0x40 && code <= 0x7e) { return index } + if (code === 0x1b || code === 0x18 || code === 0x1a) { + return index - 1 + } } return -1 }