diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index f4237e507..797719697 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -440,7 +440,8 @@ describe('restoreScrollbackBuffers', () => { } } const manager = { - getPanes: vi.fn(() => [pane]) + getPanes: vi.fn(() => [pane]), + hasWebglRenderer: vi.fn(() => true) } const replayingPanesRef = { current: new Map() } const restoredViewportBlankingPanesRef = { current: new Set() } @@ -454,6 +455,7 @@ describe('restoreScrollbackBuffers', () => { ) expect(writes).toEqual(['restored output', '\r\n', POST_REPLAY_MODE_RESET]) + expect(manager.hasWebglRenderer).toHaveBeenCalledWith(1) expect(restoredViewportBlankingPanesRef.current.has(1)).toBe(true) expect(replayingPanesRef.current.size).toBe(0) }) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index d47ced05b..5f581cb9e 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -265,6 +265,9 @@ export function restoreScrollbackBuffers( continue } try { + const renderOptions = { + shouldRefreshViewportSynchronously: () => !manager.hasWebglRenderer(pane.id) + } let buf = buffer // If buffer ends in alt-screen mode (agent TUI was running at // shutdown), exit alt-screen so the user sees a usable terminal. @@ -278,14 +281,14 @@ export function restoreScrollbackBuffers( // sequences from the prior session (DA1, DECRQM, OSC 10/11, focus, // CPR). Writing those through xterm.write would trigger auto-replies // that land in the new shell's stdin. See replay-guard.ts. - replayIntoTerminal(pane, replayingPanesRef, buf) + replayIntoTerminal(pane, replayingPanesRef, buf, renderOptions) // Ensure cursor is on a new line so the new shell prompt // doesn't trigger zsh's PROMPT_EOL_MARK (%) indicator. - replayIntoTerminal(pane, replayingPanesRef, '\r\n') + replayIntoTerminal(pane, replayingPanesRef, '\r\n', renderOptions) // Clear any mode bits the serialized buffer replayed into xterm. // The shell underneath is fresh and has no TUI consuming these modes. // See POST_REPLAY_MODE_RESET comment. - replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET) + replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET, renderOptions) // Why: connection resolution happens after layout replay; only the // fresh-shell paths should move these visible rows into scrollback. restoredViewportBlankingPanesRef?.current.add(pane.id) 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 52457e170..fe1b2e51c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -467,6 +467,7 @@ function createManager(paneCount = 1, initialActivePaneId: number | null = null) setPaneGpuRendering: vi.fn(), markPaneHasComplexScriptOutput: vi.fn(), rebuildPaneWebgl: vi.fn(), + hasWebglRenderer: vi.fn(() => false), getPanes: vi.fn(() => panes), closePane: vi.fn(), getActivePane: vi.fn<() => { id: number; leafId?: string } | null>(() => @@ -12635,6 +12636,45 @@ describe('connectPanePty', () => { } }) + it('coalesces forced foreground refreshes when WebGL is live', 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) + const synchronousRefresh = vi.fn() + const debouncedRefresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof synchronousRefresh } + refresh: typeof debouncedRefresh + } + terminal._core = { refresh: synchronousRefresh } + terminal.refresh = debouncedRefresh + terminal.write = vi.fn((_data: string, callback?: () => void) => callback?.()) + const manager = createManager(1) + manager.hasWebglRenderer.mockReturnValue(true) + + connectPanePty(pane as never, manager as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\r\x1b[3Gzzzx\x1b[K') + + expect(debouncedRefresh).toHaveBeenCalledWith(0, 39) + expect(synchronousRefresh).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + it('drains a post-submit synchronized frame on the fast path when its end marker arrives late', async () => { // Why (STA-1041): OpenCode wraps each submit repaint in a DEC 2026 frame. // Under CPU contention ConPTY splits the closing chunk past the 150ms redraw diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 19046aa00..f93bba900 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -103,6 +103,7 @@ import { resolveWindowsShellOverride } from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' +import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' import { captureTerminalWriteScrollIntent, enforceTerminalWriteScrollIntent @@ -935,6 +936,7 @@ export function connectPanePty( manager: PaneManager, deps: PtyConnectionDeps ): PanePtyBinding { + const shouldRefreshForegroundSynchronously = (): boolean => !manager.hasWebglRenderer(pane.id) exposeE2eTerminalPtyOutputDebug() let disposed = false let connectFrame: number | null = null @@ -2278,7 +2280,9 @@ export function connectPanePty( // frame still has mouse-tracking/bracketed-paste armed, which silently // eats every click and keystroke against a dead transport — disarm the // modes now and arm the reveal-time wake. - replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET) + replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET, { + shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously + }) hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record } const pendingWakeMatches = pendingHibernatedWakeTarget?.ptyId === ptyId && @@ -4300,14 +4304,18 @@ export function connectPanePty( // Why: drain any queued background bytes BEFORE the replay paint, so the // scheduler's deferred drain cannot land older bytes on top of the replay. flushTerminalOutput(pane.terminal) - replayIntoTerminal(pane, deps.replayingPanesRef, data) + replayIntoTerminal(pane, deps.replayingPanesRef, data, { + shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously + }) } const writeReplayDataAsync = (data: string): Promise => { // Why: WebGL must be rebuilt after xterm has parsed replay bytes, not // merely after the write was queued. flushTerminalOutput(pane.terminal) - return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data) + return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data, { + shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously + }) } const reattachReplayResetSequence = (payload: string): string => { @@ -4742,7 +4750,9 @@ export function connectPanePty( modelRestoreSubscribedPtyId = null } - function beforeTerminalOutputWrite(): void { + function beforeTerminalOutputWrite(data: string): void { + // Why: shaping must register before xterm parses the RTL bytes that need it. + ensureArabicShapingJoinerForText(pane.terminal, data) recordTerminalOutput(pane.terminal) } @@ -5004,6 +5014,9 @@ export function connectPanePty( foregroundRenderRefreshNeeded), followupForegroundRefresh: nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, + // Why: xterm already queued a WebGL frame while parsing this chunk; + // merge the repair into it instead of rendering the full grid twice. + shouldRefreshForegroundSynchronously, onParsed: onParsedAtlasRecovery, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index c26acbfca..e69194db7 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -6,6 +6,7 @@ import { replayIntoTerminalAsync, type ReplayingPanesRef } from './replay-guard' +import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' const mocks = vi.hoisted(() => ({ recordRendererCrashBreadcrumb: vi.fn() @@ -41,6 +42,7 @@ type FakeTerminal = { _core: { refresh: (start: number, end: number, sync?: boolean) => void } + refresh: (start: number, end: number) => void /** Flush all pending xterm write callbacks, simulating parse completion. */ flush: () => void } @@ -60,6 +62,7 @@ function makeFakePane(paneId: number): { pane: ManagedPane; terminal: FakeTermin _core: { refresh() {} }, + refresh() {}, write(data: string, cb?: () => void) { terminal.lastData.push(data) if (cb) { @@ -83,6 +86,53 @@ describe('replay-guard', () => { expect(isPaneReplaying(ref, 1)).toBe(false) }) + it('registers Arabic shaping before replay bytes are written', () => { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + const events: string[] = [] + const joinerTerminal = terminal as FakeTerminal & { + registerCharacterJoiner: (handler: (text: string) => [number, number][]) => number + deregisterCharacterJoiner: (joinerId: number) => void + } + joinerTerminal.registerCharacterJoiner = () => { + events.push('register') + return 5 + } + joinerTerminal.deregisterCharacterJoiner = () => undefined + terminal.write = (data: string, callback?: () => void) => { + events.push(`write:${data}`) + if (callback) { + terminal.pendingCallbacks.push(callback) + } + } + const cleanup = configureLazyArabicShapingJoiner(joinerTerminal as never, () => true) + + replayIntoTerminal(pane, ref, 'مرحبا') + + expect(events).toEqual(['register', 'write:مرحبا']) + cleanup() + }) + + it('still replays RTL bytes when joiner registration fails', () => { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + const joinerTerminal = terminal as FakeTerminal & { + registerCharacterJoiner: () => number + deregisterCharacterJoiner: () => void + } + joinerTerminal.registerCharacterJoiner = () => { + throw new Error('terminal disposed') + } + joinerTerminal.deregisterCharacterJoiner = () => undefined + configureLazyArabicShapingJoiner(joinerTerminal as never, () => true) + + replayIntoTerminal(pane, ref, 'مرحبا') + + expect(terminal.lastData).toEqual(['مرحبا']) + terminal.flush() + expect(isPaneReplaying(ref, pane.id)).toBe(false) + }) + it('is replaying between write dispatch and xterm parse completion', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) @@ -168,9 +218,9 @@ describe('replay-guard', () => { try { const ref = makeRef() const { pane } = makeFakePane(1) - replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H', 400) - replayIntoTerminal(pane, ref, 'scrollback bytes', 400) - replayIntoTerminal(pane, ref, '--- session restored ---', 400) + replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H', { stallCheckMs: 400 }) + replayIntoTerminal(pane, ref, 'scrollback bytes', { stallCheckMs: 400 }) + replayIntoTerminal(pane, ref, '--- session restored ---', { stallCheckMs: 400 }) expect(isPaneReplaying(ref, 1)).toBe(true) // Never flush — the probe write never parses; the wedged release fires @@ -189,8 +239,8 @@ describe('replay-guard', () => { try { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'a', 400) - replayIntoTerminal(pane, ref, 'b', 400) + replayIntoTerminal(pane, ref, 'a', { stallCheckMs: 400 }) + replayIntoTerminal(pane, ref, 'b', { stallCheckMs: 400 }) terminal.flush() expect(isPaneReplaying(ref, 1)).toBe(false) @@ -210,7 +260,7 @@ describe('replay-guard', () => { const ref = makeRef() const { pane } = makeFakePane(1) let resolved = false - const promise = replayIntoTerminalAsync(pane, ref, 'x', 400).then(() => { + const promise = replayIntoTerminalAsync(pane, ref, 'x', { stallCheckMs: 400 }).then(() => { resolved = true }) expect(isPaneReplaying(ref, 1)).toBe(true) @@ -257,6 +307,42 @@ describe('replay-guard', () => { globalThis.cancelAnimationFrame = originalCancelAnimationFrame } }) + + it('coalesces WebGL replay refreshes and rechecks before the follow-up', () => { + const scheduledFrames: FrameRequestCallback[] = [] + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + scheduledFrames.push(callback) + return scheduledFrames.length + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame + + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + const synchronousRefresh = vi.fn() + const debouncedRefresh = vi.fn() + terminal._core.refresh = synchronousRefresh + terminal.refresh = debouncedRefresh + let webglLive = true + + replayIntoTerminal(pane, ref, 'snapshot bytes', { + shouldRefreshViewportSynchronously: () => !webglLive + }) + terminal.flush() + + expect(debouncedRefresh).toHaveBeenCalledWith(0, 23) + expect(synchronousRefresh).not.toHaveBeenCalled() + webglLive = false + scheduledFrames[0]?.(16) + + expect(synchronousRefresh).toHaveBeenCalledWith(0, 23, true) + } finally { + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis.cancelAnimationFrame = originalCancelAnimationFrame + } + }) }) describe('replay-guard stall handling (probe-certified release)', () => { @@ -269,7 +355,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'slow but alive', 1_000) + replayIntoTerminal(pane, ref, 'slow but alive', { stallCheckMs: 1_000 }) expect(isPaneReplaying(ref, 1)).toBe(true) // Stall check fires: an empty probe write is enqueued behind the replay. @@ -298,7 +384,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + replayIntoTerminal(pane, ref, 'restored bytes', { stallCheckMs: 1_000 }) terminal.pendingCallbacks.shift() // xterm lost the replay's completion vi.advanceTimersByTime(1_000) // stall check → probe enqueued @@ -322,7 +408,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + replayIntoTerminal(pane, ref, 'restored bytes', { stallCheckMs: 1_000 }) vi.advanceTimersByTime(1_000) // stall check → probe enqueued expect(isPaneReplaying(ref, 1)).toBe(true) @@ -346,7 +432,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + replayIntoTerminal(pane, ref, 'restored bytes', { stallCheckMs: 1_000 }) terminal.write = () => { throw new Error('terminal disposed') } @@ -368,8 +454,8 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'lost completion', 1_000) - replayIntoTerminal(pane, ref, 'healthy completion', 60_000) + replayIntoTerminal(pane, ref, 'lost completion', { stallCheckMs: 1_000 }) + replayIntoTerminal(pane, ref, 'healthy completion', { stallCheckMs: 60_000 }) terminal.pendingCallbacks.shift() // drop only the first completion vi.advanceTimersByTime(1_000) // first engagement's probe enqueued @@ -389,7 +475,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'healthy', 1_000) + replayIntoTerminal(pane, ref, 'healthy', { stallCheckMs: 1_000 }) terminal.flush() expect(isPaneReplaying(ref, 1)).toBe(false) @@ -405,7 +491,9 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane } = makeFakePane(1) - const replayDone = replayIntoTerminalAsync(pane, ref, 'restored bytes', 1_000) + const replayDone = replayIntoTerminalAsync(pane, ref, 'restored bytes', { + stallCheckMs: 1_000 + }) let resolved = false void replayDone.then(() => { resolved = true diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 6bf821ea6..fa2cc1913 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -1,6 +1,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-foreground-render-settle' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' // Why: xterm.js auto-responds to terminal query sequences (DA1 `CSI c`, // DECRQM `CSI ? Ps $ p`, OSC 10/11 color queries, focus events, CPR) by @@ -52,6 +53,11 @@ export type ReplayingPanesRef = React.RefObject> // While the probe is pending (slow-but-alive replay), the guard HOLDS. const REPLAY_GUARD_STALL_CHECK_MS = 10_000 +type ReplayTerminalOptions = { + shouldRefreshViewportSynchronously?: () => boolean + stallCheckMs?: number +} + export function isPaneReplaying(ref: ReplayingPanesRef, paneId: number): boolean { return (ref.current.get(paneId) ?? 0) > 0 } @@ -130,22 +136,24 @@ export function replayIntoTerminal( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, data: string, - stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS + options: ReplayTerminalOptions = {} ): void { if (!data) { return } + ensureArabicShapingJoinerForText(pane.terminal, data) const releaseParsed = engageReplayGuard( replayingPanesRef.current, pane.id, pane.terminal, - stallCheckMs + options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS ) // Why: hidden/snapshot replay bypasses the live foreground write path, but // WebGL/canvas renderers still need a post-parse repaint to drop stale cells. writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, + shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, onParsed: releaseParsed }) } @@ -154,11 +162,12 @@ export function replayIntoTerminalAsync( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, data: string, - stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS + options: ReplayTerminalOptions = {} ): Promise { if (!data) { return Promise.resolve() } + ensureArabicShapingJoinerForText(pane.terminal, data) return new Promise((resolve) => { // Why resolve on either release path: callers await this to sequence // restore steps; a lost write completion must not hang the restore chain. @@ -166,12 +175,13 @@ export function replayIntoTerminalAsync( replayingPanesRef.current, pane.id, pane.terminal, - stallCheckMs, + options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS, resolve ) writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, + shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, onParsed: releaseParsed }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index c4cb78082..4b0fef324 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -7,6 +7,7 @@ import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' import { attachLigatures, disposePane, openTerminal } from './pane-lifecycle' +import { ensureArabicShapingJoinerForText } from './terminal-arabic-shaping-joiner' import { buildDefaultTerminalOptions, DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, @@ -585,17 +586,17 @@ describe('openTerminal — addon and provider wiring', () => { expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) }) - // Why: terminal.dispose() does not deregister character joiners, so the - // pane lifecycle must — this locks the register/deregister pairing that - // makes Arabic/RTL shaping (#5262) actually reach a real terminal. - it('registers the Arabic shaping joiner on open and deregisters it on dispose', () => { + // Why: ordinary panes must avoid xterm's full-grid character-joiner scan, + // while the first RTL write still registers before xterm parses the text. + it('registers Arabic shaping lazily and deregisters it on dispose', () => { const { pane, events } = createOpenTerminalHarness() openTerminal(pane) - expect(events).toContain('registerCharacterJoiner') - expect(events.indexOf('open')).toBeLessThan(events.indexOf('registerCharacterJoiner')) + expect(events).not.toContain('registerCharacterJoiner') expect(pane.arabicShapingJoinerCleanup).toBeTypeOf('function') + ensureArabicShapingJoinerForText(pane.terminal, 'مرحبا') + expect(events).toContain('registerCharacterJoiner') disposePane(pane, new Map([[pane.id, pane]])) @@ -610,6 +611,7 @@ describe('openTerminal — addon and provider wiring', () => { const { pane, getRegisteredJoinHandler } = createOpenTerminalHarness() openTerminal(pane) + ensureArabicShapingJoinerForText(pane.terminal, 'مرحبا') const handler = getRegisteredJoinHandler()! expect(pane.webglAddon).toBeNull() diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index c984508d4..adc43af01 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -1,10 +1,3 @@ -// Upstream packaging bug: @xterm/addon-ligatures declares `"main": -// "lib/addon-ligatures.js"` but ships only the `.mjs` entry, so Vite fails to -// resolve the bare import. Fixed locally via config/patches/@xterm__addon-ligatures*. -// Tracking upstream: https://github.com/xtermjs/xterm.js/issues/5822 and -// https://github.com/xtermjs/xterm.js/pull/5828 — drop the patch once that lands. -import { LigaturesAddon } from '@xterm/addon-ligatures' - import type { ManagedPaneInternal } from './pane-manager-types' import { safeFit } from './pane-tree-ops' import { @@ -17,7 +10,8 @@ import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' -import { registerArabicShapingJoiner } from './terminal-arabic-shaping-joiner' +import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner' +import { TerminalLigaturesAddon } from './terminal-ligatures-addon' import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' // --------------------------------------------------------------------------- @@ -73,12 +67,10 @@ export function openTerminal(pane: ManagedPaneInternal): void { // so the activation must stay at this position. activateOrcaTerminalUnicodeProvider(terminal) - // Why: without run-joining, Arabic/Hebrew output renders as disconnected - // letters in reversed order (#5262). Registered up front so restored - // scrollback and reattach replays shape correctly, not just live output. - // Joining tracks the live WebGL renderer: the DOM fallback misrenders - // joined spans (see registerArabicShapingJoiner), so it stays per-cell. - pane.arabicShapingJoinerCleanup = registerArabicShapingJoiner( + // Why: any xterm character joiner makes every repaint scan the whole grid. + // Defer registration until the first RTL write; replay and live paths both + // ensure it before parsing, so restored Arabic still shapes immediately. + pane.arabicShapingJoinerCleanup = configureLazyArabicShapingJoiner( terminal, () => pane.webglAddon != null ) @@ -173,7 +165,7 @@ export function attachLigatures(pane: ManagedPaneInternal): void { return } try { - const ligaturesAddon = new LigaturesAddon() + const ligaturesAddon = new TerminalLigaturesAddon() pane.terminal.loadAddon(ligaturesAddon) pane.ligaturesAddon = ligaturesAddon // Why: ligatures can be enabled after rows already rendered, especially diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index f04d5d9c3..ab9ac7575 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -245,6 +245,10 @@ export class PaneManager { })) } + hasWebglRenderer(paneId: number): boolean { + return this.panes.get(paneId)?.webglAddon != null + } + getLeafId(numericPaneId: number): TerminalLeafId | null { return this.identities.getLeafId(numericPaneId) } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index eab1caa49..5319e259a 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -19,6 +19,7 @@ export type ForegroundTerminalOutputTarget = { type ForegroundTerminalWriteOptions = { forceViewportRefresh?: boolean followupViewportRefresh?: boolean + shouldRefreshViewportSynchronously?: () => boolean onParsed?: () => void } @@ -32,7 +33,10 @@ type ViewportSnapshot = { viewportY: number | null } -function refreshVisibleRowsNow(terminal: ForegroundTerminalOutputTarget): void { +function refreshVisibleRows( + terminal: ForegroundTerminalOutputTarget, + synchronously: boolean +): void { if (typeof terminal.rows !== 'number' || terminal.rows < 1) { return } @@ -40,14 +44,17 @@ function refreshVisibleRowsNow(terminal: ForegroundTerminalOutputTarget): void { const start = 0 const end = Math.max(0, terminal.rows - 1) try { - // Why: xterm's DOM renderer batches row paints; Windows ConPTY CR-style - // rewrites can leave stale CJK glyph cells until a resize unless we paint - // the parsed foreground state before Chromium's next frame. - if (typeof terminal._core?.refresh === 'function') { + // Why: DOM-rendered Windows ConPTY rewrites need an immediate repair, while + // WebGL can merge this full-grid request into xterm's already-queued frame. + if (synchronously && typeof terminal._core?.refresh === 'function') { terminal._core.refresh(start, end, true) return } - terminal.refresh?.(start, end) + if (typeof terminal.refresh === 'function') { + terminal.refresh(start, end) + return + } + terminal._core?.refresh?.(start, end, false) } catch { // Ignore disposed terminals; PTY output can race pane teardown. } @@ -90,12 +97,15 @@ function cancelScheduledViewportSettleRefresh(terminal: ForegroundTerminalOutput clearTimeout(pending.id) } -function scheduleViewportSettleRefresh(terminal: ForegroundTerminalOutputTarget): void { +function scheduleViewportSettleRefresh( + terminal: ForegroundTerminalOutputTarget, + shouldRefreshSynchronously?: () => boolean +): void { cancelScheduledViewportSettleRefresh(terminal) if (typeof requestAnimationFrame === 'function') { const id = requestAnimationFrame(() => { pendingViewportSettleRefreshByTerminal.delete(terminal) - refreshVisibleRowsNow(terminal) + refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true) }) pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'raf', id }) return @@ -103,7 +113,7 @@ function scheduleViewportSettleRefresh(terminal: ForegroundTerminalOutputTarget) const id = setTimeout(() => { pendingViewportSettleRefreshByTerminal.delete(terminal) - refreshVisibleRowsNow(terminal) + refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true) }, 16) pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'timeout', id }) } @@ -113,7 +123,7 @@ function settleForegroundRender( beforeWriteViewport: ViewportSnapshot, options: ForegroundTerminalWriteOptions ): void { - refreshVisibleRowsNow(terminal) + refreshVisibleRows(terminal, options.shouldRefreshViewportSynchronously?.() ?? true) // Why: when output advances the viewport, Chromium can paint the freshly // scrolled top row one frame later than xterm finishes parsing. Repaint once // more after the scroll settles so the user doesn't need to jiggle the window. @@ -121,7 +131,7 @@ function settleForegroundRender( options.followupViewportRefresh || viewportChangedDuringWrite(terminal, beforeWriteViewport) ) { - scheduleViewportSettleRefresh(terminal) + scheduleViewportSettleRefresh(terminal, options.shouldRefreshViewportSynchronously) } } 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 f85d9b8c5..363264c9c 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 @@ -300,6 +300,93 @@ describe('pane terminal output scheduler', () => { expect(terminal.refresh).not.toHaveBeenCalled() }) + it('coalesces a WebGL foreground refresh through xterm public refresh', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + + writeTerminalOutput(terminal, 'active TUI repaint\r\n', { + foreground: true, + forceForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => false + }) + + expect(terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + + it('resolves the live renderer after xterm finishes parsing', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + let parseCallback: (() => void) | undefined + let webglLive = false + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + writeTerminalOutput(terminal, 'queued renderer transition\r\n', { + foreground: true, + forceForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => !webglLive + }) + webglLive = true + parseCallback?.() + + expect(terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + + it('keeps the WebGL follow-up repair on the debounced path', async () => { + const scheduledFrames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + scheduledFrames.push(callback) + return scheduledFrames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + + writeTerminalOutput(terminal, 'WebGL cursor restore', { + foreground: true, + forceForegroundRefresh: true, + followupForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => false + }) + + expect(terminal.refresh).toHaveBeenCalledTimes(1) + expect(scheduledFrames).toHaveLength(1) + scheduledFrames[0]?.(16) + + expect(terminal.refresh).toHaveBeenCalledTimes(2) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + + it('resolves WebGL loss again before the follow-up repair', async () => { + const scheduledFrames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + scheduledFrames.push(callback) + return scheduledFrames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + let webglLive = true + + writeTerminalOutput(terminal, 'renderer transition', { + foreground: true, + forceForegroundRefresh: true, + followupForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => !webglLive + }) + + expect(terminal.refresh).toHaveBeenCalledTimes(1) + webglLive = false + scheduledFrames[0]?.(16) + + expect(terminal._core.refresh).toHaveBeenCalledWith(0, 23, true) + }) + it('repaints the viewport again on the next frame when foreground output scrolls', async () => { const scheduledFrames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { @@ -916,6 +1003,41 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('ab') }) + it('keeps preparation attached when a later producer omits it', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const beforeWrite = vi.fn() + + writeTerminalOutput(terminal, 'مرحبا', { foreground: false, beforeWrite }) + writeTerminalOutput(terminal, ' fallback notice', { foreground: false }) + vi.advanceTimersByTime(50) + + expect(beforeWrite).toHaveBeenCalledWith('مرحبا fallback notice') + expect(terminal.write).toHaveBeenCalledWith('مرحبا fallback notice') + }) + + it('ignores unforced chunks when resolving a coalesced forced refresh', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + + writeTerminalOutput(terminal, 'forced', { + foreground: true, + latencySensitive: false, + forceForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => false + }) + writeTerminalOutput(terminal, ' ordinary', { + foreground: true, + latencySensitive: false + }) + vi.advanceTimersByTime(0) + + expect(terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + it('runs deferred write preparation before explicit background flushes', async () => { vi.useFakeTimers() const { flushTerminalOutput, 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 79ddae6e0..e12eeaccb 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 @@ -27,6 +27,7 @@ type TerminalOutputTarget = ForegroundTerminalOutputTarget type TerminalOutputBeforeWrite = (data: string) => void type TerminalBacklogRecoveryRequest = () => boolean type TerminalOutputParsedCallback = () => void +type ForegroundRefreshSyncResolver = () => boolean type WriteTerminalOutputOptions = { foreground: boolean @@ -42,6 +43,7 @@ type WriteTerminalOutputOptions = { latencySensitive?: boolean forceForegroundRefresh?: boolean followupForegroundRefresh?: boolean + shouldRefreshForegroundSynchronously?: ForegroundRefreshSyncResolver stripTransientCursorShows?: boolean coalesceForeground?: boolean holdForeground?: boolean @@ -52,7 +54,9 @@ type QueueChunk = { foreground: boolean forceForegroundRefresh: boolean followupForegroundRefresh: boolean + shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver stripTransientCursorShows: boolean + beforeWrite?: TerminalOutputBeforeWrite onParsed?: TerminalOutputParsedCallback ackCredit?: () => void } @@ -62,7 +66,9 @@ type QueuedWrite = { foreground: boolean forceForegroundRefresh: boolean followupForegroundRefresh: boolean + shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver stripTransientCursorShows: boolean + beforeWrite?: TerminalOutputBeforeWrite onParsed?: TerminalOutputParsedCallback ackCredits: (() => void)[] } @@ -72,7 +78,6 @@ type QueueEntry = { chunks: QueueChunk[] chunkIndex: number queuedChars: number - beforeWrite?: TerminalOutputBeforeWrite onBackgroundBacklogDropped?: () => void backgroundBacklogDropped: boolean highPriority: boolean @@ -129,6 +134,7 @@ const BACKGROUND_BACKLOG_WARNING = // skipped, not merely produced while hidden. const FOREGROUND_BACKLOG_WARNING = '\x18\x1b[0m\r\n[Orca skipped a burst of terminal output because the backlog grew too large.]\r\n' +const ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY = (): boolean => true const queuedByTerminal = new Map() const backlogRecoveryByTerminal = new WeakMap< @@ -334,7 +340,6 @@ function createQueueEntry( chunks: [], chunkIndex: 0, queuedChars: 0, - beforeWrite: options.beforeWrite, onBackgroundBacklogDropped: options.onBackgroundBacklogDropped, backgroundBacklogDropped: false, highPriority: true, @@ -571,7 +576,11 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { let foreground: boolean | null = null let forceForegroundRefresh = false let followupForegroundRefresh = false + let shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver | null = null + let additionalRefreshSyncResolvers: ForegroundRefreshSyncResolver[] | null = null let stripTransientCursorShows = false + let beforeWrite: TerminalOutputBeforeWrite | undefined + let additionalBeforeWriteCallbacks: TerminalOutputBeforeWrite[] | null = null const parsedCallbacks: TerminalOutputParsedCallback[] = [] const ackCredits: (() => void)[] = [] @@ -583,7 +592,30 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { foreground ??= chunk.foreground forceForegroundRefresh ||= chunk.forceForegroundRefresh followupForegroundRefresh ||= chunk.followupForegroundRefresh + // Why: one drained write can combine chunks from different renderer + // states or producers; preserve every forced policy and preparation hook. + if (chunk.forceForegroundRefresh) { + if (shouldRefreshForegroundSynchronously === null) { + shouldRefreshForegroundSynchronously = chunk.shouldRefreshForegroundSynchronously + } else if ( + chunk.shouldRefreshForegroundSynchronously !== shouldRefreshForegroundSynchronously && + !additionalRefreshSyncResolvers?.includes(chunk.shouldRefreshForegroundSynchronously) + ) { + additionalRefreshSyncResolvers ??= [] + additionalRefreshSyncResolvers.push(chunk.shouldRefreshForegroundSynchronously) + } + } stripTransientCursorShows ||= chunk.stripTransientCursorShows + if (!beforeWrite) { + beforeWrite = chunk.beforeWrite + } else if ( + chunk.beforeWrite && + chunk.beforeWrite !== beforeWrite && + !additionalBeforeWriteCallbacks?.includes(chunk.beforeWrite) + ) { + additionalBeforeWriteCallbacks ??= [] + additionalBeforeWriteCallbacks.push(chunk.beforeWrite) + } if (chunk.data.length <= remaining) { data += chunk.data remaining -= chunk.data.length @@ -618,7 +650,22 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { foreground: foreground === true, forceForegroundRefresh, followupForegroundRefresh, + shouldRefreshForegroundSynchronously: + additionalRefreshSyncResolvers && shouldRefreshForegroundSynchronously + ? () => + shouldRefreshForegroundSynchronously() || + additionalRefreshSyncResolvers.some((resolve) => resolve()) + : (shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY), stripTransientCursorShows, + beforeWrite: + additionalBeforeWriteCallbacks && beforeWrite + ? (queuedData) => { + beforeWrite(queuedData) + for (const callback of additionalBeforeWriteCallbacks) { + callback(queuedData) + } + } + : beforeWrite, onParsed: parsedCallbacks.length > 0 ? () => { @@ -654,7 +701,9 @@ function enqueueChunk( foreground?: boolean forceForegroundRefresh?: boolean followupForegroundRefresh?: boolean + shouldRefreshForegroundSynchronously?: ForegroundRefreshSyncResolver stripTransientCursorShows?: boolean + beforeWrite?: TerminalOutputBeforeWrite onParsed?: TerminalOutputParsedCallback ackCredit?: () => void } @@ -664,7 +713,10 @@ function enqueueChunk( foreground: options?.foreground === true, forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, + shouldRefreshForegroundSynchronously: + options?.shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, stripTransientCursorShows: options?.stripTransientCursorShows === true, + beforeWrite: options?.beforeWrite, onParsed: options?.onParsed, ackCredit: options?.ackCredit }) @@ -703,6 +755,13 @@ function replaceBacklogWithWarning( capChars: maxQueueChars }) } + let beforeWrite: TerminalOutputBeforeWrite | undefined + for (let index = entry.chunks.length - 1; index >= entry.chunkIndex; index--) { + if (entry.chunks[index]?.beforeWrite) { + beforeWrite = entry.chunks[index].beforeWrite + break + } + } clearForegroundHoldSafety(entry) fireQueuedAckCredits(entry) entry.chunks = [ @@ -711,7 +770,9 @@ function replaceBacklogWithWarning( foreground: false, forceForegroundRefresh: false, followupForegroundRefresh: false, - stripTransientCursorShows: false + shouldRefreshForegroundSynchronously: ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, + stripTransientCursorShows: false, + beforeWrite } ] entry.chunkIndex = 0 @@ -795,6 +856,7 @@ function writeForegroundTerminalChunkWithIntent( options: { forceViewportRefresh: boolean followupViewportRefresh: boolean + shouldRefreshViewportSynchronously: ForegroundRefreshSyncResolver onParsed?: TerminalOutputParsedCallback } ): void { @@ -802,6 +864,7 @@ function writeForegroundTerminalChunkWithIntent( writeForegroundTerminalChunk(terminal, data, { forceViewportRefresh: options.forceViewportRefresh, followupViewportRefresh: options.followupViewportRefresh, + shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, onParsed: () => { // Why: recovery must repaint from the scrolled buffer state that xterm // will keep, not from a pre-intent-restored viewport snapshot. @@ -886,7 +949,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null const pacer = entry.highPriority ? makeParseClockPacer() : undefined const ackCreditsParsed = registerTerminalOutputAckCredits(entry.terminal, queuedWrite.ackCredits) try { - entry.beforeWrite?.(queuedWrite.data) + queuedWrite.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { writeForegroundTerminalChunkWithIntent( entry.terminal, @@ -896,6 +959,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, + shouldRefreshViewportSynchronously: queuedWrite.shouldRefreshForegroundSynchronously, onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer) } ) @@ -1007,7 +1071,6 @@ export function writeTerminalOutput( const entry = queuedByTerminal.get(terminal) if (entry?.highPriority || options.coalesceForeground || options.holdForeground) { const queued = entry ?? createQueueEntry(terminal, options) - queued.beforeWrite = options.beforeWrite queued.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped queued.highPriority = true queuedByTerminal.set(terminal, queued) @@ -1015,7 +1078,9 @@ export function writeTerminalOutput( foreground: true, forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, + shouldRefreshForegroundSynchronously: options.shouldRefreshForegroundSynchronously, stripTransientCursorShows: options.stripTransientCursorShows, + beforeWrite: options.beforeWrite, onParsed: options.onParsed, ackCredit: options.ackCredit }) @@ -1084,13 +1149,14 @@ export function writeTerminalOutput( return } if (entry && entry.queuedChars > SYNC_FOREGROUND_FLUSH_CHARS) { - entry.beforeWrite = options.beforeWrite entry.highPriority = true enqueueChunk(entry, data, { foreground: true, forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, + shouldRefreshForegroundSynchronously: options.shouldRefreshForegroundSynchronously, stripTransientCursorShows: options.stripTransientCursorShows, + beforeWrite: options.beforeWrite, onParsed: options.onParsed, ackCredit: options.ackCredit }) @@ -1113,7 +1179,6 @@ export function writeTerminalOutput( queued = createQueueEntry(terminal, options) queuedByTerminal.set(terminal, queued) } else { - queued.beforeWrite = options.beforeWrite queued.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped queued.highPriority = true } @@ -1121,7 +1186,9 @@ export function writeTerminalOutput( foreground: true, forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, + shouldRefreshForegroundSynchronously: options.shouldRefreshForegroundSynchronously, stripTransientCursorShows: options.stripTransientCursorShows, + beforeWrite: options.beforeWrite, onParsed: options.onParsed, ackCredit: options.ackCredit }) @@ -1154,6 +1221,8 @@ export function writeTerminalOutput( { forceViewportRefresh: options.forceForegroundRefresh === true, followupViewportRefresh: options.followupForegroundRefresh === true, + shouldRefreshViewportSynchronously: + options.shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, onParsed: composeParsedCallback(options.onParsed, ackCreditsParsed, undefined) } ) @@ -1172,10 +1241,10 @@ export function writeTerminalOutput( entry.highPriority = false queuedByTerminal.set(terminal, entry) } else { - entry.beforeWrite = options.beforeWrite entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped } enqueueChunk(entry, data, { + beforeWrite: options.beforeWrite, onParsed: options.onParsed, ackCredit: options.ackCredit }) @@ -1228,7 +1297,7 @@ export function flushTerminalOutput( } const ackCreditsParsed = registerTerminalOutputAckCredits(terminal, queuedWrite.ackCredits) try { - entry.beforeWrite?.(queuedWrite.data) + queuedWrite.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { writeForegroundTerminalChunkWithIntent( terminal, @@ -1238,6 +1307,7 @@ export function flushTerminalOutput( { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, + shouldRefreshViewportSynchronously: queuedWrite.shouldRefreshForegroundSynchronously, onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined) } ) diff --git a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts index 6bed3d7c0..6895d629f 100644 --- a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + configureLazyArabicShapingJoiner, + ensureArabicShapingJoinerForText, findRtlJoinRanges, isStrongRtlCodePoint, registerArabicShapingJoiner @@ -248,3 +250,75 @@ describe('registerArabicShapingJoiner', () => { expect(handler('مرحبا')).toEqual([[0, 5]]) }) }) + +describe('configureLazyArabicShapingJoiner', () => { + function createLazyHost() { + const events: string[] = [] + let handler: ((text: string) => [number, number][]) | null = null + const terminal = { + registerCharacterJoiner(nextHandler: (text: string) => [number, number][]): number { + events.push('register') + handler = nextHandler + return 11 + }, + deregisterCharacterJoiner(joinerId: number): void { + events.push(`deregister:${joinerId}`) + } + } + return { events, terminal, getHandler: () => handler } + } + + it('does not register for ordinary terminal output', () => { + const host = createLazyHost() + const cleanup = configureLazyArabicShapingJoiner(host.terminal, () => true) + + ensureArabicShapingJoinerForText(host.terminal, 'ASCII, 中文, and emoji 😀') + + expect(host.events).toEqual([]) + expect(host.getHandler()).toBeNull() + cleanup() + expect(host.events).toEqual([]) + }) + + it('registers once before the first RTL write and cleans it up', () => { + const host = createLazyHost() + const cleanup = configureLazyArabicShapingJoiner(host.terminal, () => true) + + ensureArabicShapingJoinerForText(host.terminal, 'مرحبا') + ensureArabicShapingJoinerForText(host.terminal, 'שלום') + + expect(host.events).toEqual(['register']) + expect(host.getHandler()!('مرحبا')).toEqual([[0, 5]]) + cleanup() + expect(host.events).toEqual(['register', 'deregister:11']) + }) + + it('recognizes a supplementary-plane RTL code point split across writes', () => { + const host = createLazyHost() + configureLazyArabicShapingJoiner(host.terminal, () => true) + const adlam = String.fromCodePoint(0x1e900) + + ensureArabicShapingJoinerForText(host.terminal, adlam.charAt(0)) + expect(host.events).toEqual([]) + ensureArabicShapingJoinerForText(host.terminal, adlam.charAt(1)) + + expect(host.events).toEqual(['register']) + }) + + it('contains a registration failure and does not retry every write', () => { + let attempts = 0 + const terminal = { + registerCharacterJoiner(): number { + attempts++ + throw new Error('terminal disposed') + }, + deregisterCharacterJoiner(): void {} + } + configureLazyArabicShapingJoiner(terminal, () => true) + + expect(() => ensureArabicShapingJoinerForText(terminal, 'مرحبا')).not.toThrow() + expect(() => ensureArabicShapingJoinerForText(terminal, 'שלום')).not.toThrow() + + expect(attempts).toBe(1) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts index 5b9b5f8dc..802f708b8 100644 --- a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts @@ -1,5 +1,19 @@ import type { Terminal } from '@xterm/xterm' +type ArabicShapingTerminal = Pick + +type LazyArabicShapingJoinerState = { + cleanup: (() => void) | null + isShapingActive: () => boolean + registrationAttempted: boolean + trailingHighSurrogate: string +} + +const lazyArabicShapingJoinerByTerminal = new WeakMap< + ArabicShapingTerminal, + LazyArabicShapingJoinerState +>() + // Why: xterm draws every cell's glyph in isolation, so Arabic output shows // disconnected letterforms in logical (reversed) order — upstream has no // BiDi/shaping support (xtermjs/xterm.js#701, Orca #5262). Joining each RTL @@ -29,6 +43,27 @@ export function isStrongRtlCodePoint(codePoint: number): boolean { ) } +function containsStrongRtlText(text: string): boolean { + for (let index = 0; index < text.length; index++) { + const unit = text.charCodeAt(index) + if (unit < RTL_SCAN_FLOOR) { + continue + } + let codePoint = unit + if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < text.length) { + const low = text.charCodeAt(index + 1) + if (low >= 0xdc00 && low <= 0xdfff) { + codePoint = (unit - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000 + index++ + } + } + if (isStrongRtlCodePoint(codePoint)) { + return true + } + } + return false +} + // Neutral characters may sit inside an RTL run (so a multi-word phrase joins // as one unit and keeps right-to-left word order) but never start or end one: // ASCII space/digits/punctuation and NBSP. ASCII letters are strong LTR and @@ -169,7 +204,7 @@ export function findRtlJoinRanges(text: string): [number, number][] { * character joiners, so disposePane() must call this to avoid leaking the * registration (xtermjs/xterm.js#3289). */ export function registerArabicShapingJoiner( - terminal: Pick, + terminal: ArabicShapingTerminal, isShapingActive: () => boolean ): () => void { // Why: the DOM renderer sizes a joined span with one letter-spacing value @@ -185,3 +220,70 @@ export function registerArabicShapingJoiner( terminal.deregisterCharacterJoiner(joinerId) } } + +/** Configure RTL shaping without registering an xterm character joiner until + * output actually contains RTL text. Any registered joiner makes xterm scan + * every visible cell on every repaint, even when its handler returns no ranges. */ +export function configureLazyArabicShapingJoiner( + terminal: ArabicShapingTerminal, + isShapingActive: () => boolean +): () => void { + const previousState = lazyArabicShapingJoinerByTerminal.get(terminal) + try { + previousState?.cleanup?.() + } catch { + // A disposed terminal can reject deregistration; replace stale state anyway. + } + + const state: LazyArabicShapingJoinerState = { + cleanup: null, + isShapingActive, + registrationAttempted: false, + trailingHighSurrogate: '' + } + lazyArabicShapingJoinerByTerminal.set(terminal, state) + + return () => { + if (lazyArabicShapingJoinerByTerminal.get(terminal) !== state) { + return + } + try { + state.cleanup?.() + } catch { + // Pane teardown must continue if xterm disposed before deregistration. + } finally { + lazyArabicShapingJoinerByTerminal.delete(terminal) + } + } +} + +/** Register the configured joiner immediately before the first RTL write. */ +export function ensureArabicShapingJoinerForText( + terminal: ArabicShapingTerminal, + text: string +): void { + const state = lazyArabicShapingJoinerByTerminal.get(terminal) + if (!state || state.cleanup || state.registrationAttempted) { + return + } + + // Why: PTY/replay chunks can split supplementary-plane RTL code points + // between their surrogate halves; retain only that one boundary code unit. + const scanText = state.trailingHighSurrogate + text + const finalCharacter = scanText.at(-1) ?? '' + const finalCodeUnit = finalCharacter.charCodeAt(0) + state.trailingHighSurrogate = + finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff ? finalCharacter : '' + if (!containsStrongRtlText(scanText)) { + return + } + + state.trailingHighSurrogate = '' + state.registrationAttempted = true + try { + state.cleanup = registerArabicShapingJoiner(terminal, state.isShapingActive) + } catch { + // Why: shaping is optional; a registration race with pane disposal must + // never drop the PTY/replay bytes that triggered it or retry every chunk. + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts new file mode 100644 index 000000000..75f52f3f4 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Terminal } from '@xterm/xterm' + +const addonMock = vi.hoisted(() => ({ + delegateTerminal: null as Terminal | null, + joiner: vi.fn<(text: string) => [number, number][]>() +})) + +vi.mock('@xterm/addon-ligatures', () => ({ + LigaturesAddon: class { + private joinerId: number | null = null + + activate(terminal: Terminal): void { + addonMock.delegateTerminal = terminal + this.joinerId = terminal.registerCharacterJoiner(addonMock.joiner) + } + + dispose(): void { + if (this.joinerId !== null) { + addonMock.delegateTerminal?.deregisterCharacterJoiner(this.joinerId) + } + } + } +})) + +import { TerminalLigaturesAddon } from './terminal-ligatures-addon' + +function createTerminalHarness() { + let registeredJoiner: ((text: string) => [number, number][]) | null = null + const refresh = vi.fn() + const deregisterCharacterJoiner = vi.fn() + const terminal = { + element: { style: {} }, + options: { fontFamily: 'Fira Code' }, + refresh, + registerCharacterJoiner(joiner: (text: string) => [number, number][]): number { + registeredJoiner = joiner + return 17 + }, + deregisterCharacterJoiner + } as unknown as Terminal + return { + terminal, + refresh, + deregisterCharacterJoiner, + getRegisteredJoiner: () => registeredJoiner! + } +} + +describe('TerminalLigaturesAddon', () => { + beforeEach(() => { + addonMock.delegateTerminal = null + addonMock.joiner.mockReset() + addonMock.joiner.mockImplementation((text) => (text.includes('=>') ? [[2, 4]] : [])) + }) + + it('reuses joiner results for unchanged row text', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + + expect(joiner('a => b')).toEqual([[2, 4]]) + expect(joiner('a => b')).toEqual([[2, 4]]) + + expect(addonMock.joiner).toHaveBeenCalledTimes(1) + }) + + it('returns fresh tuples because xterm mutates joiner ranges', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + + const first = joiner('a => b') + first[0]![0] = 99 + + expect(joiner('a => b')).toEqual([[2, 4]]) + }) + + it('invalidates fallback results when font discovery refreshes', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + joiner('a => b') + + addonMock.delegateTerminal!.refresh(0, 23) + joiner('a => b') + + expect(harness.refresh).toHaveBeenCalledWith(0, 23) + expect(addonMock.joiner).toHaveBeenCalledTimes(2) + }) + + it('does not reuse results after the terminal font changes', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + joiner('a => b') + + harness.terminal.options.fontFamily = 'JetBrains Mono' + joiner('a => b') + + expect(addonMock.joiner).toHaveBeenCalledTimes(2) + }) + + it('caps cached short segments by entry count', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + + for (let index = 0; index <= 2_048; index++) { + joiner(`s${index}`) + } + joiner('s0') + + expect(addonMock.joiner).toHaveBeenCalledTimes(2_050) + }) + + it('evicts the least-recently-used segment', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + + for (let index = 0; index < 2_048; index++) { + joiner(`s${index}`) + } + joiner('s0') + joiner('new segment') + joiner('s1') + joiner('s0') + + expect(addonMock.joiner).toHaveBeenCalledTimes(2_050) + }) + + it('does not retain a segment above the character budget', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + const oversizedSegment = 'x'.repeat(100_001) + + joiner(oversizedSegment) + joiner(oversizedSegment) + + expect(addonMock.joiner).toHaveBeenCalledTimes(2) + }) + + it('deregisters the wrapped joiner through the real terminal', () => { + const harness = createTerminalHarness() + const addon = new TerminalLigaturesAddon() + addon.activate(harness.terminal) + + addon.dispose() + + expect(harness.deregisterCharacterJoiner).toHaveBeenCalledWith(17) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts new file mode 100644 index 000000000..256f38bef --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts @@ -0,0 +1,120 @@ +// Upstream packaging bug: @xterm/addon-ligatures declares a missing module +// entry. config/patches/@xterm__addon-ligatures* keeps the runtime import valid. +import { LigaturesAddon } from '@xterm/addon-ligatures' +import type { Terminal } from '@xterm/xterm' + +type LigatureRange = [number, number] +type CharacterJoiner = (text: string) => LigatureRange[] + +const LIGATURE_CACHE_CHARACTER_BUDGET = 100_000 +// Why: short attribute segments can otherwise create tens of thousands of Map +// entries per pane; 2K is still far beyond one visible grid's working set. +const LIGATURE_CACHE_ENTRY_BUDGET = 2_048 + +function cloneRanges(ranges: readonly LigatureRange[]): LigatureRange[] { + return ranges.map(([start, end]) => [start, end]) +} + +class LigatureRangeCache { + private readonly entries = new Map() + private cachedCharacters = 0 + generation = 0 + + get(text: string): LigatureRange[] | undefined { + const entry = this.entries.get(text) + if (!entry) { + return undefined + } + this.entries.delete(text) + this.entries.set(text, entry) + // Why: xterm translates and merges joiner ranges in place, so callers must + // never receive the cache's retained tuples. + return cloneRanges(entry.ranges) + } + + set(text: string, ranges: readonly LigatureRange[]): void { + if (text.length > LIGATURE_CACHE_CHARACTER_BUDGET) { + return + } + const previous = this.entries.get(text) + if (previous) { + this.cachedCharacters -= previous.size + this.entries.delete(text) + } + const entry = { ranges: cloneRanges(ranges), size: text.length } + this.entries.set(text, entry) + this.cachedCharacters += entry.size + while ( + this.cachedCharacters > LIGATURE_CACHE_CHARACTER_BUDGET || + this.entries.size > LIGATURE_CACHE_ENTRY_BUDGET + ) { + const oldest = this.entries.entries().next().value as + | [string, { ranges: LigatureRange[]; size: number }] + | undefined + if (!oldest) { + break + } + this.entries.delete(oldest[0]) + this.cachedCharacters -= oldest[1].size + } + } + + clear(): void { + this.entries.clear() + this.cachedCharacters = 0 + this.generation++ + } +} + +function createCachedCharacterJoiner( + terminal: Terminal, + joiner: CharacterJoiner, + cache: LigatureRangeCache +): CharacterJoiner { + let cachedFontFamily = terminal.options.fontFamily + return (text) => { + const fontFamily = terminal.options.fontFamily + if (fontFamily !== cachedFontFamily) { + cachedFontFamily = fontFamily + cache.clear() + } + const cached = cache.get(text) + if (cached) { + return cached + } + const generationBeforeJoin = cache.generation + const ranges = joiner(text) + // Why: the addon refreshes when async font discovery completes. If that + // happened during this call, do not repopulate the cleared fallback data. + if (cache.generation === generationBeforeJoin) { + cache.set(text, ranges) + } + return ranges + } +} + +/** LigaturesAddon with a bounded exact-row cache around its character joiner. + * Active TUIs repaint mostly unchanged rows, while the upstream fallback + * matcher otherwise retries every known ligature at every character. */ +export class TerminalLigaturesAddon extends LigaturesAddon { + override activate(terminal: Terminal): void { + const cache = new LigatureRangeCache() + const terminalForAddon = new Proxy(terminal, { + get(target, property) { + if (property === 'registerCharacterJoiner') { + return (joiner: CharacterJoiner): number => + target.registerCharacterJoiner(createCachedCharacterJoiner(target, joiner, cache)) + } + if (property === 'refresh') { + return (start: number, end: number): void => { + cache.clear() + target.refresh(start, end) + } + } + const value = Reflect.get(target, property, target) as unknown + return typeof value === 'function' ? value.bind(target) : value + } + }) + super.activate(terminalForAddon) + } +} diff --git a/tests/e2e/terminal-foreground-redraw-freeze.spec.ts b/tests/e2e/terminal-foreground-redraw-freeze.spec.ts index fe32e0236..fd6fad6f4 100644 --- a/tests/e2e/terminal-foreground-redraw-freeze.spec.ts +++ b/tests/e2e/terminal-foreground-redraw-freeze.spec.ts @@ -41,11 +41,18 @@ type SchedulerDebugWindow = Window & { type RefreshProbeWindow = SchedulerDebugWindow & { __terminalRefreshProbe?: { - count: () => number + snapshot: () => RefreshProbeSnapshot dispose: () => void } } +type RefreshProbeSnapshot = { + synchronousWebgl: number + synchronousDom: number + debouncedWebgl: number + debouncedDom: number +} + const REDRAW_FRAME_COUNT = 270 const REDRAW_PAYLOAD_CHARS = 520 const REWRITE_REDRAW_FRAME_COUNT = REDRAW_FRAME_COUNT @@ -172,32 +179,115 @@ async function installActivePaneRefreshProbe(page: Page): Promise { } const terminal = pane.terminal as unknown as { _core?: { refresh?: (start: number, end: number, sync?: boolean) => void } + refresh: (start: number, end: number) => void } const originalCoreRefresh = terminal._core?.refresh?.bind(terminal._core) + const originalPublicRefresh = terminal.refresh.bind(terminal) if (!terminal._core || !originalCoreRefresh) { throw new Error('Active terminal core refresh hook is unavailable') } - let refreshCount = 0 + let synchronousWebgl = 0 + let synchronousDom = 0 + let debouncedWebgl = 0 + let debouncedDom = 0 terminal._core.refresh = (start, end, sync) => { if (sync === true) { - refreshCount += 1 + if (manager.hasWebglRenderer(pane.id)) { + synchronousWebgl += 1 + } else { + synchronousDom += 1 + } } originalCoreRefresh(start, end, sync) } + terminal.refresh = (start, end) => { + if (manager.hasWebglRenderer(pane.id)) { + debouncedWebgl += 1 + } else { + debouncedDom += 1 + } + originalPublicRefresh(start, end) + } ;(window as RefreshProbeWindow).__terminalRefreshProbe = { - count: () => refreshCount, + snapshot: () => ({ + synchronousWebgl, + synchronousDom, + debouncedWebgl, + debouncedDom + }), dispose: () => { if (terminal._core) { terminal._core.refresh = originalCoreRefresh } + terminal.refresh = originalPublicRefresh delete (window as RefreshProbeWindow).__terminalRefreshProbe } } }) } -async function readRefreshProbeCount(page: Page): Promise { - return page.evaluate(() => (window as RefreshProbeWindow).__terminalRefreshProbe?.count() ?? 0) +async function forceActivePaneWebglRenderer(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { ...state.settings, terminalGpuAcceleration: 'on' } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on') + }) + return page + .waitForFunction( + () => { + 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 + return pane ? manager?.hasWebglRenderer(pane.id) === true : false + }, + null, + { timeout: 10_000 } + ) + .then(() => true) + .catch(() => false) +} + +async function readRefreshProbe(page: Page): Promise { + return page.evaluate( + () => + (window as RefreshProbeWindow).__terminalRefreshProbe?.snapshot() ?? { + synchronousWebgl: 0, + synchronousDom: 0, + debouncedWebgl: 0, + debouncedDom: 0 + } + ) +} + +function subtractRefreshProbe( + current: RefreshProbeSnapshot, + baseline: RefreshProbeSnapshot +): RefreshProbeSnapshot { + return { + synchronousWebgl: current.synchronousWebgl - baseline.synchronousWebgl, + synchronousDom: current.synchronousDom - baseline.synchronousDom, + debouncedWebgl: current.debouncedWebgl - baseline.debouncedWebgl, + debouncedDom: current.debouncedDom - baseline.debouncedDom + } } async function disposeActivePaneRefreshProbe(page: Page): Promise { @@ -249,7 +339,9 @@ function annotateMeasurement( } test.describe('Terminal foreground redraw freeze repro', () => { - test('Codex-style line rewrites request a visible row refresh', async ({ orcaPage }) => { + test('Codex-style line rewrites request a visible row refresh', async ({ + orcaPage + }, testInfo) => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) @@ -257,9 +349,16 @@ test.describe('Terminal foreground redraw freeze repro', () => { const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage) await waitForTerminalPtyDataInjector(orcaPage, paneKey) + const webglAttached = await forceActivePaneWebglRenderer(orcaPage) + // Why: Linux headless CI intentionally disables GPU. Declare that + // environment unsupported instead of weakening the WebGL-only oracle. + test.skip(!webglAttached, 'WebGL is unavailable for the refresh-policy probe') + if (!webglAttached) { + return + } await installActivePaneRefreshProbe(orcaPage) try { - const refreshBaseline = await readRefreshProbeCount(orcaPage) + const refreshBaseline = await readRefreshProbe(orcaPage) await resetSchedulerDebug(orcaPage) const measurement = await measureRendererDuringRewriteBurst(orcaPage, paneKey) const scheduler = await readSchedulerDebug(orcaPage) @@ -268,11 +367,32 @@ test.describe('Terminal foreground redraw freeze repro', () => { 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' - }) + .poll( + async () => { + const refresh = await readRefreshProbe(orcaPage) + const delta = subtractRefreshProbe(refresh, refreshBaseline) + return Object.values(delta).reduce((total, count) => total + count, 0) + }, + { + timeout: 5_000, + message: 'Codex-style terminal rewrites did not request an xterm refresh' + } + ) .toBeGreaterThan(0) + const refresh = await readRefreshProbe(orcaPage) + const refreshDelta = subtractRefreshProbe(refresh, refreshBaseline) + testInfo.annotations.push({ + type: 'terminal-refresh-probe', + description: `syncWebgl=${refreshDelta.synchronousWebgl} syncDom=${ + refreshDelta.synchronousDom + } debouncedWebgl=${refreshDelta.debouncedWebgl} debouncedDom=${refreshDelta.debouncedDom}` + }) + // Why: a synchronous full-grid WebGL refresh duplicates xterm's + // already-queued animation frame and was the #6655 CPU hotspot. + expect(refreshDelta.synchronousWebgl).toBe(0) + // Requiring an observed public WebGL refresh prevents a mid-run DOM + // fallback from turning the zero-sync assertion into a vacuous pass. + expect(refreshDelta.debouncedWebgl).toBeGreaterThan(0) } finally { await disposeActivePaneRefreshProbe(orcaPage) }