diff --git a/ui/src/components/workspace/Terminal.tsx b/ui/src/components/workspace/Terminal.tsx index 6204e32d..50a57894 100644 --- a/ui/src/components/workspace/Terminal.tsx +++ b/ui/src/components/workspace/Terminal.tsx @@ -3,7 +3,6 @@ import type { ReactElement } from 'react'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; -import { WebglAddon } from '@xterm/addon-webgl'; import { Terminal as Xterm } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; @@ -11,6 +10,7 @@ import { parseServerControl, type ClientControlMessage, } from './protocol'; +import { attachWebglRenderer } from './renderer'; import { darkTheme } from './theme'; // Lazy-import so the demo subtree (transcripts, fixtures, handlers) is // dynamic-imported only when demo mode is actually on. With a static import, @@ -127,14 +127,11 @@ export function TerminalView(props: TerminalViewProps): ReactElement { term.loadAddon(new WebLinksAddon()); term.open(container); - let webgl: WebglAddon | null = null; - try { - webgl = new WebglAddon(); - webgl.onContextLoss(() => webgl?.dispose()); - term.loadAddon(webgl); - } catch { - webgl = null; - } + // WebGL by default; degrades to the DOM renderer on addon failure / + // context loss, or when the `openalice.terminal.renderer` escape hatch + // forces 'dom' (GPU-pipeline corruption can't be auto-detected — see + // renderer.ts). + const webgl = attachWebglRenderer(term); safeFit(fit); let lastCols = term.cols; diff --git a/ui/src/components/workspace/__tests__/renderer.spec.ts b/ui/src/components/workspace/__tests__/renderer.spec.ts new file mode 100644 index 00000000..fe41f9bd --- /dev/null +++ b/ui/src/components/workspace/__tests__/renderer.spec.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Terminal } from '@xterm/xterm' + +// Observe construction/load/dispose instead of real WebGL (jsdom has none). +const disposeSpy = vi.fn() +const onContextLossSpy = vi.fn() +let constructed = 0 +let throwOnLoad = false + +vi.mock('@xterm/addon-webgl', () => ({ + WebglAddon: class { + onContextLoss = onContextLossSpy + dispose = disposeSpy + constructor() { + constructed++ + } + }, +})) + +const { attachWebglRenderer } = await import('../renderer') + +function fakeTerm(): Terminal { + return { + loadAddon: vi.fn(() => { + if (throwOnLoad) throw new Error('no webgl context') + }), + } as unknown as Terminal +} + +afterEach(() => { + localStorage.clear() + vi.clearAllMocks() + constructed = 0 + throwOnLoad = false +}) + +describe('attachWebglRenderer', () => { + it('loads the WebGL addon by default and returns it', () => { + const term = fakeTerm() + const addon = attachWebglRenderer(term) + expect(constructed).toBe(1) + expect(term.loadAddon).toHaveBeenCalledTimes(1) + expect(addon).not.toBeNull() + }) + + it('escape hatch: renderer=dom skips WebGL without constructing the addon', () => { + localStorage.setItem('openalice.terminal.renderer', 'dom') + const term = fakeTerm() + expect(attachWebglRenderer(term)).toBeNull() + expect(constructed).toBe(0) + expect(term.loadAddon).not.toHaveBeenCalled() + }) + + it('any other flag value keeps the WebGL default', () => { + localStorage.setItem('openalice.terminal.renderer', 'webgl') + expect(attachWebglRenderer(fakeTerm())).not.toBeNull() + expect(constructed).toBe(1) + }) + + it('degrades to null and disposes the addon when loading throws', () => { + throwOnLoad = true + expect(attachWebglRenderer(fakeTerm())).toBeNull() + expect(constructed).toBe(1) + expect(disposeSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/src/components/workspace/renderer.ts b/ui/src/components/workspace/renderer.ts new file mode 100644 index 00000000..e20da82e --- /dev/null +++ b/ui/src/components/workspace/renderer.ts @@ -0,0 +1,55 @@ +import { WebglAddon } from '@xterm/addon-webgl'; +import type { Terminal } from '@xterm/xterm'; + +/** + * Renderer escape hatch for the workspace terminal (and the demo replay). + * + * The WebGL renderer's glyph rasterization is plain Canvas2D (shared with the + * DOM renderer), but the rasterized glyphs then ride a GPU pipeline — texture + * upload, WebGL context, driver compositing — and that pipeline has a known + * family of environment-specific corruption bugs (black boxes over CJK, + * garbled cells; see e.g. microsoft/vscode#137047, #163936, #288682 — same + * xterm atlas). None of them throw, so they can't be auto-detected; VS Code's + * answer is the `terminal.integrated.gpuAcceleration` escape hatch. + * + * Ours is this localStorage flag: + * + * localStorage.setItem('openalice.terminal.renderer', 'dom'); // + reload + * + * Anything other than 'dom' (including unset) keeps the WebGL default. On top + * of the flag, the loader degrades to the DOM renderer automatically when the + * addon throws at construction/load or the WebGL context is lost. + */ +const RENDERER_STORAGE_KEY = 'openalice.terminal.renderer'; + +function webglDisabled(): boolean { + try { + return localStorage.getItem(RENDERER_STORAGE_KEY) === 'dom'; + } catch { + return false; // storage unavailable → default renderer path + } +} + +/** + * Try to attach the WebGL renderer to `term`. Returns the addon (caller owns + * disposal on unmount) or null when the flag forces DOM / the addon failed — + * xterm keeps/reverts to its built-in DOM renderer in both cases, which is + * also what happens automatically on a later context loss. + */ +export function attachWebglRenderer(term: Terminal): WebglAddon | null { + if (webglDisabled()) return null; + let webgl: WebglAddon | null = null; + try { + webgl = new WebglAddon(); + webgl.onContextLoss(() => webgl?.dispose()); + term.loadAddon(webgl); + return webgl; + } catch { + try { + webgl?.dispose(); + } catch { + /* ignore */ + } + return null; + } +} diff --git a/ui/src/demo/DemoTerminalReplay.tsx b/ui/src/demo/DemoTerminalReplay.tsx index 2e382ee6..fdb9ae7d 100644 --- a/ui/src/demo/DemoTerminalReplay.tsx +++ b/ui/src/demo/DemoTerminalReplay.tsx @@ -3,10 +3,11 @@ import type { ReactElement } from 'react' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' -import { WebglAddon } from '@xterm/addon-webgl' +import type { WebglAddon } from '@xterm/addon-webgl' import { Terminal as Xterm } from '@xterm/xterm' import '@xterm/xterm/css/xterm.css' +import { attachWebglRenderer } from '../components/workspace/renderer' import { darkTheme } from '../components/workspace/theme' import { DemoTerminalStub } from './DemoTerminalStub' import { transcriptsByWorkspace } from './fixtures/transcripts' @@ -93,12 +94,9 @@ function ReplayPane({ label, transcript }: { label: string; transcript: Transcri window.setTimeout(init, 25) return } - try { - webgl = new WebglAddon() - term.loadAddon(webgl) - } catch { - // WebGL addon is best-effort; fall back to DOM renderer silently. - } + // Best-effort WebGL (shared loader: escape-hatch flag + context-loss + // degradation); falls back to the DOM renderer silently. + webgl = attachWebglRenderer(term) try { fit.fit() } catch { /* noop */ } resizeObserver = new ResizeObserver(() => { try { fit.fit() } catch { /* noop */ }