diff --git a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts index 1076a985cfe..4fefa59dcc6 100644 --- a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts +++ b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts @@ -61,7 +61,19 @@ describe('shouldSkipHiddenRendererOutput', () => { ).toBe(true) }) - it('keeps startup query windows and terminal-control chunks live', () => { + it('skips safe hidden synchronized redraw chunks when a snapshot restore is available', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: true, + data: '\x1b[?2026h\x1b[1;1H\x1b[2J\x1b[32mready\x1b[0m\x1b[?25l\x1b[?2026l\n' + }) + ).toBe(true) + }) + + it('keeps startup query windows live', () => { expect( shouldSkipHiddenRendererOutput({ foreground: false, @@ -71,15 +83,6 @@ describe('shouldSkipHiddenRendererOutput', () => { data: 'plain\r\n' }) ).toBe(false) - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: true, - data: 'plain row inside synchronized frame\r\n' - }) - ).toBe(false) expect( shouldSkipHiddenRendererOutput({ foreground: false, @@ -95,11 +98,25 @@ describe('shouldSkipHiddenRendererOutput', () => { canRestoreHiddenOutput: true, startupRendererQueryWindowActive: false, synchronizedOutputActive: false, - data: '\x1b[?2026hredraw\x1b[?2026l' + data: '\x1b[?1;2c' }) ).toBe(false) }) + it('keeps query and incomplete control chunks live', () => { + for (const data of ['\x1b[6n', '\x1b[c', '\x1b[?25', '\x1b[?1049h']) { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data + }) + ).toBe(false) + } + }) + it('keeps non-title OSC and incomplete title OSC chunks live', () => { for (const data of ['\x1b]52;c;clipboard\x07', '\x1b]9;notify\x07', '\x1b]0;partial-title']) { expect( diff --git a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts index 123d22b60ff..ff6866da73b 100644 --- a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts +++ b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts @@ -36,11 +36,52 @@ function findTitleOscEnd(data: string, startIndex: number): number | null { return null } +function findSafeCsiEnd(data: string, startIndex: number): number | null { + if (data.charCodeAt(startIndex) !== 0x1b || data.charCodeAt(startIndex + 1) !== 0x5b) { + return null + } + + for (let index = startIndex + 2; index < data.length; index++) { + const code = data.charCodeAt(index) + if (code < 0x40 || code > 0x7e) { + continue + } + const body = data.slice(startIndex + 2, index) + const final = data[index] + if (isSafeHiddenRedrawCsi(body, final)) { + return index + 1 + } + return null + } + return null +} + +function isSafeHiddenRedrawCsi(body: string, final: string): boolean { + if (/[^0-9;?]/.test(body)) { + return false + } + if (final === 'h' || final === 'l') { + return body === '?2026' || body === '?25' + } + return ( + final === 'm' || + final === 'H' || + final === 'f' || + final === 'A' || + final === 'B' || + final === 'C' || + final === 'D' || + final === 'G' || + final === 'J' || + final === 'K' + ) +} + function containsOnlyRestorableHiddenOutput(data: string): boolean { for (let index = 0; index < data.length; ) { const code = data.charCodeAt(index) if (code === 0x1b) { - const nextIndex = findTitleOscEnd(data, index) + const nextIndex = findTitleOscEnd(data, index) ?? findSafeCsiEnd(data, index) if (nextIndex === null) { return false } @@ -66,14 +107,12 @@ export function shouldSkipHiddenRendererOutput({ foreground, canRestoreHiddenOutput, startupRendererQueryWindowActive, - synchronizedOutputActive, data }: HiddenRendererSkipEligibility): boolean { if ( foreground || !canRestoreHiddenOutput || startupRendererQueryWindowActive || - synchronizedOutputActive || data.length === 0 ) { return false 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 450688ddcda..2fd955463f5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2945,48 +2945,56 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - it('keeps hidden terminal-control bytes on the live xterm path', async () => { - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout + it('restores safe hidden terminal-control bytes from the main snapshot', 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 + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'control snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + const controlOutput = '\x1b[2J\x1b[Hhello\r\n' + capturedDataCallback.current?.(controlOutput, { + seq: controlOutput.length, + rawLength: controlOutput.length + }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(controlOutput, expect.any(Function)) - expect(capturedDataCallback.current).not.toBeNull() - const controlOutput = '\x1b[2J\x1b[Hhello\r\n' - capturedDataCallback.current?.(controlOutput) - expect(pane.terminal.write).not.toHaveBeenCalledWith(controlOutput) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.('visible\r\n', { + seq: controlOutput.length + 'visible\r\n'.length, + rawLength: 'visible\r\n'.length + }) + await flushAsyncTicks(20) - for (const fn of pendingTimeouts) { - fn() - } - - expect(pane.terminal.write).toHaveBeenCalledWith(controlOutput) - } finally { - globalThis.setTimeout = originalSetTimeout - } + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('control snapshot'), + expect.any(Function) + ) }) it('keeps visually rich hidden PTY bytes on the live xterm path', async () => { @@ -3023,15 +3031,26 @@ describe('connectPanePty', () => { } }) - it('keeps split hidden synchronized output frames on the live xterm path', async () => { + it('restores safe split hidden synchronized output frames from the main snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + 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 + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot table\r\nLONG_TABLE_SCROLL_RESTORE_marker\r\n', + cols: 100, + rows: 30, + seq: 80 + }) const pane = createPane(1) const manager = createManager(1) @@ -3043,23 +3062,40 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const startChunk = '\x1b[?2026h' - const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' - const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' + const startChunk = '\x1b[?2026h' + const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' + const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' + const visibleChunk = 'visible-after-hidden\r\n' - capturedDataCallback.current?.(startChunk) - capturedDataCallback.current?.(plainRowChunk) - capturedDataCallback.current?.(endChunk) + capturedDataCallback.current?.(startChunk, { + seq: startChunk.length, + rawLength: startChunk.length + }) + capturedDataCallback.current?.(plainRowChunk, { + seq: startChunk.length + plainRowChunk.length, + rawLength: plainRowChunk.length + }) + capturedDataCallback.current?.(endChunk, { + seq: startChunk.length + plainRowChunk.length + endChunk.length, + rawLength: endChunk.length + }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(`${startChunk}${plainRowChunk}${endChunk}`) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } + expect(pane.terminal.write).not.toHaveBeenCalledWith(startChunk, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(endChunk, expect.any(Function)) + + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleChunk, { + seq: startChunk.length + plainRowChunk.length + endChunk.length + visibleChunk.length, + rawLength: visibleChunk.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('snapshot table'), + expect.any(Function) + ) }) it('queues visible split-pane PTY bytes when the pane is not active', async () => { diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index b34e8b0c606..da631383d73 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -171,7 +171,11 @@ export async function runHiddenRealPtyPressureScenario< ackGate ) - if (pressureOutputMode === 'plain' || pressureOutputMode === 'title') { + if ( + pressureOutputMode === 'plain' || + pressureOutputMode === 'title' || + pressureOutputMode === 'tui' + ) { expect(debug?.hiddenRendererSkipCount ?? 0).toBeGreaterThan(0) expect(debug?.hiddenRendererSkippedChars ?? 0).toBeGreaterThan(0) } else {