diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts index 751b3eb85d8..29698d9418f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts @@ -34,7 +34,7 @@ const { shouldSeedCacheTimerOnInitialTitle, toastInfo, notifyCodexPaneBoundForStaleSweep, - presentPaneViewportPreservingSynchronizedOutput + presentPaneViewport } = vi.hoisted(() => ({ resetAndRefreshAllTerminalWebglAtlases: vi.fn(), scheduleTerminalWebglAtlasRecovery: vi.fn(), @@ -42,7 +42,7 @@ const { shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), toastInfo: vi.fn(), notifyCodexPaneBoundForStaleSweep: vi.fn(), - presentPaneViewportPreservingSynchronizedOutput: vi.fn() + presentPaneViewport: vi.fn() })) let mockStoreState: StoreState @@ -61,7 +61,7 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ vi.mock('@/lib/pane-manager/pane-webgl-renderer', async (importOriginal) => ({ ...(await importOriginal>()), - presentPaneViewportPreservingSynchronizedOutput + presentPaneViewport })) vi.mock('./terminal-webgl-atlas-recovery', () => ({ @@ -211,7 +211,7 @@ describe('connectPanePty', () => { expect.stringContaining('remote snapshot with hidden remote output'), expect.any(Function) ) - expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane) + expect(presentPaneViewport).toHaveBeenCalledWith(pane) disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 22da0d73e05..b97cf9de244 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -15,7 +15,7 @@ import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' -import { presentPaneViewportPreservingSynchronizedOutput } from '@/lib/pane-manager/pane-webgl-renderer' +import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, @@ -198,7 +198,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): recordTerminalOutput(session.pane.terminal) await waitForTerminalReplayWritesParsed(session.pane.terminal) if (session.deps.isVisibleRef.current) { - presentPaneViewportPreservingSynchronizedOutput(session.pane) + presentPaneViewport(session.pane) recordTerminalFreezeBreadcrumb('stale-pixel-restore-present', { paneId: session.pane.id }) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 635adf7c36b..78fcd7098b6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -9,11 +9,8 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ resetAndRefreshAllTerminalWebglAtlases: vi.fn() })) const presentPaneViewport = vi.fn() -const presentPaneViewportPreservingSynchronizedOutput = vi.fn() vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({ - presentPaneViewport: (pane: unknown) => presentPaneViewport(pane), - presentPaneViewportPreservingSynchronizedOutput: (pane: unknown) => - presentPaneViewportPreservingSynchronizedOutput(pane) + presentPaneViewport: (pane: unknown) => presentPaneViewport(pane) })) vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ flushTerminalOutput: vi.fn(), @@ -173,7 +170,7 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledWith(pane) expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1) expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledWith('visibility-resume-dpr') - expect(presentPaneViewportPreservingSynchronizedOutput).not.toHaveBeenCalled() + expect(presentPaneViewport).not.toHaveBeenCalled() expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) }) @@ -189,7 +186,7 @@ describe('resumeTerminalVisibility reveal repaint', () => { resumeTerminalVisibility(resumeArgs(manager, false)) - expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane) + expect(presentPaneViewport).toHaveBeenCalledWith(pane) // The expensive registry-wide rebuild is still deferred to the settled frame. expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index d208fc9ab74..07e94e56351 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -18,10 +18,7 @@ import { useAppStore } from '@/store' import { auditPaneWeightParity } from './terminal-render-desync-weight-probe' import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit' import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair' -import { - presentPaneViewport, - presentPaneViewportPreservingSynchronizedOutput -} from '@/lib/pane-manager/pane-webgl-renderer' +import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 @@ -130,7 +127,7 @@ export function resumeTerminalVisibility({ // present now; the settled rebuild is two frames out and would otherwise // leave pre-hide pixels composited until then. for (const pane of manager.getPanes()) { - presentPaneViewportPreservingSynchronizedOutput(pane) + presentPaneViewport(pane) } // Why: one settled rebuild repairs recreated rendering without paying a // duplicate global atlas rebuild before the pane is attached and measured. diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts index 0c96dbb32a9..d6d9ce2ee9a 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts @@ -66,14 +66,14 @@ describe('pane manager registry', () => { const first = { resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-reset')), clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-clear')), - presentForcedViewports: vi.fn<() => void>(() => order.push('first-present')), + presentViewports: vi.fn<() => void>(() => order.push('first-present')), refreshAllPanes: vi.fn<() => void>(() => order.push('first-refresh')), isVisibleForAtlasRecovery: () => true } const second = { resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-reset')), clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-clear')), - presentForcedViewports: vi.fn<() => void>(() => order.push('second-present')), + presentViewports: vi.fn<() => void>(() => order.push('second-present')), refreshAllPanes: vi.fn<() => void>(() => order.push('second-refresh')), isVisibleForAtlasRecovery: () => true } @@ -93,7 +93,7 @@ describe('pane manager registry', () => { const visible = { resetWebglTextureAtlases: vi.fn<() => void>(), clearWebglTextureAtlases: vi.fn<() => void>(), - presentForcedViewports: vi.fn<() => void>(), + presentViewports: vi.fn<() => void>(), refreshAllPanes: vi.fn<() => void>(), isVisibleForAtlasRecovery: () => true } @@ -102,7 +102,7 @@ describe('pane manager registry', () => { const hidden = Array.from({ length: 64 }, () => ({ resetWebglTextureAtlases: vi.fn<() => void>(), clearWebglTextureAtlases: vi.fn<() => void>(), - presentForcedViewports: vi.fn<() => void>(), + presentViewports: vi.fn<() => void>(), refreshAllPanes: vi.fn<() => void>(), isVisibleForAtlasRecovery: () => false })) @@ -114,13 +114,11 @@ describe('pane manager registry', () => { resetAndRefreshAllTerminalWebglAtlases() expect(visible.clearWebglTextureAtlases).toHaveBeenCalledOnce() - expect(visible.presentForcedViewports).toHaveBeenCalledOnce() + expect(visible.presentViewports).toHaveBeenCalledOnce() expect( hidden.every((manager) => manager.clearWebglTextureAtlases.mock.calls.length === 0) ).toBe(true) - expect(hidden.every((manager) => manager.presentForcedViewports.mock.calls.length === 0)).toBe( - true - ) + expect(hidden.every((manager) => manager.presentViewports.mock.calls.length === 0)).toBe(true) }) it('continues reset-and-refresh recovery when one manager throws', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts index 56f79c961bd..4312cacabc7 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -5,7 +5,7 @@ import type { PaneRenderingDiagnostics } from './pane-manager-types' type RegisteredPaneManager = { resetWebglTextureAtlases(): void clearWebglTextureAtlases?: () => void - presentForcedViewports?: () => void + presentViewports?: () => void fitAllPanes?: () => void refreshAllPanes?: () => void getRenderingDiagnostics?: () => PaneRenderingDiagnostics[] @@ -60,8 +60,8 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void { } for (const manager of resetManagers) { try { - if (manager.presentForcedViewports) { - manager.presentForcedViewports() + if (manager.presentViewports) { + manager.presentViewports() } else { manager.refreshAllPanes?.() } diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index e1febd2d0df..975dc746dbb 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -291,7 +291,7 @@ export class PaneManager { clearPaneWebglTextureAtlases(this.panes.values()) } - presentForcedViewports(): void { + presentViewports(): void { presentPaneViewports(this.panes.values()) } diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts index 7a566d3af93..9d099ce0792 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -1,6 +1,6 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { reattachWebglIfNeeded } from './pane-webgl-reattach' -import { presentPaneViewportPreservingSynchronizedOutput } from './pane-webgl-renderer' +import { presentPaneViewport } from './pane-webgl-renderer' import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry' type PaneGetter = () => Iterable @@ -132,6 +132,6 @@ export function schedulePaneRevealPresent( ): void { forEachPaneOnSettledFrame(resolvePaneGetter(ownerOrGetter, panes), (pane) => { reattachWebglIfNeeded(pane) - presentPaneViewportPreservingSynchronizedOutput(pane) + presentPaneViewport(pane) }) } diff --git a/src/renderer/src/lib/pane-manager/pane-viewport-present-lifetime.test.ts b/src/renderer/src/lib/pane-manager/pane-viewport-present-lifetime.test.ts index ef773302415..6c38720d2cd 100644 --- a/src/renderer/src/lib/pane-manager/pane-viewport-present-lifetime.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-viewport-present-lifetime.test.ts @@ -10,18 +10,10 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' import { toPublicPane } from './pane-public-view' -import { - cancelPendingTerminalViewportPresents, - presentPaneViewport, - presentPaneViewportPreservingSynchronizedOutput -} from './pane-viewport-present' -import { - forceFullViewportPresent, - requestFullViewportPresent -} from './terminal-render-pause-release' +import { cancelPendingTerminalViewportPresents, presentPaneViewport } from './pane-viewport-present' +import { requestFullViewportPresent } from './terminal-render-pause-release' vi.mock('./terminal-render-pause-release', () => ({ - forceFullViewportPresent: vi.fn(() => false), requestFullViewportPresent: vi.fn(() => false) })) @@ -103,25 +95,23 @@ describe('pane viewport retry lifetime', () => { vi.clearAllMocks() }) - for (const present of [presentPaneViewport, presentPaneViewportPreservingSynchronizedOutput]) { - for (const elapsed of [0, 3]) { - it(`cancels every public-wrapper retry after ${elapsed} ticks`, () => { - const pane = createPane() - present(toPublicPane(pane)) - present(toPublicPane(pane)) - for (let tick = 0; tick < elapsed; tick++) { - flushFrame() - } - expect(frames.size).toBe(2) - const lateCallbacks = [...frames.values()] - disposePane(pane, new Map([[pane.id, pane]])) - expect(frames.size).toBe(0) - for (const callback of lateCallbacks) { - callback(16) - } - expect(frames.size).toBe(0) - }) - } + for (const elapsed of [0, 3]) { + it(`cancels every public-wrapper retry after ${elapsed} ticks`, () => { + const pane = createPane() + presentPaneViewport(toPublicPane(pane)) + presentPaneViewport(toPublicPane(pane)) + for (let tick = 0; tick < elapsed; tick++) { + flushFrame() + } + expect(frames.size).toBe(2) + const lateCallbacks = [...frames.values()] + disposePane(pane, new Map([[pane.id, pane]])) + expect(frames.size).toBe(0) + for (const callback of lateCallbacks) { + callback(16) + } + expect(frames.size).toBe(0) + }) } it('releases real disposed xterm buffers before paused frames resume', async () => { @@ -131,7 +121,7 @@ describe('pane viewport retry lifetime', () => { pane.terminal.write('retained history\r\n'.repeat(500), resolve) ) presentPaneViewport(toPublicPane(pane)) - presentPaneViewportPreservingSynchronizedOutput(toPublicPane(pane)) + presentPaneViewport(toPublicPane(pane)) flushFrame() disposePane(pane, new Map([[pane.id, pane]])) pane.container.remove() @@ -153,25 +143,24 @@ describe('pane viewport retry lifetime', () => { const retired = createPane() const live = createPane() presentPaneViewport(toPublicPane(retired)) - presentPaneViewportPreservingSynchronizedOutput(toPublicPane(live)) + presentPaneViewport(toPublicPane(live)) disposePane(retired, new Map([[retired.id, retired]])) expect(frames.size).toBe(1) live.container.style.display = 'block' flushFrame() + expect(requestFullViewportPresent).toHaveBeenCalledOnce() expect(requestFullViewportPresent).toHaveBeenCalledWith(live.terminal) - expect(forceFullViewportPresent).not.toHaveBeenCalled() }) - it('deduplicates the same wrapper and upgrades its force mode', () => { + it('deduplicates retries for the same wrapper', () => { const pane = createPane() const view = toPublicPane(pane) - presentPaneViewportPreservingSynchronizedOutput(view) + presentPaneViewport(view) presentPaneViewport(view) expect(frames.size).toBe(1) pane.container.style.display = 'block' flushFrame() - expect(forceFullViewportPresent).toHaveBeenCalledOnce() - expect(requestFullViewportPresent).not.toHaveBeenCalled() + expect(requestFullViewportPresent).toHaveBeenCalledOnce() }) it('preserves the sixteen-frame budget for a live collapsed pane', () => { @@ -185,13 +174,13 @@ describe('pane viewport retry lifetime', () => { expect(frames.size).toBe(0) pane.container.style.display = 'block' presentPaneViewport(toPublicPane(pane)) - expect(forceFullViewportPresent).toHaveBeenCalledOnce() + expect(requestFullViewportPresent).toHaveBeenCalledOnce() }) it('keeps immediate displayed presentation and the no-frame fallback', () => { const pane = createPane() pane.container.style.display = 'block' - presentPaneViewportPreservingSynchronizedOutput(toPublicPane(pane)) + presentPaneViewport(toPublicPane(pane)) expect(requestFullViewportPresent).toHaveBeenCalledOnce() expect(frames.size).toBe(0) pane.container.style.display = 'none' diff --git a/src/renderer/src/lib/pane-manager/pane-viewport-present-synchronized-output.test.ts b/src/renderer/src/lib/pane-manager/pane-viewport-present-synchronized-output.test.ts new file mode 100644 index 00000000000..da270ef83f9 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-viewport-present-synchronized-output.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { SearchAddon } from '@xterm/addon-search' +import { SerializeAddon } from '@xterm/addon-serialize' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import type { ManagedPaneInternal } from './pane-manager-types' +import { presentPaneViewport } from './pane-viewport-present' + +const SYNC_START = '\x1b[?2026h' +const SYNC_END = '\x1b[?2026l' +const ERASE_LINE = '\x1b[K' +// xterm's SynchronizedOutputHandler safety timeout. +const XTERM_SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1_000 + +// Codex 0.158 wraps each redraw in DEC 2026 and erases rows before repainting them. +function firstCodexFrameWithErase(): { erased: string; rest: string } { + const transcript = readFileSync( + join(__dirname, '../../../../main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt'), + 'utf8' + ) + for (const block of transcript.split(SYNC_START).slice(1)) { + const body = block.split(SYNC_END)[0] + const erase = body.indexOf(ERASE_LINE) + if (erase > 0 && !body.includes('\x1b[?1049h')) { + const cut = erase + ERASE_LINE.length + return { erased: body.slice(0, cut), rest: body.slice(cut) } + } + } + throw new Error('fixture has no erasing synchronized frame') +} + +// xterm parses writes on a timer; fake timers must be stepped for the callback to fire. +async function write(terminal: Terminal, data: string): Promise { + let parsed = false + terminal.write(data, () => { + parsed = true + }) + await vi.advanceTimersByTimeAsync(0) + expect(parsed).toBe(true) +} + +function createPane(terminal: Terminal, container: HTMLElement): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' + if (!isTerminalLeafId(leafId)) { + throw new Error('Expected a valid fixture leaf id') + } + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal, + container, + xtermContainer: container, + linkTooltip: document.createElement('div'), + fitAddon: new FitAddon(), + searchAddon: new SearchAddon(), + serializeAddon: new SerializeAddon(), + unicode11Addon: new Unicode11Addon(), + webLinksAddon: new WebLinksAddon(), + terminalGpuAcceleration: 'off', + gpuRenderingEnabled: false, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + imageAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +const COHERENT_TEXT = 'COHERENT FRAME' + +describe('presentPaneViewport during synchronized output', () => { + const originalGetContext = Object.getOwnPropertyDescriptor( + HTMLCanvasElement.prototype, + 'getContext' + ) + let terminal: Terminal + let pane: ManagedPaneInternal + // What the DOM renderer has put on screen, not the parsed buffer. + const screenText = (): string => pane.container.querySelector('.xterm-rows')?.textContent ?? '' + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'requestAnimationFrame'] }) + // Why: happy-dom has no canvas text metrics; xterm measures glyphs on open(). + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: () => ({ measureText: () => ({ width: 10 }) }) + }) + const container = document.createElement('div') + document.body.append(container) + terminal = new Terminal({ cols: 120, rows: 40, allowProposedApi: true }) + terminal.open(container) + pane = createPane(terminal, container) + await write(terminal, `\x1b[5;2H${COHERENT_TEXT}`) + await vi.advanceTimersByTimeAsync(50) + expect(screenText()).toContain(COHERENT_TEXT) + }) + + afterEach(() => { + terminal.dispose() + document.body.replaceChildren() + vi.useRealTimers() + vi.restoreAllMocks() + if (originalGetContext) { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', originalGetContext) + } + }) + + it('keeps the last coherent frame until the agent closes its redraw', async () => { + const frame = firstCodexFrameWithErase() + await write(terminal, SYNC_START + frame.erased) + + presentPaneViewport(pane) + await vi.advanceTimersByTimeAsync(50) + expect(screenText()).toContain(COHERENT_TEXT) + + await write(terminal, frame.rest + SYNC_END) + await vi.advanceTimersByTimeAsync(50) + expect(screenText()).not.toContain(COHERENT_TEXT) + }) + + it('still paints a frame the agent never closes once xterm times out', async () => { + const frame = firstCodexFrameWithErase() + await write(terminal, SYNC_START + frame.erased) + + presentPaneViewport(pane) + await vi.advanceTimersByTimeAsync(XTERM_SYNCHRONIZED_OUTPUT_TIMEOUT_MS - 1) + expect(screenText()).toContain(COHERENT_TEXT) + await vi.advanceTimersByTimeAsync(50) + expect(screenText()).not.toContain(COHERENT_TEXT) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-viewport-present.ts b/src/renderer/src/lib/pane-manager/pane-viewport-present.ts index b5779011f67..a71ae8f5ec8 100644 --- a/src/renderer/src/lib/pane-manager/pane-viewport-present.ts +++ b/src/renderer/src/lib/pane-manager/pane-viewport-present.ts @@ -1,17 +1,13 @@ import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' import { isManagedPaneDisplayNone } from './pane-display-visibility' -import { - forceFullViewportPresent, - requestFullViewportPresent -} from './terminal-render-pause-release' +import { requestFullViewportPresent } from './terminal-render-pause-release' // Presenting a pane's viewport is a distinct concern from owning the WebGL // addon's lifecycle: it runs for DOM-rendered panes too, and its retry loop is // about DOM visibility, not about the renderer. const DISPLAYED_PRESENT_RETRY_FRAMES = 16 -type ViewportPresentMode = 'preserve-synchronized-output' | 'force-current-buffer' -type DisplayedPresentRetry = { frames: number; mode: ViewportPresentMode } +type DisplayedPresentRetry = { frames: number } const pendingDisplayedPresentRetries = new WeakMap() // Public pane views are recreated; terminal teardown must cancel every view's retry. const presentRetryCancellations = new WeakMap void>>() @@ -22,21 +18,14 @@ export function cancelPendingTerminalViewportPresents(terminal: ManagedPane['ter } } -function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportPresentMode): void { +function schedulePresentWhenDisplayed(pane: ManagedPaneInternal): void { if (typeof globalThis.requestAnimationFrame !== 'function') { return } - const pending = pendingDisplayedPresentRetries.get(pane) - if (pending) { - if (mode === 'force-current-buffer') { - pending.mode = mode - } + if (pendingDisplayedPresentRetries.has(pane)) { return } - pendingDisplayedPresentRetries.set(pane, { - frames: DISPLAYED_PRESENT_RETRY_FRAMES, - mode - }) + pendingDisplayedPresentRetries.set(pane, { frames: DISPLAYED_PRESENT_RETRY_FRAMES }) const terminal = pane.terminal const cancellations = presentRetryCancellations.get(terminal) ?? new Set<() => void>() presentRetryCancellations.set(terminal, cancellations) @@ -78,12 +67,20 @@ function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportP return } finish() - presentPaneViewportWithMode(pane, retry.mode) + presentPaneViewport(pane) } frameId = globalThis.requestAnimationFrame(tick) } -function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMode): void { +/** + * Presents the full viewport, keeping the last coherent canvas while a TUI holds + * DEC 2026 synchronized output. + * + * Why one mode: a forced paint mid-frame publishes the TUI's erase-then-repaint + * half frame (a blank flash; #32198). Every reveal and atlas recovery waits for + * the frame instead, bounded by xterm's synchronized-output safety timeout. + */ +export function presentPaneViewport(pane: ManagedPane): void { const internal = pane as ManagedPaneInternal if (internal.webglDisabledAfterContextLoss) { return @@ -112,14 +109,10 @@ function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMod // latches _needsFullRefresh; if IntersectionObserver never fires, the // canvas keeps pre-hide pixels until a user resize. Retry once the box // exists so the full present actually runs. - schedulePresentWhenDisplayed(internal, mode) + schedulePresentWhenDisplayed(internal) return } - const presented = - mode === 'force-current-buffer' - ? forceFullViewportPresent(pane.terminal) - : requestFullViewportPresent(pane.terminal) - if (!presented) { + if (!requestFullViewportPresent(pane.terminal)) { // Why: refresh even without a WebGL addon so recovery never silently // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1) @@ -128,11 +121,3 @@ function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMod /* ignore — pane may have been disposed in the meantime */ } } - -export function presentPaneViewport(pane: ManagedPane): void { - presentPaneViewportWithMode(pane, 'force-current-buffer') -} - -export function presentPaneViewportPreservingSynchronizedOutput(pane: ManagedPane): void { - presentPaneViewportWithMode(pane, 'preserve-synchronized-output') -} diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index bf8f5f09ea7..89e167aa058 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -5,7 +5,6 @@ import { attachWebgl, clearTerminalWebglAttachBackoff, presentPaneViewport, - presentPaneViewportPreservingSynchronizedOutput, primeTerminalWebglAddon, resetTerminalWebglSuggestion, resetWebglTextureAtlas @@ -183,7 +182,7 @@ describe('terminal WebGL addon lifecycle', () => { it('falls back to terminal.refresh on an unpaused, unsynchronized pane', () => { // Restore-after-replay and other callers use presentPaneViewport even when - // forceFullViewportPresent is a no-op (not paused, no DEC 2026). + // requestFullViewportPresent is a no-op (not paused, no DEC 2026). const refreshRows = vi.fn() const renderRows = vi.fn() const pane = createPane() @@ -208,7 +207,7 @@ describe('terminal WebGL addon lifecycle', () => { expect(renderRows).not.toHaveBeenCalled() }) - it('preserves a synchronized frame on an ordinary reveal present', () => { + it('preserves a synchronized frame on a viewport present', () => { const refreshRows = vi.fn() const renderRows = vi.fn() const pane = createPane() @@ -226,7 +225,7 @@ describe('terminal WebGL addon lifecycle', () => { } } as never - presentPaneViewportPreservingSynchronizedOutput(pane) + presentPaneViewport(pane) expect(refreshRows).toHaveBeenCalledWith(0, 23, true) expect(renderRows).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 23cf486cb6d..a60d3866492 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -12,10 +12,7 @@ import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair' import { recordPaneWebglContextLoss } from './pane-webgl-context-loss-policy' import { presentPaneViewport } from './pane-viewport-present' -export { - presentPaneViewport, - presentPaneViewportPreservingSynchronizedOutput -} from './pane-viewport-present' +export { presentPaneViewport } from './pane-viewport-present' import { getTerminalWebglAddonConstructor, primeTerminalWebglAddon, diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts index 31e5c4ff23b..cdb1b2487e7 100644 --- a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts @@ -157,6 +157,10 @@ export function isSynchronizedOutputHeld(terminal: unknown): boolean { * One synchronous full-viewport present when xterm would otherwise swallow it: * IntersectionObserver pause, or DEC 2026 synchronized output. * + * Why so few callers: painting mid DEC 2026 publishes a half-built TUI frame + * (#32198). Only a keystroke echo inside a held frame and the replay hold cap + * may do that; reveals and recovery use requestFullViewportPresent. + * * Why not on every paint: a forced renderer.renderRows on a fresh, unpaused * splash paints before cell metrics settle and leaves a 1px black gutter under * a TUI composer — production never does that. Callers fall back to diff --git a/src/renderer/src/lib/pane-manager/terminal-replay-presentation.ts b/src/renderer/src/lib/pane-manager/terminal-replay-presentation.ts index 44662fb0676..957d0c01cc9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-replay-presentation.ts +++ b/src/renderer/src/lib/pane-manager/terminal-replay-presentation.ts @@ -1,7 +1,8 @@ import { forceFullViewportPresent, forceRepaintThroughRenderPause, - isSynchronizedOutputHeld + isSynchronizedOutputHeld, + requestFullViewportPresent } from './terminal-render-pause-release' import { retainTerminalReplayFrame } from './terminal-replay-frame' @@ -23,6 +24,7 @@ type ReplayPresentation = { generation: number present: boolean timeout: ReturnType + frameWait: number | null } const presentations = new WeakMap() @@ -54,15 +56,23 @@ export function beginTerminalReplayPresentation( terminal.refresh?.(0, terminal.rows - 1) } } - const unhide = (held: ReplayPresentation): void => { + const unhide = (held: ReplayPresentation, atHoldCap = false): void => { clearTimeout(held.timeout) + if (held.frameWait !== null) { + cancelAnimationFrame(held.frameWait) + held.frameWait = null + } if (presentations.get(screen) === held) { presentations.delete(screen) } try { const bounds = element.getBoundingClientRect() if (held.present && bounds.width > 0 && bounds.height > 0 && terminal.rows) { - if (!forceFullViewportPresent(terminal)) { + // Only the hold cap may publish an unfinished DEC 2026 frame. + const presented = atHoldCap + ? forceFullViewportPresent(terminal) + : requestFullViewportPresent(terminal) + if (!presented) { refresh() } } @@ -77,6 +87,28 @@ export function beginTerminalReplayPresentation( held.frame?.remove() } } + // Why: unhiding mid-DEC 2026 publishes the TUI's half-built frame over the + // retained copy; the hold cap still unhides a frame that never closes. + const unhideWhenFrameComplete = (held: ReplayPresentation): void => { + if (held.frameWait !== null) { + return + } + if ( + held.present && + held.frame && + isSynchronizedOutputHeld(terminal) && + typeof requestAnimationFrame === 'function' + ) { + held.frameWait = requestAnimationFrame(() => { + held.frameWait = null + if (held.pending === 0 && presentations.get(screen) === held) { + unhideWhenFrameComplete(held) + } + }) + return + } + unhide(held) + } let presentation = presentations.get(screen) if (!presentation) { const capture = retainTerminalReplayFrame(screen, () => { @@ -100,9 +132,10 @@ export function beginTerminalReplayPresentation( pending: 0, generation: 0, present: false, + frameWait: null, timeout: setTimeout(() => { created.present = true - unhide(created) + unhide(created, true) }, TERMINAL_REPLAY_PRESENTATION_MAX_HOLD_MS) } presentation = created @@ -127,6 +160,6 @@ export function beginTerminalReplayPresentation( if (--held.pending > 0 || presentations.get(screen) !== held) { return } - unhide(held) + unhideWhenFrameComplete(held) } } diff --git a/src/renderer/src/lib/pane-manager/terminal-structural-replay-presentation.test.ts b/src/renderer/src/lib/pane-manager/terminal-structural-replay-presentation.test.ts index ca04f7bfeac..9fc174714f2 100644 --- a/src/renderer/src/lib/pane-manager/terminal-structural-replay-presentation.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-structural-replay-presentation.test.ts @@ -328,25 +328,64 @@ describe('structural replay presentation', () => { expect(getContext).not.toHaveBeenCalled() }) - it('still retains a DOM frame during synchronized output without repainting it', async () => { - const { terminal, screen } = createTerminal() - const refreshRows = vi.fn() - const synchronized = { - ...terminal, - _core: { - ...terminal._core, - coreService: { decPrivateModes: { synchronizedOutput: true } }, - _renderService: { _isPaused: false, refreshRows } + it('keeps the retained frame up until an open synchronized frame closes', async () => { + vi.useFakeTimers() + try { + const { terminal, screen } = createTerminal() + const refreshRows = vi.fn() + const renderRows = vi.fn() + const decPrivateModes = { synchronizedOutput: true } + const synchronized = { + ...terminal, + _core: { + ...terminal._core, + coreService: { decPrivateModes }, + _renderService: { _isPaused: false, refreshRows, _renderer: { value: { renderRows } } } + } } - } - await createTerminalStructuralReplayCoordinator(synchronized).run(() => { + await createTerminalStructuralReplayCoordinator(synchronized).run(() => { + expect(screen.style.opacity).toBe('0') + expect(refreshRows).not.toHaveBeenCalled() + }) + await vi.advanceTimersByTimeAsync(100) expect(screen.style.opacity).toBe('0') expect(terminal.element.querySelector('[data-terminal-replay-frame]')?.textContent).toBe( 'Last coherent frame' ) - expect(refreshRows).not.toHaveBeenCalled() - }) - expect(screen.style.opacity).toBe('') + expect(renderRows).not.toHaveBeenCalled() + + decPrivateModes.synchronizedOutput = false + await vi.advanceTimersByTimeAsync(20) + expect(screen.style.opacity).toBe('') + expect(terminal.element.querySelector('[data-terminal-replay-frame]')).toBeNull() + expect(renderRows).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('shows the screen at the hold cap when a synchronized frame never closes', async () => { + vi.useFakeTimers() + try { + const { terminal, screen } = createTerminal() + const synchronized = { + ...terminal, + _core: { + ...terminal._core, + coreService: { decPrivateModes: { synchronizedOutput: true } }, + _renderService: { _isPaused: false, refreshRows: vi.fn() } + } + } + await createTerminalStructuralReplayCoordinator(synchronized).run(() => {}) + await vi.advanceTimersByTimeAsync(TERMINAL_REPLAY_PRESENTATION_MAX_HOLD_MS - 1) + expect(screen.style.opacity).toBe('0') + await vi.advanceTimersByTimeAsync(1) + expect(screen.style.opacity).toBe('') + expect(terminal.element.querySelector('[data-terminal-replay-frame]')).toBeNull() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } }) it('keeps decorations positioned in the retained frame', async () => { diff --git a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts index 9efabdea164..5fc0e233693 100644 --- a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts +++ b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts @@ -425,7 +425,7 @@ test.describe('terminal reveal paused-render recovery', () => { ).toBe(true) }) - test('@headful atlas recovery presents a synchronized-output WebGL frame', async ({ + test('@headful atlas recovery keeps the coherent frame until synchronized output releases', async ({ orcaPage }, testInfo) => { await waitForSessionReady(orcaPage) @@ -455,6 +455,7 @@ test.describe('terminal reveal paused-render recovery', () => { background: 17, release: true }) + const baseline = await captureStableTabScreenshot(orcaPage, tabId) await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { marker: 'REVEALED_FRAME', background: 52, @@ -470,36 +471,45 @@ test.describe('terminal reveal paused-render recovery', () => { await expect .poll(async () => (await readSynchronizedRevealProbe(orcaPage)).atlasClears) .toBeGreaterThan(0) - const afterReveal = await captureStableTabScreenshot(orcaPage, tabId) + // Why not a stable capture: the atlas clear's redraw starts xterm's 1s DEC 2026 safety timeout. + const afterReveal = await captureFirstRevealedFrame(orcaPage, tabId) + const heldDiff = compareTerminalScreenshots(baseline, afterReveal) + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'REVEALED_FRAME', + background: 52, + release: true + }) + const released = await captureStableTabScreenshot(orcaPage, tabId) await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent') - const afterForcedPresent = await captureStableTabScreenshot(orcaPage, tabId) - const diff = compareTerminalScreenshots(afterReveal, afterForcedPresent) + const direct = await captureStableTabScreenshot(orcaPage, tabId) + const releasedDiff = compareTerminalScreenshots(released, direct) const afterRevealPath = testInfo.outputPath('synchronized-frame-after-reveal.png') - const afterForcedPresentPath = testInfo.outputPath( - 'synchronized-frame-after-forced-present.png' - ) + const releasedPath = testInfo.outputPath('synchronized-frame-after-release.png') const pixelDiffPath = testInfo.outputPath('synchronized-frame-pixel-diff.json') await Promise.all([ writeFile(afterRevealPath, afterReveal), - writeFile(afterForcedPresentPath, afterForcedPresent), - writeFile(pixelDiffPath, JSON.stringify(diff, null, 2)) + writeFile(releasedPath, released), + writeFile(pixelDiffPath, JSON.stringify({ heldDiff, releasedDiff }, null, 2)) ]) - await testInfo.attach('synchronized-frame-after-reveal.png', { - path: afterRevealPath, - contentType: 'image/png' - }) - await testInfo.attach('synchronized-frame-after-forced-present.png', { - path: afterForcedPresentPath, - contentType: 'image/png' - }) - await testInfo.attach('synchronized-frame-pixel-diff.json', { - path: pixelDiffPath, - contentType: 'application/json' - }) + for (const [name, path, contentType] of [ + ['synchronized-frame-after-reveal.png', afterRevealPath, 'image/png'], + ['synchronized-frame-after-release.png', releasedPath, 'image/png'], + ['synchronized-frame-pixel-diff.json', pixelDiffPath, 'application/json'] + ] as const) { + await testInfo.attach(name, { path, contentType }) + } expect( - diff.matches, - `revealed pixels already match a forced buffer present (diffRatio=${diff.diffRatio})` + heldDiff.matches, + `atlas recovery keeps the coherent frame while DEC 2026 is held (diffRatio=${heldDiff.diffRatio})` + ).toBe(true) + expect( + compareTerminalScreenshots(baseline, released).matches, + 'release replaces the coherent frame' + ).toBe(false) + expect( + releasedDiff.matches, + `release presents the completed frame (diffRatio=${releasedDiff.diffRatio})` ).toBe(true) })