Reduce terminal redraw CPU on active TUIs (#8178)

This commit is contained in:
Neil 2026-07-10 19:50:44 -07:00 committed by GitHub
parent a916a93df3
commit c7777e44a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1006 additions and 80 deletions

View File

@ -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<number, number>() }
const restoredViewportBlankingPanesRef = { current: new Set<number>() }
@ -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)
})

View File

@ -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)

View File

@ -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

View File

@ -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<void> => {
// 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,

View File

@ -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

View File

@ -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<Map<number, number>>
// 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<void> {
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
})
})

View File

@ -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()

View File

@ -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

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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<TerminalOutputTarget, QueueEntry>()
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)
}
)

View File

@ -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)
})
})

View File

@ -1,5 +1,19 @@
import type { Terminal } from '@xterm/xterm'
type ArabicShapingTerminal = Pick<Terminal, 'registerCharacterJoiner' | 'deregisterCharacterJoiner'>
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, 'registerCharacterJoiner' | 'deregisterCharacterJoiner'>,
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.
}
}

View File

@ -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)
})
})

View File

@ -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<string, { ranges: LigatureRange[]; size: number }>()
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)
}
}

View File

@ -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<void> {
}
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<number> {
return page.evaluate(() => (window as RefreshProbeWindow).__terminalRefreshProbe?.count() ?? 0)
async function forceActivePaneWebglRenderer(page: Page): Promise<boolean> {
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<RefreshProbeSnapshot> {
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<void> {
@ -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)
}