diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 559cf3d9630..bc9b2e5b806 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -25,7 +25,11 @@ import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' -import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' +import { + terminalOutputPrefersRenderRefresh, + terminalRewriteOutputRenderRefreshDecision, + terminalRewriteOutputPrefersRenderRefresh +} from '@/lib/pane-manager/terminal-complex-script' import { PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, queuePanePtyResizeIfHeld, @@ -2594,6 +2598,8 @@ export function connectPanePty( let hiddenOutputRestoreGeneration = 0 let foregroundImmediateBudgetChars = 0 let foregroundImmediateBudgetWindowStart = 0 + let foregroundRewriteChunkEndedWithCarriageReturn = false + let foregroundRewriteCsiScanTail = '' let hiddenMode2031ScanTail = '' const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) let hiddenStartupRendererQueryPending = '' @@ -2710,35 +2716,31 @@ export function connectPanePty( } function containsWindowsRewriteControl(data: string): boolean { - if (data.includes('\r') || data.includes('\b')) { - return true - } - let escapeIndex = data.indexOf('\x1b[') - while (escapeIndex !== -1) { - for (let index = escapeIndex + 2; index < data.length; index++) { - const char = data[index] - if (char >= '0' && char <= '9') { - continue - } - if (char === ';' || char === '?') { - continue - } - if (char === 'J' || char === 'K') { - return true - } - break - } - escapeIndex = data.indexOf('\x1b[', escapeIndex + 2) - } - return false + return data.includes('\r') || terminalRewriteOutputPrefersRenderRefresh(data) + } + + function foregroundRewriteOutputPrefersRenderRefresh(data: string): boolean { + const decision = terminalRewriteOutputRenderRefreshDecision(data, { + previousChunkEndsWithCarriageReturn: foregroundRewriteChunkEndedWithCarriageReturn, + previousRewriteCsiScanTail: foregroundRewriteCsiScanTail + }) + foregroundRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn + foregroundRewriteCsiScanTail = decision.nextRewriteCsiScanTail + return decision.prefersRenderRefresh } function shouldForceForegroundRenderRefresh(data: string): boolean { + const rewriteOutputPrefersRenderRefresh = foregroundRewriteOutputPrefersRenderRefresh(data) if (foregroundAnsiOutputPrefersRenderRefresh(data)) { // Why: Codex-style background SGR panels can paint cell fills while // glyphs lag behind; refresh only renderer-risk ANSI chunks, not all output. return true } + if (rewriteOutputPrefersRenderRefresh) { + // Why: resize fixes these panes because xterm's buffer is right but + // in-place redraw cells can remain stale in the renderer until repaint. + return true + } return ( shouldApplyNativeWindowsRewriteRefresh && containsNonAsciiOutput(data) && @@ -2779,21 +2781,24 @@ export function connectPanePty( // cursor-only restores need row invalidation even outside DEC 2026. const nativeWindowsCursorRestore = shouldProtectNativeWindowsSynchronizedOutput && foreground && containsCursorRestore(data) + const foregroundOutput = foreground || parseHiddenStartupOutput + const foregroundRenderRefreshNeeded = + foregroundOutput && shouldForceForegroundRenderRefresh(data) synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive - if (hiddenMode2031ScanTail) { + if (!foreground && hiddenMode2031ScanTail) { respondToSkippedMode2031Subscribe(data) } writeTerminalOutput(pane.terminal, data, { - foreground: foreground || parseHiddenStartupOutput, + foreground: foregroundOutput, beforeWrite: beforeTerminalOutputWrite, onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded, latencySensitive: !foreground || parseHiddenStartupOutput ? true : isLatencySensitiveForegroundOutput(data), forceForegroundRefresh: - (foreground || parseHiddenStartupOutput) && + foregroundOutput && (synchronizedForegroundOutput || nativeWindowsCursorRestore || - shouldForceForegroundRenderRefresh(data)), + foregroundRenderRefreshNeeded), followupForegroundRefresh: nativeWindowsCursorRestore, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts index b83fe4fee1f..4480fee3a15 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { terminalOutputPrefersRenderRefresh } from './terminal-complex-script' +import { + terminalOutputPrefersRenderRefresh, + terminalRewriteOutputRenderRefreshDecision, + terminalRewriteOutputPrefersRenderRefresh +} from './terminal-complex-script' describe('terminalOutputPrefersRenderRefresh', () => { it('detects Arabic terminal output', () => { @@ -69,3 +73,156 @@ describe('terminalOutputPrefersRenderRefresh', () => { ) }) }) + +describe('terminalRewriteOutputPrefersRenderRefresh', () => { + it('detects in-place carriage-return redraws', () => { + expect(terminalRewriteOutputPrefersRenderRefresh('\r• Working')).toBe(true) + expect(terminalRewriteOutputPrefersRenderRefresh('prefix\r\x1b[2K• Working')).toBe(true) + }) + + it('does not treat normal CRLF output as an in-place redraw', () => { + expect(terminalRewriteOutputPrefersRenderRefresh('line one\r\nline two\r\n')).toBe(false) + }) + + it('waits on a trailing carriage return so split CRLF output does not refresh early', () => { + expect(terminalRewriteOutputPrefersRenderRefresh('line one\r')).toBe(false) + expect(terminalRewriteOutputPrefersRenderRefresh('\nline two')).toBe(false) + }) + + it('detects terminal erase rewrites and backspace updates', () => { + expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[2K• Working')).toBe(true) + expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[2J\x1b[Hredraw')).toBe(true) + expect(terminalRewriteOutputPrefersRenderRefresh('progress 10%\b\b20%')).toBe(true) + }) + + it('still detects split Codex-style rewrites through the erase-line chunk', () => { + expect(terminalRewriteOutputPrefersRenderRefresh('\r')).toBe(false) + expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[2K• Working')).toBe(true) + }) + + it('ignores ordinary cursor movement and style output', () => { + expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[10;2Hcursor move')).toBe(false) + expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[32mplain green\x1b[0m')).toBe(false) + }) +}) + +describe('terminalRewriteOutputRenderRefreshDecision', () => { + it('refreshes when a trailing carriage return continues as a split redraw', () => { + const trailingCarriageReturn = terminalRewriteOutputRenderRefreshDecision('\r', { + previousChunkEndsWithCarriageReturn: false, + previousRewriteCsiScanTail: '' + }) + expect(trailingCarriageReturn).toEqual({ + nextChunkEndsWithCarriageReturn: true, + nextRewriteCsiScanTail: '', + prefersRenderRefresh: false + }) + + expect( + terminalRewriteOutputRenderRefreshDecision('• Working without erase-line', { + previousChunkEndsWithCarriageReturn: true, + previousRewriteCsiScanTail: '' + }) + ).toEqual({ + nextChunkEndsWithCarriageReturn: false, + nextRewriteCsiScanTail: '', + prefersRenderRefresh: true + }) + }) + + it('does not refresh split CRLF output', () => { + const trailingCarriageReturn = terminalRewriteOutputRenderRefreshDecision('line one\r', { + previousChunkEndsWithCarriageReturn: false, + previousRewriteCsiScanTail: '' + }) + expect(trailingCarriageReturn).toEqual({ + nextChunkEndsWithCarriageReturn: true, + nextRewriteCsiScanTail: '', + prefersRenderRefresh: false + }) + + expect( + terminalRewriteOutputRenderRefreshDecision('\nline two', { + previousChunkEndsWithCarriageReturn: true, + previousRewriteCsiScanTail: '' + }) + ).toEqual({ + nextChunkEndsWithCarriageReturn: false, + nextRewriteCsiScanTail: '', + prefersRenderRefresh: false + }) + }) + + it('refreshes when a rewrite erase sequence is split across chunks', () => { + const trailingRewriteCsi = terminalRewriteOutputRenderRefreshDecision('\r\x1b[', { + previousChunkEndsWithCarriageReturn: false, + previousRewriteCsiScanTail: '' + }) + expect(trailingRewriteCsi).toEqual({ + nextChunkEndsWithCarriageReturn: false, + nextRewriteCsiScanTail: '\x1b[', + prefersRenderRefresh: true + }) + + expect( + terminalRewriteOutputRenderRefreshDecision('2K• Working', { + previousChunkEndsWithCarriageReturn: false, + previousRewriteCsiScanTail: '\x1b[' + }) + ).toEqual({ + nextChunkEndsWithCarriageReturn: false, + nextRewriteCsiScanTail: '', + prefersRenderRefresh: true + }) + }) + + it('carries rewrite erase sequence tails split before CSI introducer or params', () => { + expect( + terminalRewriteOutputRenderRefreshDecision('\x1b', { + previousChunkEndsWithCarriageReturn: false, + previousRewriteCsiScanTail: '' + }) + ).toEqual({ + nextChunkEndsWithCarriageReturn: false, + nextRewriteCsiScanTail: '\x1b', + prefersRenderRefresh: false + }) + + expect( + terminalRewriteOutputRenderRefreshDecision('2J', { + previousChunkEndsWithCarriageReturn: false, + previousRewriteCsiScanTail: '\x1b[' + }) + ).toEqual({ + nextChunkEndsWithCarriageReturn: false, + nextRewriteCsiScanTail: '', + prefersRenderRefresh: true + }) + }) + + it('drops overlong rewrite CSI tails', () => { + expect( + terminalRewriteOutputRenderRefreshDecision(`\x1b[${'1'.repeat(80)}`, { + previousChunkEndsWithCarriageReturn: false, + previousRewriteCsiScanTail: '' + }) + ).toEqual({ + nextChunkEndsWithCarriageReturn: false, + nextRewriteCsiScanTail: '', + prefersRenderRefresh: false + }) + }) + + it('preserves pending trailing carriage return state across empty chunks', () => { + expect( + terminalRewriteOutputRenderRefreshDecision('', { + previousChunkEndsWithCarriageReturn: true, + previousRewriteCsiScanTail: '\x1b[' + }) + ).toEqual({ + nextChunkEndsWithCarriageReturn: true, + nextRewriteCsiScanTail: '\x1b[', + prefersRenderRefresh: false + }) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts index 3395138d760..d7b0617a099 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts @@ -3,8 +3,23 @@ // without switching renderers based on the text content. const EMOJI_PRESENTATION_PATTERN = /\p{Emoji_Presentation}/u const ESCAPE_CHARACTER = String.fromCharCode(0x1b) +const REWRITE_CSI_SCAN_TAIL_MAX_CHARS = 64 const SGR_SEQUENCE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[([0-9:;]*)m`, 'g') +function containsStandaloneCarriageReturn(data: string): boolean { + let index = data.indexOf('\r') + while (index !== -1) { + if (index === data.length - 1) { + return false + } + if (data[index + 1] !== '\n') { + return true + } + index = data.indexOf('\r', index + 1) + } + return false +} + function isInRange(value: number, start: number, end: number): boolean { return value >= start && value <= end } @@ -86,6 +101,99 @@ function containsBackgroundSgr(data: string): boolean { return false } +function containsRewriteEraseSequence(data: string): boolean { + let escapeIndex = data.indexOf('\x1b[') + while (escapeIndex !== -1) { + for (let index = escapeIndex + 2; index < data.length; index++) { + const char = data[index] + if (char >= '0' && char <= '9') { + continue + } + if (char === ';' || char === '?') { + continue + } + // Why: erase-in-line/screen rewrites can leave stale renderer cells until + // the next resize; xterm's buffer is correct, but the visible layer needs repainting. + if (char === 'J' || char === 'K') { + return true + } + break + } + escapeIndex = data.indexOf('\x1b[', escapeIndex + 2) + } + return false +} + +function trailingIncompleteRewriteCsiTail(data: string): string { + const escapeIndex = data.lastIndexOf(ESCAPE_CHARACTER) + if (escapeIndex === -1) { + return '' + } + const tail = data.slice(escapeIndex) + if (tail === ESCAPE_CHARACTER) { + return tail + } + if (!tail.startsWith('\x1b[')) { + return '' + } + if (tail.length > REWRITE_CSI_SCAN_TAIL_MAX_CHARS) { + return '' + } + for (let index = 2; index < tail.length; index++) { + const char = tail[index] + if (char >= '0' && char <= '9') { + continue + } + if (char === ';' || char === '?') { + continue + } + return '' + } + return tail +} + +export function terminalRewriteOutputPrefersRenderRefresh(data: string): boolean { + if (data.includes('\b') || containsStandaloneCarriageReturn(data)) { + return true + } + + return containsRewriteEraseSequence(data) +} + +export type TerminalRewriteOutputRenderRefreshDecision = { + nextChunkEndsWithCarriageReturn: boolean + nextRewriteCsiScanTail: string + prefersRenderRefresh: boolean +} + +export type TerminalRewriteOutputRenderRefreshState = { + previousChunkEndsWithCarriageReturn: boolean + previousRewriteCsiScanTail: string +} + +export function terminalRewriteOutputRenderRefreshDecision( + data: string, + state: TerminalRewriteOutputRenderRefreshState +): TerminalRewriteOutputRenderRefreshDecision { + if (!data) { + return { + nextChunkEndsWithCarriageReturn: state.previousChunkEndsWithCarriageReturn, + nextRewriteCsiScanTail: state.previousRewriteCsiScanTail, + prefersRenderRefresh: false + } + } + const scanData = state.previousRewriteCsiScanTail + ? `${state.previousRewriteCsiScanTail}${data}` + : data + return { + nextChunkEndsWithCarriageReturn: data.endsWith('\r'), + nextRewriteCsiScanTail: trailingIncompleteRewriteCsiTail(scanData), + prefersRenderRefresh: + (state.previousChunkEndsWithCarriageReturn && data[0] !== '\n') || + terminalRewriteOutputPrefersRenderRefresh(scanData) + } +} + export function terminalOutputPrefersRenderRefresh(data: string): boolean { if (containsBackgroundSgr(data)) { return true diff --git a/tests/e2e/terminal-foreground-redraw-freeze.spec.ts b/tests/e2e/terminal-foreground-redraw-freeze.spec.ts index c36413f6e9e..d0d846bfb49 100644 --- a/tests/e2e/terminal-foreground-redraw-freeze.spec.ts +++ b/tests/e2e/terminal-foreground-redraw-freeze.spec.ts @@ -39,8 +39,17 @@ type SchedulerDebugWindow = Window & { } } +type RefreshProbeWindow = SchedulerDebugWindow & { + __terminalRefreshProbe?: { + count: () => number + dispose: () => void + } +} + const REDRAW_FRAME_COUNT = 270 const REDRAW_PAYLOAD_CHARS = 520 +const REWRITE_REDRAW_FRAME_COUNT = REDRAW_FRAME_COUNT +const REWRITE_REDRAW_PAYLOAD_CHARS = REDRAW_PAYLOAD_CHARS const TIMER_SAMPLE_MS = 16 const MAX_RENDERER_TIMER_DRIFT_MS = 500 const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024 @@ -82,6 +91,18 @@ async function measureRendererDuringBurst(page: Page, paneKey: string): Promise< return measureRendererDuringFrames(page, paneKey, frames) } +async function measureRendererDuringRewriteBurst( + page: Page, + paneKey: string +): Promise { + const frames = Array.from({ length: REWRITE_REDRAW_FRAME_COUNT }, (_, frame) => { + const text = `• Working ${String(frame).padStart(4, '0')}` + const payload = 'x'.repeat(REWRITE_REDRAW_PAYLOAD_CHARS) + return `\r\x1b[2K${text} ${payload}` + }) + return measureRendererDuringFrames(page, paneKey, frames) +} + async function measureRendererDuringFrames( page: Page, paneKey: string, @@ -133,6 +154,58 @@ async function measureRendererDuringFrames( ) } +async function installActivePaneRefreshProbe(page: Page): Promise { + await page.evaluate(() => { + ;(window as RefreshProbeWindow).__terminalRefreshProbe?.dispose() + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('Active terminal pane is unavailable') + } + const terminal = pane.terminal as unknown as { + _core?: { refresh?: (start: number, end: number, sync?: boolean) => void } + } + const originalCoreRefresh = terminal._core?.refresh?.bind(terminal._core) + if (!terminal._core || !originalCoreRefresh) { + throw new Error('Active terminal core refresh hook is unavailable') + } + let refreshCount = 0 + terminal._core.refresh = (start, end, sync) => { + if (sync === true) { + refreshCount += 1 + } + originalCoreRefresh(start, end, sync) + } + ;(window as RefreshProbeWindow).__terminalRefreshProbe = { + count: () => refreshCount, + dispose: () => { + if (terminal._core) { + terminal._core.refresh = originalCoreRefresh + } + delete (window as RefreshProbeWindow).__terminalRefreshProbe + } + } + }) +} + +async function readRefreshProbeCount(page: Page): Promise { + return page.evaluate(() => (window as RefreshProbeWindow).__terminalRefreshProbe?.count() ?? 0) +} + +async function disposeActivePaneRefreshProbe(page: Page): Promise { + await page.evaluate(() => { + ;(window as RefreshProbeWindow).__terminalRefreshProbe?.dispose() + }) +} + function loadCapturedOpenCodeSmallRedrawFrames(): string[] { if (!existsSync(OPENCODE_CAPTURE_PATH)) { return [] @@ -176,6 +249,35 @@ function annotateMeasurement( } test.describe('Terminal foreground redraw freeze repro', () => { + test('Codex-style line rewrites request a visible row refresh', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage) + await waitForTerminalPtyDataInjector(orcaPage, paneKey) + await installActivePaneRefreshProbe(orcaPage) + try { + const refreshBaseline = await readRefreshProbeCount(orcaPage) + await resetSchedulerDebug(orcaPage) + const measurement = await measureRendererDuringRewriteBurst(orcaPage, paneKey) + const scheduler = await readSchedulerDebug(orcaPage) + + expect(measurement.injectedFrames).toBe(REWRITE_REDRAW_FRAME_COUNT) + expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_RENDERER_TIMER_DRIFT_MS) + expect(scheduler.deferredForegroundEnqueueCount).toBeGreaterThan(0) + await expect + .poll(async () => (await readRefreshProbeCount(orcaPage)) - refreshBaseline, { + timeout: 5_000, + message: 'Codex-style terminal rewrites did not request an xterm refresh' + }) + .toBeGreaterThan(0) + } finally { + await disposeActivePaneRefreshProbe(orcaPage) + } + }) + test('active OpenTUI-style redraw bursts do not monopolize the renderer', async ({ orcaPage }, testInfo) => {