Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions ui/src/components/workspace/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ 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';

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,
Expand Down Expand Up @@ -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;
Expand Down
67 changes: 67 additions & 0 deletions ui/src/components/workspace/__tests__/renderer.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
55 changes: 55 additions & 0 deletions ui/src/components/workspace/renderer.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 5 additions & 7 deletions ui/src/demo/DemoTerminalReplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 */ }
Expand Down
Loading