/** * Terminal adapter backed by ghostty-web. * * ghostty-web exposes an xterm.js-compatible API surface (Terminal class, * onData / onResize events, loadAddon, FitAddon). WASM must be initialised * via `await init()` before the first Terminal is created — that happens * once in main.tsx. */ import { Terminal, FitAddon } from "ghostty-web"; import type { ITerminalAdapter } from "./types"; export interface TerminalAdapterOptions { cursorBlink?: boolean; fontSize?: number; fontFamily?: string; } const ACTIVE_BURST_MS = 250; const CURSOR_PULSE_MS = 530; const CURSOR_PULSE_BURST_MS = 64; const DEFAULT_FONT_SIZE = 14; const DEFAULT_FONT_FAMILY = '"JetBrains Mono", "SF Mono", "Fira Code", monospace'; const TERMINAL_HEIGHT_PADDING_PX = 2; const TERMINAL_BASELINE_PADDING_PX = 1; export class GhosttyTerminalAdapter implements ITerminalAdapter { private terminal: Terminal | null = null; private fitAddon: FitAddon | null = null; private disposables: Array<{ dispose(): void }> = []; private originalStartRenderLoop: (() => void) | null = null; private renderPauseTimer: ReturnType | null = null; private cursorPulseIntervalId: ReturnType | null = null; constructor(private opts: TerminalAdapterOptions = {}) {} get cols(): number { return this.terminal?.cols ?? 80; } get rows(): number { return this.terminal?.rows ?? 24; } mount(container: HTMLElement): void { if (this.terminal) return; this.terminal = new Terminal({ cursorBlink: this.opts.cursorBlink ?? true, fontSize: this.opts.fontSize ?? DEFAULT_FONT_SIZE, fontFamily: this.opts.fontFamily ?? DEFAULT_FONT_FAMILY, scrollback: 10_000, theme: { background: "#221f21", foreground: "#f5f4f4", cursor: "#f5f4f4", selectionBackground: "rgba(201, 111, 122, 0.25)", }, }); this.fitAddon = new FitAddon(); this.terminal.loadAddon(this.fitAddon); this.terminal.open(container); this.stabilizeRendererMetrics(); const mountedTerminal = this.terminal; this.captureNativeRenderLoop(); this.resumeRenderBurst(ACTIVE_BURST_MS); this.syncCursorPulseState(); // ghostty-web's WASM key encoder may not reliably produce control codes // for Ctrl+letter combinations, and the contenteditable container can // cause the browser to intercept Ctrl+C as clipboard copy. Handle // Ctrl+letter directly: emit the control code via terminal.input() and // return true to suppress ghostty-web's default handling. const term = this.terminal; term.attachCustomKeyEventHandler((event: KeyboardEvent) => { if (event.type !== "keydown") return false; // Let Ctrl+V / Cmd+V through for browser paste if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { return false; } // Ctrl+C with an active selection → let browser copy if (event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "c" && term.hasSelection()) { return false; } // Ctrl+letter → emit control code directly (Ctrl+A=\x01 … Ctrl+Z=\x1a) if (event.ctrlKey && !event.altKey && !event.metaKey) { const key = event.key.toLowerCase(); if (key.length === 1 && key >= "a" && key <= "z") { const code = key.charCodeAt(0) - 96; // 'a'=97 → 1, 'c'=99 → 3 term.input(String.fromCharCode(code), true); return true; } } return false; }); const schedulePointerBurst = () => this.resumeRenderBurst(ACTIVE_BURST_MS); container.addEventListener("mousemove", schedulePointerBurst); container.addEventListener("mouseleave", schedulePointerBurst); this.disposables.push({ dispose: () => { container.removeEventListener("mousemove", schedulePointerBurst); container.removeEventListener("mouseleave", schedulePointerBurst); }, }); const scrollDisposable = this.getInternalTerminal()?.onScroll?.(() => { this.resumeRenderBurst(ACTIVE_BURST_MS); }); if (scrollDisposable) this.disposables.push(scrollDisposable); const selectionDisposable = this.getInternalTerminal()?.onSelectionChange?.(() => { this.resumeRenderBurst(ACTIVE_BURST_MS); }); if (selectionDisposable) this.disposables.push(selectionDisposable); const handleVisibilityChange = () => { if (document.visibilityState !== "visible") { this.stopCursorPulse(); this.pauseRenderLoop(); return; } this.resumeRenderBurst(ACTIVE_BURST_MS); this.syncCursorPulseState(); }; const handleFocusIn = () => { this.resumeRenderBurst(ACTIVE_BURST_MS); this.startCursorPulse(); }; const handleFocusOut = () => { this.resumeRenderBurst(ACTIVE_BURST_MS); this.stopCursorPulse(); }; document.addEventListener("visibilitychange", handleVisibilityChange); container.addEventListener("focusin", handleFocusIn); container.addEventListener("focusout", handleFocusOut); this.disposables.push({ dispose: () => { document.removeEventListener("visibilitychange", handleVisibilityChange); container.removeEventListener("focusin", handleFocusIn); container.removeEventListener("focusout", handleFocusOut); }, }); // Delay initial fit until the container has paint dimensions, then keep // Ghostty's native RAF loop alive long enough to settle glyph metrics. requestAnimationFrame(() => { if (this.terminal !== mountedTerminal) return; this.fitAddon?.fit(); this.resumeRenderBurst(ACTIVE_BURST_MS); }); const fontsReady = getDocumentFontsReady(); if (fontsReady) { void fontsReady .then(() => { if (this.terminal !== mountedTerminal) return; this.stabilizeRendererMetrics(); this.fit(); }) .catch(() => { // Best-effort only. If font loading fails, fall back to the current size. }); } } write(data: string | Uint8Array): void { this.terminal?.write(data); this.resumeRenderBurst(ACTIVE_BURST_MS); } onInput(callback: (data: string) => void): void { const d = this.terminal?.onData(callback); if (d) this.disposables.push(d); } onResize(callback: (cols: number, rows: number) => void): void { const d = this.terminal?.onResize(({ cols, rows }: { cols: number; rows: number }) => callback(cols, rows)); if (d) this.disposables.push(d); } fit(): void { this.stabilizeRendererMetrics(); this.fitAddon?.fit(); this.resumeRenderBurst(ACTIVE_BURST_MS); } async prepareForAttach(): Promise<{ cols: number; rows: number }> { // The first fit can race with canvas metrics and font readiness. // Retry before opening the PTY so full-screen TUIs start at the // correct size instead of staying at the worker's 80x24 default. this.fit(); await nextAnimationFrame(); this.fit(); await waitForFontsReady(); await nextAnimationFrame(); this.fit(); return { cols: this.cols, rows: this.rows, }; } reset(): void { this.terminal?.reset(); this.resumeRenderBurst(ACTIVE_BURST_MS); } focus(): void { this.terminal?.focus(); this.resumeRenderBurst(ACTIVE_BURST_MS); this.startCursorPulse(); } dispose(): void { for (const d of this.disposables) d.dispose(); this.disposables = []; this.stopCursorPulse(); this.pauseRenderLoop(); this.fitAddon = null; this.originalStartRenderLoop = null; this.terminal?.dispose(); this.terminal = null; } private getInternalTerminal(): GhosttyTerminalInternal | null { return this.terminal as GhosttyTerminalInternal | null; } private captureNativeRenderLoop(): void { const terminal = this.getInternalTerminal(); if (!terminal) return; this.originalStartRenderLoop = terminal.startRenderLoop?.bind(this.terminal) ?? null; } private resumeRenderBurst(durationMs: number): void { if (document.visibilityState !== "visible") { return; } const terminal = this.getInternalTerminal(); if (!terminal || !this.originalStartRenderLoop) { return; } if (typeof terminal.animationFrameId !== "number") { this.originalStartRenderLoop(); } this.scheduleRenderPause(durationMs); } private scheduleRenderPause(durationMs: number): void { if (this.renderPauseTimer !== null) { clearTimeout(this.renderPauseTimer); } this.renderPauseTimer = setTimeout(() => { this.renderPauseTimer = null; this.pauseRenderLoop(); }, durationMs); } private pauseRenderLoop(): void { if (this.renderPauseTimer !== null) { clearTimeout(this.renderPauseTimer); this.renderPauseTimer = null; } const terminal = this.getInternalTerminal(); if (typeof terminal?.animationFrameId === "number") { cancelAnimationFrame(terminal.animationFrameId); terminal.animationFrameId = undefined; } } private syncCursorPulseState(): void { if (this.shouldPulseCursor()) { this.startCursorPulse(); return; } this.stopCursorPulse(); } private startCursorPulse(): void { if (!this.opts.cursorBlink || this.cursorPulseIntervalId !== null) { return; } this.cursorPulseIntervalId = window.setInterval(() => { if (!this.shouldPulseCursor()) { this.stopCursorPulse(); return; } this.resumeRenderBurst(CURSOR_PULSE_BURST_MS); }, CURSOR_PULSE_MS); } private stopCursorPulse(): void { if (this.cursorPulseIntervalId === null) { return; } clearInterval(this.cursorPulseIntervalId); this.cursorPulseIntervalId = null; } private shouldPulseCursor(): boolean { if (document.visibilityState !== "visible") { return false; } const terminal = this.getInternalTerminal(); const element = terminal?.element; if (!element) { return false; } const activeElement = document.activeElement; return !!activeElement && (activeElement === element || element.contains(activeElement)); } private stabilizeRendererMetrics(): void { const terminal = this.getInternalTerminal(); const renderer = terminal?.renderer; if (!terminal || !renderer?.remeasureFont) { return; } const fontSize = terminal.options?.fontSize ?? this.opts.fontSize ?? DEFAULT_FONT_SIZE; const fontFamily = terminal.options?.fontFamily ?? this.opts.fontFamily ?? DEFAULT_FONT_FAMILY; const measureFont = createTerminalMeasureFont(fontSize, fontFamily); if (!measureFont) { return; } renderer.measureFont = measureFont; renderer.remeasureFont(); const cols = terminal.cols ?? this.cols; const rows = terminal.rows ?? this.rows; if (renderer.resize && cols > 0 && rows > 0) { renderer.resize(cols, rows); } if (renderer.render && terminal.wasmTerm) { renderer.render(terminal.wasmTerm, true, terminal.viewportY ?? 0, terminal); } } } interface GhosttyRenderDisposable { dispose(): void; } interface GhosttyTerminalInternal { animationFrameId?: number; canvas?: HTMLCanvasElement; cols?: number; element?: HTMLElement; onScroll?: (listener: () => void) => GhosttyRenderDisposable; onSelectionChange?: (listener: () => void) => GhosttyRenderDisposable; options?: { fontFamily?: string; fontSize?: number; }; renderer?: GhosttyCanvasRendererInternal; rows?: number; startRenderLoop?: () => void; viewportY?: number; wasmTerm?: unknown; } interface GhosttyCanvasRendererInternal { measureFont?: () => GhosttyFontMetrics; remeasureFont?: () => void; render?: ( buffer: unknown, forceAll?: boolean, viewportY?: number, scrollbackProvider?: unknown, scrollbarOpacity?: number, ) => void; resize?: (cols: number, rows: number) => void; } interface GhosttyFontMetrics { baseline: number; height: number; width: number; } function nextAnimationFrame(): Promise { return new Promise((resolve) => { if (typeof requestAnimationFrame === "function") { requestAnimationFrame(() => resolve()); return; } setTimeout(resolve, 16); }); } async function waitForFontsReady(timeoutMs = 250): Promise { const ready = getDocumentFontsReady(); if (!ready) return; try { await Promise.race([ready.then(() => undefined), new Promise((resolve) => setTimeout(resolve, timeoutMs))]); } catch { // Best-effort only. If font loading fails, fall back to the current size. } } function getDocumentFontsReady(): Promise | undefined { const fonts = ( document as Document & { fonts?: { ready?: Promise }; } ).fonts; return fonts?.ready; } function createTerminalMeasureFont(fontSize: number, fontFamily: string): (() => GhosttyFontMetrics) | undefined { const metrics = measureTerminalFont(fontSize, fontFamily); if (!metrics) { return undefined; } return () => measureTerminalFont(fontSize, fontFamily) ?? metrics; } function measureTerminalFont(fontSize: number, fontFamily: string): GhosttyFontMetrics | undefined { const context = document.createElement("canvas").getContext("2d"); if (!context) { return undefined; } context.font = `${fontSize}px ${fontFamily}`; // Use a descender in the sample so both Chromium and Firefox include enough // vertical ink when deriving terminal cell height from text bounds. const sampleMetrics = context.measureText("Mg"); const widthMetrics = context.measureText("M"); const ascent = getTextMetric(sampleMetrics, "actualBoundingBoxAscent") ?? getTextMetric(sampleMetrics, "fontBoundingBoxAscent") ?? getTextMetric(sampleMetrics, "emHeightAscent") ?? fontSize * 0.8; const descent = getTextMetric(sampleMetrics, "actualBoundingBoxDescent") ?? getTextMetric(sampleMetrics, "fontBoundingBoxDescent") ?? getTextMetric(sampleMetrics, "emHeightDescent") ?? fontSize * 0.2; return { width: Math.max(1, Math.ceil(widthMetrics.width)), height: Math.max(1, Math.ceil(ascent + descent) + TERMINAL_HEIGHT_PADDING_PX), baseline: Math.max(1, Math.ceil(ascent) + TERMINAL_BASELINE_PADDING_PX), }; } function getTextMetric( metrics: TextMetrics, key: | "actualBoundingBoxAscent" | "actualBoundingBoxDescent" | "emHeightAscent" | "emHeightDescent" | "fontBoundingBoxAscent" | "fontBoundingBoxDescent", ): number | undefined { const value = metrics[key]; if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return undefined; } return value; }