diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d8848058..fec14aec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -84,6 +84,11 @@ open ([macOS release workflow](../.github/workflows/release.yml) `current`, [commands table](../src/terminal/tab-manager.ts#L946-L1024) `current`, [menu generator](../scripts/generate-menu.ts) `current`). - The macOS menu path can't tell an accelerator from a mouse click (Tauri's `MenuEvent` carries only an id), so only `destructive: true` actions (`close-pane`/`close-tab`/`clear-buffer`) are suppressed while a chrome text field holds the caret — every other action still runs there — [ActionDefinition.destructive](../src/terminal/action-registry.ts#L82-L115) `current`, [runAction](../src/terminal/tab-manager.ts#L1032-L1054) `current`. +- Terminal panes use xterm's official WebGL renderer so continuous block and + box-drawing glyphs used by agent TUIs render without DOM-renderer seams. It + is loaded only after `Terminal.open()` and disposes on initialization failure + or WebGL context loss, preserving xterm's DOM renderer as a compatibility + fallback ([pane.ts](../src/terminal/pane.ts) `current`). - Windows uses native decorated system chrome; Preact renders only Deck's internal toolbar and omits synthetic minimize/maximize/close controls ([tauri.windows.conf.json](../src-tauri/tauri.windows.conf.json) `current`, diff --git a/package-lock.json b/package-lock.json index cfdc5aef..8e17fabd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode-graphemes": "^0.4.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "ogl": "^1.0.11", "preact": "^10.29.3", @@ -2080,6 +2081,12 @@ "integrity": "sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==", "license": "MIT" }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/package.json b/package.json index 8399c177..ea379f55 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode-graphemes": "^0.4.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "ogl": "^1.0.11", "preact": "^10.29.3", diff --git a/src/terminal/pane.test.ts b/src/terminal/pane.test.ts new file mode 100644 index 00000000..6c163234 --- /dev/null +++ b/src/terminal/pane.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SETTINGS } from "../settings/settings-schema"; + +const xterm = vi.hoisted(() => ({ + constructorOptions: undefined as Record | undefined, + opened: false, +})); + +const webgl = vi.hoisted(() => ({ + instances: [] as Array<{ + activatedAfterOpen: boolean; + disposed: number; + emitContextLoss(): void; + }>, + throwOnActivate: false, +})); + +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + options: Record; + unicode = { activeVersion: "" }; + parser = { registerOscHandler: () => ({ dispose() {} }) }; + buffer = { active: { type: "normal" } }; + cols = 80; + + constructor(options: Record) { + xterm.constructorOptions = options; + this.options = options; + } + open() { + xterm.opened = true; + } + loadAddon(addon: { activate?(terminal: unknown): void }) { + addon.activate?.(this); + } + attachCustomWheelEventHandler() {} + registerLinkProvider() { + return { dispose() {} }; + } + onBell() { + return { dispose() {} }; + } + onData() {} + onResize() {} + getSelectionPosition() { + return undefined; + } + paste() {} + dispose() {} + }, +})); + +vi.mock("@xterm/addon-webgl", () => ({ + WebglAddon: class { + activatedAfterOpen = false; + disposed = 0; + private contextLossHandler: (() => void) | undefined; + + constructor() { + webgl.instances.push(this); + } + activate() { + this.activatedAfterOpen = xterm.opened; + if (webgl.throwOnActivate) throw new Error("WebGL2 unavailable"); + } + onContextLoss(handler: () => void) { + this.contextLossHandler = handler; + return { dispose() {} }; + } + emitContextLoss() { + this.contextLossHandler?.(); + } + dispose() { + this.disposed += 1; + } + }, +})); + +vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { fit() {} } })); +vi.mock("@xterm/addon-search", () => ({ SearchAddon: class {} })); +vi.mock("@xterm/addon-unicode-graphemes", () => ({ UnicodeGraphemesAddon: class {} })); +vi.mock("./webkit-ime-fix", () => ({ applyWebkitImeFix: vi.fn(), isWebKitWebView: () => false })); +vi.mock("./shift-enter", () => ({ installShiftEnterNewline: () => vi.fn() })); +vi.mock("./ime-trace", () => ({ installImeTrace: vi.fn() })); +vi.mock("../settings/themes", () => ({ resolveTheme: () => ({}) })); +vi.mock("./link-provider", () => ({ createLinkProvider: () => ({}) })); +vi.mock("./osc-link-handler", () => ({ createOscLinkHandler: () => ({}) })); +vi.mock("./pane-cwd", () => ({ paneCwd: () => "" })); +vi.mock("../lib/osc-notification", () => ({ classifyOscNotification: () => null })); +vi.mock("./terminal-clipboard", () => ({ copyTerminalSelection: vi.fn(), pasteIntoTerminal: vi.fn() })); +vi.mock("../lib/platform", () => ({ getDesktopEnvironment: () => ({ platform: "windows" }) })); +vi.mock("./codex-wheel", () => ({ createCodexWheelHandler: () => vi.fn() })); +vi.mock("./pane-background", () => ({ applyPaneBackground: vi.fn(), paneUsesBackgroundImage: () => false })); + +import { createPane } from "./pane"; + +class ResizeObserverStub { + observe() {} + disconnect() {} +} + +beforeEach(() => { + xterm.constructorOptions = undefined; + xterm.opened = false; + webgl.instances = []; + webgl.throwOnActivate = false; + vi.stubGlobal("ResizeObserver", ResizeObserverStub); +}); + +const events = { + onData: async () => true, + onResize() {}, + onFocus() {}, +}; + +describe("createPane OpenCode glyph rendering", () => { + it("keeps terminal rows flush", () => { + createPane(1, DEFAULT_SETTINGS, events); + expect(xterm.constructorOptions?.lineHeight).toBe(1); + }); + + it("loads WebGL only after the terminal is open", () => { + const pane = createPane(1, DEFAULT_SETTINGS, events); + pane.mount(); + expect(webgl.instances[0].activatedAfterOpen).toBe(true); + }); + + it("falls back when WebGL cannot initialize", () => { + webgl.throwOnActivate = true; + const pane = createPane(1, DEFAULT_SETTINGS, events); + expect(() => pane.mount()).not.toThrow(); + expect(webgl.instances[0].disposed).toBe(1); + }); + + it("disposes WebGL on context loss", () => { + const pane = createPane(1, DEFAULT_SETTINGS, events); + pane.mount(); + webgl.instances[0].emitContextLoss(); + expect(webgl.instances[0].disposed).toBe(1); + }); +}); diff --git a/src/terminal/pane.ts b/src/terminal/pane.ts index 9ae4255e..094ea03a 100644 --- a/src/terminal/pane.ts +++ b/src/terminal/pane.ts @@ -2,6 +2,7 @@ import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; +import { WebglAddon } from "@xterm/addon-webgl"; import { FONT_FALLBACK, type Settings } from "../settings/settings-schema"; import { applyWebkitImeFix, isWebKitWebView } from "./webkit-ime-fix"; import { installShiftEnterNewline } from "./shift-enter"; @@ -156,7 +157,9 @@ export function createPane( cursorWidth: 1, fontSize: initial.fontSize, fontFamily: toFontStack(initial.fontFamily), - lineHeight: 1.25, + // Keep cell rows flush: OpenCode joins block and box-drawing glyphs across + // rows, and extra leading slices its wordmark and prompt borders apart. + lineHeight: 1, scrollback: initial.scrollback, // Option must stay a character key on macOS so IMEs (Vietnamese Telex, // dead-key accents) can compose — `true` swallows it as Meta. @@ -279,9 +282,27 @@ export function createPane( }); let opened = false; + function loadWebglRenderer(): void { + let addon: WebglAddon | undefined; + try { + addon = new WebglAddon(); + const renderer = addon; + renderer.onContextLoss(() => renderer.dispose()); + term.loadAddon(renderer); + } catch { + // WebGL is optional. Disposing a partially activated addon restores + // xterm's DOM renderer, which keeps the terminal usable on older GPUs. + addon?.dispose(); + } + } + function mount(): void { if (!opened) { term.open(termEl); + // xterm's DOM renderer does not support custom glyphs, so block and + // box-drawing characters used by TUIs such as OpenCode look segmented. + // WebGL draws those glyphs continuously and must be loaded after open(). + loadWebglRenderer(); if (isWebKitWebView()) { applyWebkitImeFix(term); }