From e0de46ffc8ed68079cb68e5da28a88bb32a44fe7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:10:50 -0700 Subject: [PATCH] Prevent hidden terminal TUI overlap (#7054) Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 313 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 121 ++++++- .../pane-terminal-output-scheduler.test.ts | 85 ++++- .../pane-terminal-output-scheduler.ts | 54 +-- 4 files changed, 536 insertions(+), 37 deletions(-) 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 4c84c3f825b..01ad840dcbe 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -140,7 +140,7 @@ type StoreState = { } type ConnectCallbacks = { - onData?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + onData?: (data: string, meta?: { seq?: number; rawLength?: number; background?: boolean }) => void onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void } @@ -371,6 +371,21 @@ function createPane(paneId: number) { } } +function captureCallbackTerminalWrites(pane: ReturnType): { + writes: string[] + parseCallbacks: (() => void)[] +} { + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + pane.terminal.write = function write(data: string, callback?: () => void): void { + writes.push(data) + if (callback) { + parseCallbacks.push(callback) + } + } as typeof pane.terminal.write + return { writes, parseCallbacks } +} + function createManager(paneCount = 1, initialActivePaneId: number | null = null) { let activePaneId = initialActivePaneId const panes = Array.from({ length: paneCount }, (_, index) => ({ @@ -5375,6 +5390,269 @@ describe('connectPanePty', () => { } }) + it('schedules WebGL atlas recovery after hidden synchronized output parses', async () => { + 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 { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + const startChunk = '\x1b[?2026h' + const plainRowChunk = '| hidden Claude row |\r\n' + const endChunk = '\x1b[?2026l' + + capturedDataCallback.current?.(startChunk) + capturedDataCallback.current?.(plainRowChunk) + capturedDataCallback.current?.(endChunk) + + expect(writes).toEqual([]) + vi.advanceTimersByTime(50) + + expect(writes).toEqual([`${startChunk}${plainRowChunk}${endChunk}`]) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + + it('recognizes hidden synchronized output markers split across PTY chunks', async () => { + 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 { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[?202') + capturedDataCallback.current?.('6hbody row\r\n') + capturedDataCallback.current?.('tail\x1b[?20') + capturedDataCallback.current?.('26l') + + vi.advanceTimersByTime(50) + + expect(writes.join('')).toBe('\x1b[?2026hbody row\r\ntail\x1b[?2026l') + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + + it('does not schedule hidden atlas recovery for ordinary rich text or metadata output', async () => { + 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 { parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('plain hidden emoji 😀 and CJK 没改什么\r\n') + capturedDataCallback.current?.('\x1b[48;2;52;52;52mcolored shell text\x1b[0m\r\n') + capturedDataCallback.current?.('\x1b]0;hidden title\x07\x1b]133;A\x07') + + vi.advanceTimersByTime(50) + for (const callback of parseCallbacks) { + callback() + } + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('schedules hidden atlas recovery for high-confidence TUI redraw controls', async () => { + 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 { parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[2J\x1b[Hredrawn hidden table\x1b[K') + + vi.advanceTimersByTime(50) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('advances hidden rewrite state when synchronized output already requests recovery', async () => { + 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 { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('prompt rewrite\r') + vi.advanceTimersByTime(50) + expect(writes).toEqual(['prompt rewrite\r']) + parseCallbacks.shift()?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + capturedDataCallback.current?.('\x1b[?2026hredraw frame\x1b[?2026l') + vi.advanceTimersByTime(50) + expect(writes).toEqual(['prompt rewrite\r', '\x1b[?2026hredraw frame\x1b[?2026l']) + parseCallbacks.shift()?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + scheduleTerminalWebglAtlasRecovery.mockClear() + + capturedDataCallback.current?.('plain after frame') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('resets hidden synchronized state when hidden renderer output is skipped', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { background?: boolean }) => 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 { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const isVisibleRef = { current: false } + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[?2026h') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + scheduleTerminalWebglAtlasRecovery.mockClear() + writes.length = 0 + + isVisibleRef.current = true + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + capturedDataCallback.current?.('\x1b[?2026l', { background: true }) + expect(writes).toEqual([]) + + isVisibleRef.current = false + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' + capturedDataCallback.current?.('plain after skipped close\r\n') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + + expect(writes).toEqual(['plain after skipped close\r\n']) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('queues visible split-pane PTY bytes when the pane is not active', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -8263,6 +8541,39 @@ describe('connectPanePty', () => { expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('does not schedule WebGL atlas recovery for plain synchronized foreground frames', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + 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) + let parseCallback: (() => void) | undefined + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?2026hplain claude frame\x1b[?2026l') + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + it('forces a viewport refresh when foreground background SGR is split across PTY chunks', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 0bc7fd41975..3147e291644 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -181,6 +181,9 @@ const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750 const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 +const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' +const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' +const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 @@ -796,16 +799,16 @@ function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { } function containsSynchronizedOutputStart(data: string): boolean { - return data.includes('\x1b[?2026h') + return data.includes(SYNCHRONIZED_OUTPUT_START_SEQUENCE) } function containsSynchronizedOutputEnd(data: string): boolean { - return data.includes('\x1b[?2026l') + return data.includes(SYNCHRONIZED_OUTPUT_END_SEQUENCE) } function shouldSynchronizedOutputRemainActive(data: string, wasActive: boolean): boolean { - const lastStartIndex = data.lastIndexOf('\x1b[?2026h') - const lastEndIndex = data.lastIndexOf('\x1b[?2026l') + const lastStartIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE) + const lastEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE) if (lastStartIndex === -1 && lastEndIndex === -1) { return wasActive } @@ -3244,6 +3247,96 @@ export function connectPanePty( return prefersRefresh } + function resetHiddenRendererRiskState(ptyId: string | null = null): void { + hiddenRiskPtyId = ptyId + hiddenSynchronizedOutputActive = false + hiddenSynchronizedOutputMarkerTail = '' + hiddenRewriteChunkEndedWithCarriageReturn = false + hiddenRewriteCsiScanTail = '' + } + + function ensureHiddenRendererRiskStateForCurrentPty(): void { + const ptyId = transport.getPtyId() + if (hiddenRiskPtyId === ptyId) { + return + } + resetHiddenRendererRiskState(ptyId) + } + + function resetSkippedHiddenRendererRiskState(): void { + // Why: skipped/backlog bytes were not parsed by xterm; reset any live hidden + // frame instead of letting dropped DEC starts make later plain bytes risky. + resetHiddenRendererRiskState(transport.getPtyId()) + } + + function hiddenSynchronizedOutputTouchesParsedFrame(data: string): boolean { + const scanData = hiddenSynchronizedOutputMarkerTail + ? `${hiddenSynchronizedOutputMarkerTail}${data}` + : data + const currentChunkStartIndex = scanData.length - data.length + let active = hiddenSynchronizedOutputActive + let touchesParsedFrame = active && data.length > 0 + let offset = 0 + + while (offset < scanData.length) { + const startIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE, offset) + const endIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE, offset) + if (startIndex === -1 && endIndex === -1) { + break + } + if (endIndex !== -1 && (startIndex === -1 || endIndex < startIndex)) { + if ( + active && + endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length > currentChunkStartIndex + ) { + touchesParsedFrame = true + } + active = false + offset = endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length + continue + } + if (startIndex !== -1) { + active = true + if (startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length > currentChunkStartIndex) { + touchesParsedFrame = true + } + offset = startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length + continue + } + } + + if (active && data.length > 0) { + touchesParsedFrame = true + } + hiddenSynchronizedOutputActive = active + hiddenSynchronizedOutputMarkerTail = scanData.slice(-SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS) + return touchesParsedFrame + } + + function hiddenTuiRedrawOutputPrefersAtlasRecovery(data: string): boolean { + if (!data) { + return false + } + const scanData = hiddenRewriteCsiScanTail ? `${hiddenRewriteCsiScanTail}${data}` : data + const decision = terminalRewriteOutputRenderRefreshDecision(data, { + previousChunkEndsWithCarriageReturn: hiddenRewriteChunkEndedWithCarriageReturn, + previousRewriteCsiScanTail: hiddenRewriteCsiScanTail + }) + hiddenRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn + hiddenRewriteCsiScanTail = decision.nextRewriteCsiScanTail + return decision.prefersRenderRefresh || containsCursorPositionSequence(scanData) + } + + function hiddenOutputNeedsAtlasRecoveryAfterParse(data: string): boolean { + if (!data) { + return false + } + ensureHiddenRendererRiskStateForCurrentPty() + const synchronizedOutputTouchesParsedFrame = hiddenSynchronizedOutputTouchesParsedFrame(data) + const tuiRedrawOutputPrefersAtlasRecovery = hiddenTuiRedrawOutputPrefersAtlasRecovery(data) + return synchronizedOutputTouchesParsedFrame || tuiRedrawOutputPrefersAtlasRecovery + } + // The replay path uses the guard so xterm auto-replies to embedded query // sequences don't leak into the shell. xterm.write() buffers internally // regardless of DOM visibility and the guard stays engaged via the @@ -3345,6 +3438,11 @@ export function connectPanePty( const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) let hiddenStartupRendererQueryPending = '' let hiddenRendererStateDirty = false + let hiddenRiskPtyId: string | null = null + let hiddenSynchronizedOutputActive = false + let hiddenSynchronizedOutputMarkerTail = '' + let hiddenRewriteChunkEndedWithCarriageReturn = false + let hiddenRewriteCsiScanTail = '' function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) @@ -3519,6 +3617,7 @@ export function connectPanePty( ): void { if (foreground) { resetHiddenOutputRestoreIfPtyChanged() + resetHiddenRendererRiskState() } const parseHiddenStartupOutput = !foreground && @@ -3552,6 +3651,10 @@ export function connectPanePty( const renderRefreshDecision = foregroundOutput ? shouldForceForegroundRenderRefresh(data) : { refresh: false, inPlaceRewrite: false, recoverWebglAtlasAfterParse: false } + const recoverHiddenWebglAtlasAfterParse = + !foregroundOutput && hiddenOutputNeedsAtlasRecoveryAfterParse(data) + const recoverWebglAtlasAfterParse = + renderRefreshDecision.recoverWebglAtlasAfterParse || recoverHiddenWebglAtlasAfterParse const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh // Why: see nativeWindowsRewriteNeedsFollowupRenderRefresh — Claude Code's // in-place prompt redraws on Windows ConPTY can paint one frame late, so a @@ -3600,9 +3703,7 @@ export function connectPanePty( nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, // Why: atlas recovery must repaint from the parsed xterm buffer, not // a pre-write snapshot that a late TUI redraw can immediately stale. - onForegroundParsed: renderRefreshDecision.recoverWebglAtlasAfterParse - ? scheduleTerminalWebglAtlasRecovery - : undefined, + onParsed: recoverWebglAtlasAfterParse ? scheduleTerminalWebglAtlasRecovery : undefined, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive @@ -3620,6 +3721,7 @@ export function connectPanePty( } function markHiddenOutputRestoreNeeded(): void { + resetSkippedHiddenRendererRiskState() const ptyId = transport.getPtyId() if (!canUseHiddenOutputSnapshot(ptyId)) { return @@ -3932,6 +4034,7 @@ export function connectPanePty( hiddenOutputRestoreScheduled = false hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false + resetHiddenRendererRiskState() cancelScheduledHiddenOutputRestore(pane.terminal) clearHiddenOutputRestoreDeferredRetryTimer() clearHiddenOutputRestoreForegroundDeadlineTimer() @@ -3982,6 +4085,7 @@ export function connectPanePty( clearPendingLiveChunksDuringRestore() hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false + resetHiddenRendererRiskState() hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null hiddenOutputRestoreGeneration += 1 @@ -4014,6 +4118,7 @@ export function connectPanePty( function skipBackgroundAlternateScreenOutput(data: string): void { writeHiddenStartupRendererQueries(data) respondToSkippedMode2031Subscribe(data) + resetSkippedHiddenRendererRiskState() hiddenRendererStateDirty = true recordHiddenRendererSkip(data.length) const ptyId = transport.getPtyId() @@ -4089,6 +4194,7 @@ export function connectPanePty( writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) hiddenRendererStateDirty = false + resetHiddenRendererRiskState() recordTerminalOutput(pane.terminal) const currentPtyId = transport.getPtyId() if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { @@ -4350,6 +4456,7 @@ export function connectPanePty( queueLiveChunkDuringRestore(rendererData, rendererMeta) requestHiddenOutputRestoreIfNeeded() } else if (hiddenOutputRestoreInFlight) { + resetSkippedHiddenRendererRiskState() hiddenOutputRestoreNeeded = true hiddenOutputRestoreFreshSnapshotNeeded = true } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 0312419a560..2e107d9b218 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -69,26 +69,26 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('foreground', expect.any(Function)) }) - it('runs foreground parsed callbacks after immediate foreground output parses', async () => { + it('runs parsed callbacks after immediate foreground output parses', async () => { const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() let parseCallback: (() => void) | undefined terminal.write.mockImplementation((_data: string, callback?: () => void) => { parseCallback = callback }) - const onForegroundParsed = vi.fn() + const onParsed = vi.fn() writeTerminalOutput(terminal, 'foreground', { foreground: true, - onForegroundParsed + onParsed }) - expect(onForegroundParsed).not.toHaveBeenCalled() + expect(onParsed).not.toHaveBeenCalled() parseCallback?.() - expect(onForegroundParsed).toHaveBeenCalledTimes(1) + expect(onParsed).toHaveBeenCalledTimes(1) }) - it('runs foreground parsed callbacks after queued foreground output parses', async () => { + it('runs parsed callbacks after queued foreground output parses', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -96,20 +96,20 @@ describe('pane terminal output scheduler', () => { terminal.write.mockImplementation((_data: string, callback?: () => void) => { parseCallback = callback }) - const onForegroundParsed = vi.fn() + const onParsed = vi.fn() writeTerminalOutput(terminal, 'queued', { foreground: true, latencySensitive: false, - onForegroundParsed + onParsed }) vi.advanceTimersByTime(0) expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function)) - expect(onForegroundParsed).not.toHaveBeenCalled() + expect(onParsed).not.toHaveBeenCalled() parseCallback?.() - expect(onForegroundParsed).toHaveBeenCalledTimes(1) + expect(onParsed).toHaveBeenCalledTimes(1) }) it('synchronously refreshes visible rows after foreground output parses', async () => { @@ -244,6 +244,71 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('ab') }) + it('runs parsed callbacks after background output parses without foreground refresh', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + terminal.write = function write(data: string, callback?: () => void): void { + writes.push(data) + if (callback) { + parseCallbacks.push(callback) + } + } as typeof terminal.write + const onParsed = vi.fn() + + writeTerminalOutput(terminal, 'hidden redraw', { + foreground: false, + forceForegroundRefresh: true, + followupForegroundRefresh: true, + onParsed + }) + + vi.advanceTimersByTime(50) + + expect(writes).toEqual(['hidden redraw']) + expect(onParsed).not.toHaveBeenCalled() + expect(terminal._core.refresh).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(onParsed).toHaveBeenCalledTimes(1) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + + it('keeps parsed callbacks on large background chunks split by the scheduler', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + terminal.write = function write(data: string, callback?: () => void): void { + writes.push(data) + if (callback) { + parseCallbacks.push(callback) + } + } as typeof terminal.write + const onParsed = vi.fn() + + writeTerminalOutput(terminal, 'x'.repeat(20 * 1024), { + foreground: false, + onParsed + }) + + vi.advanceTimersByTime(50) + + expect(writes.map((data) => data.length)).toEqual([16 * 1024, 4 * 1024]) + expect(parseCallbacks).toHaveLength(2) + expect(onParsed).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(onParsed).toHaveBeenCalledTimes(1) + parseCallbacks[1]?.() + expect(onParsed).toHaveBeenCalledTimes(2) + }) + it('defers throughput foreground output to the shared high-priority drain', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index e56df22c0df..c43fc8cf161 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -21,7 +21,7 @@ type TerminalOutputParsedCallback = () => void type WriteTerminalOutputOptions = { foreground: boolean beforeWrite?: TerminalOutputBeforeWrite - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback onBackgroundBacklogDropped?: () => void latencySensitive?: boolean forceForegroundRefresh?: boolean @@ -37,7 +37,7 @@ type QueueChunk = { forceForegroundRefresh: boolean followupForegroundRefresh: boolean stripTransientCursorShows: boolean - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback } type QueuedWrite = { @@ -46,7 +46,7 @@ type QueuedWrite = { forceForegroundRefresh: boolean followupForegroundRefresh: boolean stripTransientCursorShows: boolean - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback } type QueueEntry = { @@ -499,13 +499,16 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { remaining -= chunk.data.length entry.queuedChars -= chunk.data.length entry.chunkIndex += 1 - if (chunk.foreground && chunk.onForegroundParsed) { - parsedCallbacks.push(chunk.onForegroundParsed) + if (chunk.onParsed) { + parsedCallbacks.push(chunk.onParsed) } continue } data += chunk.data.slice(0, remaining) + if (chunk.onParsed) { + parsedCallbacks.push(chunk.onParsed) + } entry.chunks[entry.chunkIndex] = { ...chunk, data: chunk.data.slice(remaining) @@ -526,7 +529,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { forceForegroundRefresh, followupForegroundRefresh, stripTransientCursorShows, - onForegroundParsed: + onParsed: parsedCallbacks.length > 0 ? () => { for (const callback of parsedCallbacks) { @@ -561,7 +564,7 @@ function enqueueChunk( forceForegroundRefresh?: boolean followupForegroundRefresh?: boolean stripTransientCursorShows?: boolean - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback } ): void { entry.chunks.push({ @@ -570,7 +573,7 @@ function enqueueChunk( forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, stripTransientCursorShows: options?.stripTransientCursorShows === true, - onForegroundParsed: options?.onForegroundParsed + onParsed: options?.onParsed }) entry.queuedChars += data.length recordQueueDebugPressure() @@ -628,19 +631,30 @@ function hasDrainableBacklog(): boolean { return false } -function writeBackgroundTerminalChunk(terminal: TerminalOutputTarget, data: string): void { +function writeBackgroundTerminalChunk( + terminal: TerminalOutputTarget, + data: string, + onParsed?: TerminalOutputParsedCallback +): void { const scrollIntent = captureTerminalWriteScrollIntent(terminal) if (!scrollIntent) { - terminal.write(data) + if (!onParsed || terminal.write.length < 2) { + terminal.write(data) + onParsed?.() + return + } + terminal.write(data, onParsed) return } if (terminal.write.length < 2) { terminal.write(data) enforceTerminalWriteScrollIntent(terminal, scrollIntent) + onParsed?.() return } terminal.write(data, () => { enforceTerminalWriteScrollIntent(terminal, scrollIntent) + onParsed?.() }) } @@ -693,11 +707,11 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onForegroundParsed + onParsed: queuedWrite.onParsed } ) } else { - writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data) + writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data, queuedWrite.onParsed) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; @@ -782,7 +796,7 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onForegroundParsed: options.onForegroundParsed + onParsed: options.onParsed }) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -849,7 +863,7 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onForegroundParsed: options.onForegroundParsed + onParsed: options.onParsed }) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -876,7 +890,7 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onForegroundParsed: options.onForegroundParsed + onParsed: options.onParsed }) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -899,7 +913,7 @@ export function writeTerminalOutput( { forceViewportRefresh: options.forceForegroundRefresh === true, followupViewportRefresh: options.followupForegroundRefresh === true, - onParsed: options.onForegroundParsed + onParsed: options.onParsed } ) return @@ -914,7 +928,9 @@ export function writeTerminalOutput( entry.beforeWrite = options.beforeWrite entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped } - enqueueChunk(entry, data) + enqueueChunk(entry, data, { + onParsed: options.onParsed + }) if ( entry.queuedChars > MAX_BACKGROUND_QUEUE_CHARS || entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS @@ -975,11 +991,11 @@ export function flushTerminalOutput( { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onForegroundParsed + onParsed: queuedWrite.onParsed } ) } else { - writeBackgroundTerminalChunk(terminal, queuedWrite.data) + writeBackgroundTerminalChunk(terminal, queuedWrite.data, queuedWrite.onParsed) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping;