Reduce terminal redraw CPU on active TUIs (#8178)
This commit is contained in:
parent
a916a93df3
commit
c7777e44a6
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue