diff --git a/config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch b/config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch similarity index 100% rename from config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch rename to config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch diff --git a/package.json b/package.json index dbc3f48bd..d91560e55 100644 --- a/package.json +++ b/package.json @@ -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" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a26dec090..cd90f486c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index cfa771ce9..038c9356c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index bee000737..4df722708 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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) { diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index 802ffdb42..c2cdb6e03 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -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 })) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index a5d2b370a..b17788f01 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -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, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 3fb6b304a..f9673b3fa 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 85e79dd0f..32041bd81 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 9fe3c0fb7..5dae299e0 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -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 { diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts new file mode 100644 index 000000000..af71be629 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -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, + 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, + paneId: number +): void { + const pane = panes.get(paneId) + if (pane) { + markComplexScriptOutput(pane) + } +} + +export function suspendPaneRendering(panes: Iterable): void { + for (const pane of panes) { + pane.webglAttachmentDeferred = true + disposeWebgl(pane) + } +} + +export function resumePaneRendering(panes: Iterable): 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 */ + } + } + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts index e39c2a594..509de8157 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts index dc41fffe5..3ad266020 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts @@ -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) diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index 57fe2cdd1..695f3aaeb 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -52,6 +52,7 @@ function createPane({ gpuRenderingEnabled: true, webglAttachmentDeferred: false, webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, fitAddon: { fit, proposeDimensions diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 2667c2b8f..4359e4fd7 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -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' diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts index 8c1de681f..21a839fce 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts @@ -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) { diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts new file mode 100644 index 000000000..4fd294575 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -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 + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts new file mode 100644 index 000000000..0c27e91ba --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts new file mode 100644 index 000000000..6d7095ca1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts @@ -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) +}