Skip to content
Closed
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
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
142 changes: 142 additions & 0 deletions src/terminal/pane.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, unknown>;
unicode = { activeVersion: "" };
parser = { registerOscHandler: () => ({ dispose() {} }) };
buffer = { active: { type: "normal" } };
cols = 80;

constructor(options: Record<string, unknown>) {
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);
});
});
23 changes: 22 additions & 1 deletion src/terminal/pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down
Loading