fix terminal complex script rendering (#1675)

This commit is contained in:
Neil 2026-05-10 16:53:24 -07:00 committed by GitHub
parent 01bab271eb
commit ad5d5dc841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 374 additions and 216 deletions

View File

@ -64,15 +64,15 @@
"@tiptap/pm": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@xterm/addon-fit": "0.12.0-beta.198",
"@xterm/addon-ligatures": "0.11.0-beta.198",
"@xterm/addon-search": "0.17.0-beta.198",
"@xterm/addon-serialize": "0.15.0-beta.198",
"@xterm/addon-unicode11": "0.10.0-beta.198",
"@xterm/addon-web-links": "0.13.0-beta.198",
"@xterm/addon-webgl": "0.20.0-beta.197",
"@xterm/headless": "6.1.0-beta.198",
"@xterm/xterm": "6.1.0-beta.198",
"@xterm/addon-fit": "0.12.0-beta.216",
"@xterm/addon-ligatures": "0.11.0-beta.216",
"@xterm/addon-search": "0.17.0-beta.216",
"@xterm/addon-serialize": "0.15.0-beta.216",
"@xterm/addon-unicode11": "0.10.0-beta.216",
"@xterm/addon-web-links": "0.13.0-beta.216",
"@xterm/addon-webgl": "0.20.0-beta.215",
"@xterm/headless": "6.1.0-beta.216",
"@xterm/xterm": "6.1.0-beta.216",
"agent-browser": "~0.24.1",
"better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.1",
@ -168,7 +168,7 @@
],
"patchedDependencies": {
"node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch",
"@xterm/addon-ligatures@0.11.0-beta.198": "config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch"
"@xterm/addon-ligatures@0.11.0-beta.216": "config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch"
}
}
}

View File

@ -5,9 +5,9 @@ settings:
excludeLinksFromLockfile: false
patchedDependencies:
'@xterm/addon-ligatures@0.11.0-beta.198':
'@xterm/addon-ligatures@0.11.0-beta.216':
hash: df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch
node-pty@1.1.0:
hash: 93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4
path: config/patches/node-pty@1.1.0.patch
@ -86,32 +86,32 @@ importers:
specifier: ^3.22.5
version: 3.22.5
'@xterm/addon-fit':
specifier: 0.12.0-beta.198
version: 0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198)
specifier: 0.12.0-beta.216
version: 0.12.0-beta.216(@xterm/xterm@6.1.0-beta.216)
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.198
version: 0.11.0-beta.198(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.198)
specifier: 0.11.0-beta.216
version: 0.11.0-beta.216(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.216)
'@xterm/addon-search':
specifier: 0.17.0-beta.198
version: 0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198)
specifier: 0.17.0-beta.216
version: 0.17.0-beta.216(@xterm/xterm@6.1.0-beta.216)
'@xterm/addon-serialize':
specifier: 0.15.0-beta.198
version: 0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198)
specifier: 0.15.0-beta.216
version: 0.15.0-beta.216(@xterm/xterm@6.1.0-beta.216)
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.198
version: 0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198)
specifier: 0.10.0-beta.216
version: 0.10.0-beta.216(@xterm/xterm@6.1.0-beta.216)
'@xterm/addon-web-links':
specifier: 0.13.0-beta.198
version: 0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198)
specifier: 0.13.0-beta.216
version: 0.13.0-beta.216(@xterm/xterm@6.1.0-beta.216)
'@xterm/addon-webgl':
specifier: 0.20.0-beta.197
version: 0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198)
specifier: 0.20.0-beta.215
version: 0.20.0-beta.215(@xterm/xterm@6.1.0-beta.216)
'@xterm/headless':
specifier: 6.1.0-beta.198
version: 6.1.0-beta.198
specifier: 6.1.0-beta.216
version: 6.1.0-beta.216
'@xterm/xterm':
specifier: 6.1.0-beta.198
version: 6.1.0-beta.198
specifier: 6.1.0-beta.216
version: 6.1.0-beta.216
agent-browser:
specifier: ~0.24.1
version: 0.24.1
@ -2997,47 +2997,47 @@ packages:
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
'@xterm/addon-fit@0.12.0-beta.198':
resolution: {integrity: sha512-1EZFzOaUjRnlo5p2diIV9+HHVtHqr0O7TEFCstSuFqV/5QaKkRiWiKaMfsPEg2pF5P7yBOASk73yUPII7oquaA==}
'@xterm/addon-fit@0.12.0-beta.216':
resolution: {integrity: sha512-IgKE3ngNodSnmj1O+EEYpKQZkSbAUbghPlCWd8G32RL0piIMqb3FX3BuYLnWZeLNoD9iMtublLMG1T9XjGeVvA==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/addon-ligatures@0.11.0-beta.198':
resolution: {integrity: sha512-LpF9KYiixxybSdyWpBtaPg5S1NNosQU0vcYg9P71xZFVRPT5vjaFvxYd80hV0oeKdnT25EMgNxwxbqHasmHQvg==}
'@xterm/addon-ligatures@0.11.0-beta.216':
resolution: {integrity: sha512-NJrK8MRi64Kb1Kxz5Jmrnd7H2gTlBqM3wHfObneD7T4xGFoPZkeX2/Kit4IcHt2Y5CTAQFAc92DilsncVm1HnQ==}
engines: {node: '>8.0.0'}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/addon-search@0.17.0-beta.198':
resolution: {integrity: sha512-loNSX0cB4P+9Sg13sgB/cQPPBgdOi1hxRyZ75Jj90+4iKe227mRmgzdqU3wxr10nyFWJWCpysCjVT+47tdC8mQ==}
'@xterm/addon-search@0.17.0-beta.216':
resolution: {integrity: sha512-p//4yv5aB2eyQcCFmPQ/s7tTFSTwKbtxs89xP1gvMbT9QuZgjW9FNqaQKb1GLDWtMpSfZdy7RrUp4GiCg3PVJQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/addon-serialize@0.15.0-beta.198':
resolution: {integrity: sha512-0yf13cLPaKX8nkgUqclcdbU5FexacA/xnvWzHzGFYTku0WH+SqygbQcDOcg4bTUGgCcvfZqntEb1NQpr+69KgQ==}
'@xterm/addon-serialize@0.15.0-beta.216':
resolution: {integrity: sha512-scj/83XfYHg5LiDscYDP9dnadUMZt8To1tbdncQP/dZq2pD4TsTMokW1pciVYYazLBxCeKZybbVLEuOo7IPCqQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/addon-unicode11@0.10.0-beta.198':
resolution: {integrity: sha512-3itjZ5lTQ5tnDcpUAplX88BsTJTZOvEzX9wNTNMO2aPuctLavMNg2+pXU8KTkeRhUZnDyhx9Lsv81dDs94vWDQ==}
'@xterm/addon-unicode11@0.10.0-beta.216':
resolution: {integrity: sha512-i7TrEHOTzUEOClH1+6IHoHy7bR/XHVRBjHc5e0u6A1HucFkAlCU+bqUY8EfwNOh1/iUjuB06EtNh6BM1o/ZAlA==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/addon-web-links@0.13.0-beta.198':
resolution: {integrity: sha512-qA/B1tuNLpqlzscrAVFjLKjuH0CmKKyfbTn78Y3RLKs6H7xxdYSE6/DjJ1zg9C6aDtLwF9lsaOFpPSPf2AfpYQ==}
'@xterm/addon-web-links@0.13.0-beta.216':
resolution: {integrity: sha512-ZP3BDy1na/37TZHO8FB+XHJFoO8muyPoOzkaL2X28n5A9ZFQPbf834EMRsvDczKnfQvtZcve+ZCmMJj1ZHGjow==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/addon-webgl@0.20.0-beta.197':
resolution: {integrity: sha512-0o30FvLzfd9ODuB1gbpwYMGQHYiz0FebmVaMP0Idesw1sLIG3qc7aIIBQEUnWiBDsKbDHhaysW8WTxacg2AhaQ==}
'@xterm/addon-webgl@0.20.0-beta.215':
resolution: {integrity: sha512-oCbH3YxiGOzRcKxwTfSRCA1TqpoT/AitO2X5MuqD14DVnb4Z3rTKQYfHBA7R6HA7U4K9OzmtIsi5+VyEIEaWsg==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/headless@6.1.0-beta.198':
resolution: {integrity: sha512-pDS/I5HLGwwJf8sWZFaT+qbHxgFu0qdVQv5/XDzhNyKXMXQJ7cL3ZsJsIox5JxLo2ZuYLVVY8/lirHronhOSfA==}
'@xterm/headless@6.1.0-beta.216':
resolution: {integrity: sha512-HCut3ABwBv5zVUAB+5KVTW4svr/fCeVMW9xtaJIhmXZsNHOrhVFOGX7E2CYzRa8xdX/aI2JdTIhgLl8xjcHEoA==}
'@xterm/xterm@6.1.0-beta.198':
resolution: {integrity: sha512-udjhNFi4YfypbVBJV7gFNwQV3KziZFJdq+fg87WefGF5JkGbC+4LvIhEsHXR0Kb5Ez8ESxeqM7Yjvj+kw8zDAg==}
'@xterm/xterm@6.1.0-beta.216':
resolution: {integrity: sha512-87rfymzVje5eYUlGG94hz1WkOYvFRcFDGdiOAbg4d8xt8OGSGR2nMNU4I1n5MDE1RBPBqRd+WVJ5w7q3pwMoZA==}
abbrev@4.0.0:
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
@ -8910,39 +8910,39 @@ snapshots:
'@xmldom/xmldom@0.9.10':
optional: true
'@xterm/addon-fit@0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
'@xterm/addon-fit@0.12.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/xterm': 6.1.0-beta.216
'@xterm/addon-ligatures@0.11.0-beta.198(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.198)':
'@xterm/addon-ligatures@0.11.0-beta.216(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.216)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/xterm': 6.1.0-beta.216
lru-cache: 6.0.0
opentype.js: 0.8.0
'@xterm/addon-search@0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
'@xterm/addon-search@0.17.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/xterm': 6.1.0-beta.216
'@xterm/addon-serialize@0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
'@xterm/addon-serialize@0.15.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/xterm': 6.1.0-beta.216
'@xterm/addon-unicode11@0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
'@xterm/addon-unicode11@0.10.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/xterm': 6.1.0-beta.216
'@xterm/addon-web-links@0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
'@xterm/addon-web-links@0.13.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/xterm': 6.1.0-beta.216
'@xterm/addon-webgl@0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198)':
'@xterm/addon-webgl@0.20.0-beta.215(@xterm/xterm@6.1.0-beta.216)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/xterm': 6.1.0-beta.216
'@xterm/headless@6.1.0-beta.198': {}
'@xterm/headless@6.1.0-beta.216': {}
'@xterm/xterm@6.1.0-beta.198': {}
'@xterm/xterm@6.1.0-beta.216': {}
abbrev@4.0.0: {}

View File

@ -162,6 +162,7 @@ function createPane(paneId: number) {
function createManager(paneCount = 1) {
return {
setPaneGpuRendering: vi.fn(),
markPaneHasComplexScriptOutput: vi.fn(),
getPanes: vi.fn(() => Array.from({ length: paneCount }, (_, index) => ({ id: index + 1 }))),
closePane: vi.fn(),
getActivePane: vi.fn<() => { id: number } | null>(() => null)
@ -767,6 +768,29 @@ describe('connectPanePty', () => {
expect(pane.terminal.write).toHaveBeenCalledWith('hello\r\n')
})
it('marks panes that receive Arabic output for DOM rendering', async () => {
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 manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
capturedDataCallback.current?.('Arabic: السلام عليكم\r\n')
expect(manager.markPaneHasComplexScriptOutput).toHaveBeenCalledWith(1)
expect(pane.terminal.write).toHaveBeenCalledWith('Arabic: السلام عليكم\r\n')
})
it('reattaches via daemon sessionId when an in-session PTY is live', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()

View File

@ -13,6 +13,7 @@ import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { isPaneReplaying, replayIntoTerminal } from './replay-guard'
import { terminalOutputRequiresDomRenderer } from '@/lib/pane-manager/terminal-complex-script'
import {
paneLeafId,
POST_REPLAY_MODE_RESET,
@ -580,12 +581,19 @@ export function connectPanePty(
// sequences don't leak into the shell. xterm.write() buffers internally
// regardless of DOM visibility and the guard stays engaged via the
// write-completion callback until xterm finishes parsing.
const writeReplayData = (data: string): void => {
if (terminalOutputRequiresDomRenderer(data)) {
manager.markPaneHasComplexScriptOutput(pane.id)
}
replayIntoTerminal(pane, deps.replayingPanesRef, data)
}
const replayDataCallback = (data: string): void => {
// Relay replay buffer holds the last 100 KB of output, which may
// overlap with content already rendered in xterm before the
// disconnect. Clear first to prevent duplication on SSH reconnect.
replayIntoTerminal(pane, deps.replayingPanesRef, '\x1b[2J\x1b[3J\x1b[H')
replayIntoTerminal(pane, deps.replayingPanesRef, data)
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(data)
}
const dataCallback = (data: string): void => {
@ -595,6 +603,9 @@ export function connectPanePty(
// keeps GPU resources from leaking. Visibility-gated buffering used
// to feed bytes into xterm at stale dimensions on resume, which was
// the root of the cursor-on-strange-line and broken-wide-char bugs.
if (terminalOutputRequiresDomRenderer(data)) {
manager.markPaneHasComplexScriptOutput(pane.id)
}
pane.terminal.write(data)
if (pendingStartupCommand) {
@ -663,12 +674,12 @@ export function connectPanePty(
// the daemon and relay are by definition tracking the same session
// and only the freshest source belongs on screen.
if (connectResult?.snapshot) {
replayIntoTerminal(pane, deps.replayingPanesRef, '\x1b[2J\x1b[3J\x1b[H')
replayIntoTerminal(pane, deps.replayingPanesRef, connectResult.snapshot)
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(connectResult.snapshot)
// Snapshot reattach keeps a live session, so avoid the broader mode
// reset. Focus reporting is the unsafe exception: preserving `?1004h`
// can make restored shells ring BEL on pane focus/blur.
replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_FOCUS_REPORTING_RESET)
writeReplayData(POST_REPLAY_FOCUS_REPORTING_RESET)
if (connectResult.coldRestore) {
// Snapshot superseded the cold-restore payload — ack it so the
// daemon does not redeliver it on the next reattach.
@ -679,9 +690,9 @@ export function connectPanePty(
// already hold pre-disconnect content; clear first to avoid
// duplication. Focus-reporting reset prevents BEL from stale mode
// bits in the replayed data.
replayIntoTerminal(pane, deps.replayingPanesRef, '\x1b[2J\x1b[3J\x1b[H')
replayIntoTerminal(pane, deps.replayingPanesRef, connectResult.replay)
replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_FOCUS_REPORTING_RESET)
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(connectResult.replay)
writeReplayData(POST_REPLAY_FOCUS_REPORTING_RESET)
if (connectResult.coldRestore) {
window.api.pty.ackColdRestore(ptyId)
}
@ -693,18 +704,14 @@ export function connectPanePty(
// may contain query sequences the previous agent CLI emitted;
// writing them through xterm.write would trigger auto-replies that
// land in the new shell's stdin. See replay-guard.ts.
replayIntoTerminal(pane, deps.replayingPanesRef, '\x1b[2J\x1b[3J\x1b[H')
replayIntoTerminal(pane, deps.replayingPanesRef, connectResult.coldRestore.scrollback)
replayIntoTerminal(
pane,
deps.replayingPanesRef,
'\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n'
)
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(connectResult.coldRestore.scrollback)
writeReplayData('\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n')
// Cold-restore means the daemon lost the session and spawned a
// fresh shell — no TUI is consuming the mode-setting bytes that a
// crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so
// reset them to match the fresh shell's expectations.
replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET)
writeReplayData(POST_REPLAY_MODE_RESET)
window.api.pty.ackColdRestore(ptyId)
}
if (connectResult?.sessionExpired) {

View File

@ -46,6 +46,7 @@ function createPane(): ManagedPaneInternal {
gpuRenderingEnabled: true,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
fitAddon: {
fit: vi.fn(),
proposeDimensions: vi.fn(() => ({ cols: 80, rows: 24 }))

View File

@ -1,7 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WebglAddon } from '@xterm/addon-webgl'
import type { ManagedPaneInternal } from './pane-manager-types'
import { attachWebgl, openTerminal, resetTerminalWebglSuggestion } from './pane-lifecycle'
import {
attachWebgl,
markComplexScriptOutput,
resetTerminalWebglSuggestion
} from './pane-webgl-renderer'
import { openTerminal } from './pane-lifecycle'
import { buildDefaultTerminalOptions } from './pane-terminal-options'
const webglMock = vi.hoisted(() => ({
@ -35,6 +40,7 @@ function createPane(): ManagedPaneInternal {
gpuRenderingEnabled: true,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
fitAddon: {
fit: vi.fn()
} as never,
@ -159,6 +165,33 @@ describe('attachWebgl', () => {
expect(forcedPane.terminal.loadAddon).toHaveBeenCalledTimes(1)
})
it('keeps auto-mode panes on DOM after complex-script output', () => {
const pane = createPane()
attachWebgl(pane)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
markComplexScriptOutput(pane)
expect(pane.hasComplexScriptOutput).toBe(true)
expect(pane.webglAddon).toBeNull()
expect(webglMock.dispose).toHaveBeenCalledTimes(1)
attachWebgl(pane)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
})
it('allows explicit on mode to override complex-script DOM fallback', () => {
const pane = createPane()
markComplexScriptOutput(pane)
pane.terminalGpuAcceleration = 'on'
attachWebgl(pane)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
})
})
describe('openTerminal — Unicode 11 ordering', () => {
@ -239,6 +272,7 @@ describe('openTerminal — Unicode 11 ordering', () => {
gpuRenderingEnabled: false,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
fitAddon,
fitResizeObserver: null,
pendingObservedFitRafId: null,

View File

@ -10,7 +10,6 @@ import { LigaturesAddon } from '@xterm/addon-ligatures'
import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { SerializeAddon } from '@xterm/addon-serialize'
import type { PaneManagerOptions, ManagedPaneInternal } from './pane-manager-types'
@ -23,25 +22,12 @@ import {
detachPaneFitResizeObserver
} from './pane-fit-resize-observer'
import { buildDefaultTerminalOptions } from './pane-terminal-options'
import type { GlobalSettings } from '../../../../shared/types'
import { ENABLE_WEBGL_RENDERER, attachWebgl, disposeWebgl } from './pane-webgl-renderer'
// ---------------------------------------------------------------------------
// Pane creation, terminal open/close, addon management
// ---------------------------------------------------------------------------
const ENABLE_WEBGL_RENDERER = true
let suggestedRendererType: 'dom' | undefined
export function resetTerminalWebglSuggestion(): void {
// Why: VS Code clears its suggested renderer when gpuAcceleration changes,
// letting "auto" retry WebGL after a user toggles the setting.
suggestedRendererType = undefined
}
function shouldUseWebgl(mode: GlobalSettings['terminalGpuAcceleration']): boolean {
return mode === 'on' || (mode === 'auto' && suggestedRendererType === undefined)
}
function getTerminalUrlOpenHint(): string {
return navigator.userAgent.includes('Mac')
? '⌘+click to open or ⇧⌘+click for system browser'
@ -124,6 +110,7 @@ export function createPaneDOM(
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
fitAddon,
fitResizeObserver: null,
pendingObservedFitRafId: null,
@ -290,74 +277,6 @@ export function setLigaturesEnabled(pane: ManagedPaneInternal, enabled: boolean)
}
}
export function disposeWebgl(
pane: ManagedPaneInternal,
options?: { refreshDimensions?: boolean }
): void {
if (!pane.webglAddon) {
return
}
try {
pane.webglAddon.dispose()
} catch {
/* ignore */
}
pane.webglAddon = null
if (options?.refreshDimensions) {
// Why: VS Code refreshes terminal dimensions after WebGL teardown because
// DOM and WebGL renderer cell metrics differ. Without this, Linux DOM
// scrollbars can desync and trigger visible reflow jitter.
requestAnimationFrame(() => {
try {
pane.fitAddon.fit()
pane.terminal.refresh(0, pane.terminal.rows - 1)
} catch {
/* ignore — pane may have been disposed in the meantime */
}
})
}
}
export function attachWebgl(pane: ManagedPaneInternal): void {
if (
!ENABLE_WEBGL_RENDERER ||
!pane.gpuRenderingEnabled ||
!shouldUseWebgl(pane.terminalGpuAcceleration) ||
pane.webglAttachmentDeferred ||
pane.webglDisabledAfterContextLoss
) {
pane.webglAddon = null
return
}
try {
const webglAddon = new WebglAddon()
webglAddon.onContextLoss(() => {
console.warn(
'[terminal] WebGL context lost for pane',
pane.id,
'— falling back to DOM renderer'
)
// Why: Chromium starts reclaiming terminal contexts under pressure.
// Recreating WebGL for this pane can loop context loss and leave xterm
// visually blank, so keep the pane on the DOM renderer until remount.
pane.webglDisabledAfterContextLoss = true
disposeWebgl(pane, { refreshDimensions: true })
})
pane.terminal.loadAddon(webglAddon)
pane.webglAddon = webglAddon
} catch (err) {
if (pane.terminalGpuAcceleration === 'auto') {
// Why: mirrors VS Code's `terminal.integrated.gpuAcceleration=auto`
// behavior: once WebGL fails, keep subsequent auto panes on DOM until
// the setting changes and resets the suggestion.
suggestedRendererType = 'dom'
}
// WebGL not available — default DOM renderer is fine, but log it for debugging
console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err)
pane.webglAddon = null
}
}
export function disposePane(
pane: ManagedPaneInternal,
panes: Map<number, ManagedPaneInternal>

View File

@ -80,6 +80,9 @@ export type ManagedPaneInternal = {
gpuRenderingEnabled: boolean
webglAttachmentDeferred: boolean
webglDisabledAfterContextLoss: boolean
// Why: complex-script shaping/RTL rendering is visibly wrong in xterm WebGL;
// keep auto-mode panes on DOM once their output proves they need browser text shaping.
hasComplexScriptOutput: boolean
webglAddon: WebglAddon | null
// Why nullable: ligatures are opt-in per font and toggleable at runtime,
// so the addon instance only exists while the feature is active. A null

View File

@ -17,14 +17,8 @@ import {
handlePaneDrop,
updateMultiPaneState
} from './pane-drag-reorder'
import {
createPaneDOM,
openTerminal,
attachWebgl,
disposeWebgl,
setLigaturesEnabled,
disposePane
} from './pane-lifecycle'
import { createPaneDOM, openTerminal, setLigaturesEnabled, disposePane } from './pane-lifecycle'
import { disposeWebgl } from './pane-webgl-renderer'
import { shouldFollowMouseFocus } from './focus-follows-mouse'
import {
findPaneChildren,
@ -40,6 +34,12 @@ import { scheduleSplitScrollRestore } from './pane-split-scroll'
import { toPublicPane } from './pane-public-view'
import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
import {
markPaneComplexScriptOutput,
resumePaneRendering,
setPaneGpuRenderingState,
suspendPaneRendering
} from './pane-rendering-control'
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
@ -224,50 +224,25 @@ export class PaneManager {
}
setPaneGpuRendering(paneId: number, enabled: boolean): void {
const pane = this.panes.get(paneId)
if (!pane) {
return
}
pane.gpuRenderingEnabled = enabled
if (!enabled) {
disposeWebgl(pane, { refreshDimensions: true })
return
}
if (pane.webglAttachmentDeferred || pane.webglDisabledAfterContextLoss) {
return
}
if (!pane.webglAddon) {
attachWebgl(pane)
safeFit(pane)
}
setPaneGpuRenderingState(this.panes, paneId, enabled)
}
setTerminalGpuAcceleration(mode: PaneManagerOptions['terminalGpuAcceleration']): void {
applyTerminalGpuAcceleration(this.panes.values(), this.options, mode)
}
markPaneHasComplexScriptOutput(paneId: number): void {
markPaneComplexScriptOutput(this.panes, paneId)
}
suspendRendering(): void {
this.renderingSuspended = true
for (const pane of this.panes.values()) {
pane.webglAttachmentDeferred = true
disposeWebgl(pane)
}
suspendPaneRendering(this.panes.values())
}
resumeRendering(): void {
this.renderingSuspended = false
for (const pane of this.panes.values()) {
pane.webglAttachmentDeferred = false
reattachWebglIfNeeded(pane)
// Why: fresh WebGL canvas has no content — refresh prevents frozen terminal.
if (pane.webglAddon) {
try {
pane.terminal.refresh(0, pane.terminal.rows - 1)
} catch {
/* ignore */
}
}
}
resumePaneRendering(this.panes.values())
}
movePane(sourcePaneId: number, targetPaneId: number, zone: DropZone): void {

View File

@ -0,0 +1,59 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { safeFit } from './pane-tree-ops'
import { attachWebgl, disposeWebgl, markComplexScriptOutput } from './pane-webgl-renderer'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
export function setPaneGpuRenderingState(
panes: Map<number, ManagedPaneInternal>,
paneId: number,
enabled: boolean
): void {
const pane = panes.get(paneId)
if (!pane) {
return
}
pane.gpuRenderingEnabled = enabled
if (!enabled) {
disposeWebgl(pane, { refreshDimensions: true })
return
}
if (pane.webglAttachmentDeferred || pane.webglDisabledAfterContextLoss) {
return
}
if (!pane.webglAddon) {
attachWebgl(pane)
safeFit(pane)
}
}
export function markPaneComplexScriptOutput(
panes: Map<number, ManagedPaneInternal>,
paneId: number
): void {
const pane = panes.get(paneId)
if (pane) {
markComplexScriptOutput(pane)
}
}
export function suspendPaneRendering(panes: Iterable<ManagedPaneInternal>): void {
for (const pane of panes) {
pane.webglAttachmentDeferred = true
disposeWebgl(pane)
}
}
export function resumePaneRendering(panes: Iterable<ManagedPaneInternal>): void {
for (const pane of panes) {
pane.webglAttachmentDeferred = false
reattachWebglIfNeeded(pane)
// Why: fresh WebGL canvas has no content — refresh prevents frozen terminal.
if (pane.webglAddon) {
try {
pane.terminal.refresh(0, pane.terminal.rows - 1)
} catch {
/* ignore */
}
}
}
}

View File

@ -16,6 +16,7 @@ function createPane(): ManagedPaneInternal {
gpuRenderingEnabled: true,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
webglAddon: {
dispose: vi.fn()
} as never,
@ -57,4 +58,15 @@ describe('applyTerminalGpuAcceleration', () => {
expect(pane.webglAddon).toBeNull()
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
})
it('returns complex-script panes to DOM when switching from forced WebGL back to auto', () => {
const pane = createPane()
pane.hasComplexScriptOutput = true
const options: PaneManagerOptions = { terminalGpuAcceleration: 'on' }
applyTerminalGpuAcceleration([pane], options, 'auto')
expect(pane.webglAddon).toBeNull()
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,5 +1,5 @@
import type { ManagedPaneInternal, PaneManagerOptions } from './pane-manager-types'
import { attachWebgl, disposeWebgl, resetTerminalWebglSuggestion } from './pane-lifecycle'
import { attachWebgl, disposeWebgl, resetTerminalWebglSuggestion } from './pane-webgl-renderer'
import { safeFit } from './pane-tree-ops'
export function applyTerminalGpuAcceleration(
@ -15,7 +15,7 @@ export function applyTerminalGpuAcceleration(
}
for (const pane of panes) {
pane.terminalGpuAcceleration = nextMode
if (nextMode === 'off') {
if (nextMode === 'off' || (nextMode === 'auto' && pane.hasComplexScriptOutput)) {
disposeWebgl(pane, { refreshDimensions: true })
continue
}
@ -23,7 +23,8 @@ export function applyTerminalGpuAcceleration(
pane.gpuRenderingEnabled &&
!pane.webglAddon &&
!pane.webglAttachmentDeferred &&
!pane.webglDisabledAfterContextLoss
!pane.webglDisabledAfterContextLoss &&
(nextMode === 'on' || !pane.hasComplexScriptOutput)
) {
attachWebgl(pane)
safeFit(pane)

View File

@ -52,6 +52,7 @@ function createPane({
gpuRenderingEnabled: true,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
fitAddon: {
fit,
proposeDimensions

View File

@ -6,7 +6,7 @@ import type {
} from './pane-manager-types'
import { createDivider } from './pane-divider'
import { getFitOverrideForPty } from './mobile-fit-overrides'
import { disposeWebgl, attachWebgl } from './pane-lifecycle'
import { disposeWebgl, attachWebgl } from './pane-webgl-renderer'
export { findLineByContent, captureScrollState, restoreScrollState } from './pane-scroll'

View File

@ -1,5 +1,5 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { attachWebgl } from './pane-lifecycle'
import { attachWebgl } from './pane-webgl-renderer'
export function reattachWebglIfNeeded(pane: ManagedPaneInternal): void {
if (pane.gpuRenderingEnabled && !pane.webglAddon && !pane.webglDisabledAfterContextLoss) {

View File

@ -0,0 +1,98 @@
import { WebglAddon } from '@xterm/addon-webgl'
import type { ManagedPaneInternal } from './pane-manager-types'
export const ENABLE_WEBGL_RENDERER = true
let suggestedRendererType: 'dom' | undefined
export function resetTerminalWebglSuggestion(): void {
// Why: VS Code clears its suggested renderer when gpuAcceleration changes,
// letting "auto" retry WebGL after a user toggles the setting.
suggestedRendererType = undefined
}
function shouldUseWebgl(pane: ManagedPaneInternal): boolean {
if (pane.terminalGpuAcceleration === 'on') {
return true
}
return (
pane.terminalGpuAcceleration === 'auto' &&
suggestedRendererType === undefined &&
!pane.hasComplexScriptOutput
)
}
export function disposeWebgl(
pane: ManagedPaneInternal,
options?: { refreshDimensions?: boolean }
): void {
if (!pane.webglAddon) {
return
}
try {
pane.webglAddon.dispose()
} catch {
/* ignore */
}
pane.webglAddon = null
if (options?.refreshDimensions) {
// Why: VS Code refreshes terminal dimensions after WebGL teardown because
// DOM and WebGL renderer cell metrics differ. Without this, Linux DOM
// scrollbars can desync and trigger visible reflow jitter.
requestAnimationFrame(() => {
try {
pane.fitAddon.fit()
pane.terminal.refresh(0, pane.terminal.rows - 1)
} catch {
/* ignore — pane may have been disposed in the meantime */
}
})
}
}
export function markComplexScriptOutput(pane: ManagedPaneInternal): void {
pane.hasComplexScriptOutput = true
if (pane.terminalGpuAcceleration !== 'auto') {
return
}
disposeWebgl(pane, { refreshDimensions: true })
}
export function attachWebgl(pane: ManagedPaneInternal): void {
if (
!ENABLE_WEBGL_RENDERER ||
!pane.gpuRenderingEnabled ||
!shouldUseWebgl(pane) ||
pane.webglAttachmentDeferred ||
pane.webglDisabledAfterContextLoss
) {
pane.webglAddon = null
return
}
try {
const webglAddon = new WebglAddon()
webglAddon.onContextLoss(() => {
console.warn(
'[terminal] WebGL context lost for pane',
pane.id,
'— falling back to DOM renderer'
)
// Why: Chromium starts reclaiming terminal contexts under pressure.
// Recreating WebGL for this pane can loop context loss and leave xterm
// visually blank, so keep the pane on the DOM renderer until remount.
pane.webglDisabledAfterContextLoss = true
disposeWebgl(pane, { refreshDimensions: true })
})
pane.terminal.loadAddon(webglAddon)
pane.webglAddon = webglAddon
} catch (err) {
if (pane.terminalGpuAcceleration === 'auto') {
// Why: mirrors VS Code's `terminal.integrated.gpuAcceleration=auto`
// behavior: once WebGL fails, keep subsequent auto panes on DOM until
// the setting changes and resets the suggestion.
suggestedRendererType = 'dom'
}
// WebGL not available — default DOM renderer is fine, but log it for debugging
console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err)
pane.webglAddon = null
}
}

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { terminalOutputRequiresDomRenderer } from './terminal-complex-script'
describe('terminalOutputRequiresDomRenderer', () => {
it('detects Arabic terminal output', () => {
expect(terminalOutputRequiresDomRenderer('Arabic: السلام عليكم')).toBe(true)
})
it('detects RTL scripts that need browser text shaping/order', () => {
expect(terminalOutputRequiresDomRenderer('Hebrew: שלום')).toBe(true)
})
it('does not disable WebGL for ordinary terminal output', () => {
expect(terminalOutputRequiresDomRenderer('abc 123 ── ✓')).toBe(false)
})
})

View File

@ -0,0 +1,8 @@
// Why: xterm's WebGL renderer draws per-cell glyphs from a texture atlas,
// bypassing the browser shaping/bidi path that the DOM renderer provides.
const COMPLEX_SCRIPT_PATTERN =
/[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u0840-\u085F\u0860-\u086F\u0870-\u089F\u08A0-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]|\u{10EC0}-\u{10EFF}|\u{1E900}-\u{1E95F}/u
export function terminalOutputRequiresDomRenderer(data: string): boolean {
return COMPLEX_SCRIPT_PATTERN.test(data)
}