Skip to content
File

Blob: src/client/terminal/adapter.ts

typescript482 lines
1/**
2 * Terminal adapter backed by ghostty-web.
3 *
4 * ghostty-web exposes an xterm.js-compatible API surface (Terminal class,
5 * onData / onResize events, loadAddon, FitAddon). WASM must be initialised
6 * via `await init()` before the first Terminal is created — that happens
7 * once in main.tsx.
8 */
9import { Terminal, FitAddon } from "ghostty-web";
10import type { ITerminalAdapter } from "./types";
11 
12export interface TerminalAdapterOptions {
13 cursorBlink?: boolean;
14 fontSize?: number;
15 fontFamily?: string;
16}
17 
18const ACTIVE_BURST_MS = 250;
19const CURSOR_PULSE_MS = 530;
20const CURSOR_PULSE_BURST_MS = 64;
21const DEFAULT_FONT_SIZE = 14;
22const DEFAULT_FONT_FAMILY = '"JetBrains Mono", "SF Mono", "Fira Code", monospace';
23const TERMINAL_HEIGHT_PADDING_PX = 2;
24const TERMINAL_BASELINE_PADDING_PX = 1;
25 
26export class GhosttyTerminalAdapter implements ITerminalAdapter {
27 private terminal: Terminal | null = null;
28 private fitAddon: FitAddon | null = null;
29 private disposables: Array<{ dispose(): void }> = [];
30 private originalStartRenderLoop: (() => void) | null = null;
31 private renderPauseTimer: ReturnType<typeof setTimeout> | null = null;
32 private cursorPulseIntervalId: ReturnType<typeof setInterval> | null = null;
33 
34 constructor(private opts: TerminalAdapterOptions = {}) {}
35 
36 get cols(): number {
37 return this.terminal?.cols ?? 80;
38 }
39 
40 get rows(): number {
41 return this.terminal?.rows ?? 24;
42 }
43 
44 mount(container: HTMLElement): void {
45 if (this.terminal) return;
46 
47 this.terminal = new Terminal({
48 cursorBlink: this.opts.cursorBlink ?? true,
49 fontSize: this.opts.fontSize ?? DEFAULT_FONT_SIZE,
50 fontFamily: this.opts.fontFamily ?? DEFAULT_FONT_FAMILY,
51 scrollback: 10_000,
52 theme: {
53 background: "#221f21",
54 foreground: "#f5f4f4",
55 cursor: "#f5f4f4",
56 selectionBackground: "rgba(201, 111, 122, 0.25)",
57 },
58 });
59 
60 this.fitAddon = new FitAddon();
61 this.terminal.loadAddon(this.fitAddon);
62 this.terminal.open(container);
63 this.stabilizeRendererMetrics();
64 const mountedTerminal = this.terminal;
65 this.captureNativeRenderLoop();
66 this.resumeRenderBurst(ACTIVE_BURST_MS);
67 this.syncCursorPulseState();
68 
69 // ghostty-web's WASM key encoder may not reliably produce control codes
70 // for Ctrl+letter combinations, and the contenteditable container can
71 // cause the browser to intercept Ctrl+C as clipboard copy. Handle
72 // Ctrl+letter directly: emit the control code via terminal.input() and
73 // return true to suppress ghostty-web's default handling.
74 const term = this.terminal;
75 term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
76 if (event.type !== "keydown") return false;
77 
78 // Let Ctrl+V / Cmd+V through for browser paste
79 if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
80 return false;
81 }
82 
83 // Ctrl+C with an active selection → let browser copy
84 if (event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "c" && term.hasSelection()) {
85 return false;
86 }
87 
88 // Ctrl+letter → emit control code directly (Ctrl+A=\x01 … Ctrl+Z=\x1a)
89 if (event.ctrlKey && !event.altKey && !event.metaKey) {
90 const key = event.key.toLowerCase();
91 if (key.length === 1 && key >= "a" && key <= "z") {
92 const code = key.charCodeAt(0) - 96; // 'a'=97 → 1, 'c'=99 → 3
93 term.input(String.fromCharCode(code), true);
94 return true;
95 }
96 }
97 
98 return false;
99 });
100 
101 const schedulePointerBurst = () => this.resumeRenderBurst(ACTIVE_BURST_MS);
102 container.addEventListener("mousemove", schedulePointerBurst);
103 container.addEventListener("mouseleave", schedulePointerBurst);
104 this.disposables.push({
105 dispose: () => {
106 container.removeEventListener("mousemove", schedulePointerBurst);
107 container.removeEventListener("mouseleave", schedulePointerBurst);
108 },
109 });
110 
111 const scrollDisposable = this.getInternalTerminal()?.onScroll?.(() => {
112 this.resumeRenderBurst(ACTIVE_BURST_MS);
113 });
114 if (scrollDisposable) this.disposables.push(scrollDisposable);
115 
116 const selectionDisposable = this.getInternalTerminal()?.onSelectionChange?.(() => {
117 this.resumeRenderBurst(ACTIVE_BURST_MS);
118 });
119 if (selectionDisposable) this.disposables.push(selectionDisposable);
120 
121 const handleVisibilityChange = () => {
122 if (document.visibilityState !== "visible") {
123 this.stopCursorPulse();
124 this.pauseRenderLoop();
125 return;
126 }
127 this.resumeRenderBurst(ACTIVE_BURST_MS);
128 this.syncCursorPulseState();
129 };
130 const handleFocusIn = () => {
131 this.resumeRenderBurst(ACTIVE_BURST_MS);
132 this.startCursorPulse();
133 };
134 const handleFocusOut = () => {
135 this.resumeRenderBurst(ACTIVE_BURST_MS);
136 this.stopCursorPulse();
137 };
138 document.addEventListener("visibilitychange", handleVisibilityChange);
139 container.addEventListener("focusin", handleFocusIn);
140 container.addEventListener("focusout", handleFocusOut);
141 this.disposables.push({
142 dispose: () => {
143 document.removeEventListener("visibilitychange", handleVisibilityChange);
144 container.removeEventListener("focusin", handleFocusIn);
145 container.removeEventListener("focusout", handleFocusOut);
146 },
147 });
148 
149 // Delay initial fit until the container has paint dimensions, then keep
150 // Ghostty's native RAF loop alive long enough to settle glyph metrics.
151 requestAnimationFrame(() => {
152 if (this.terminal !== mountedTerminal) return;
153 this.fitAddon?.fit();
154 this.resumeRenderBurst(ACTIVE_BURST_MS);
155 });
156 
157 const fontsReady = getDocumentFontsReady();
158 if (fontsReady) {
159 void fontsReady
160 .then(() => {
161 if (this.terminal !== mountedTerminal) return;
162 this.stabilizeRendererMetrics();
163 this.fit();
164 })
165 .catch(() => {
166 // Best-effort only. If font loading fails, fall back to the current size.
167 });
168 }
169 }
170 
171 write(data: string | Uint8Array): void {
172 this.terminal?.write(data);
173 this.resumeRenderBurst(ACTIVE_BURST_MS);
174 }
175 
176 onInput(callback: (data: string) => void): void {
177 const d = this.terminal?.onData(callback);
178 if (d) this.disposables.push(d);
179 }
180 
181 onResize(callback: (cols: number, rows: number) => void): void {
182 const d = this.terminal?.onResize(({ cols, rows }: { cols: number; rows: number }) => callback(cols, rows));
183 if (d) this.disposables.push(d);
184 }
185 
186 fit(): void {
187 this.stabilizeRendererMetrics();
188 this.fitAddon?.fit();
189 this.resumeRenderBurst(ACTIVE_BURST_MS);
190 }
191 
192 async prepareForAttach(): Promise<{ cols: number; rows: number }> {
193 // The first fit can race with canvas metrics and font readiness.
194 // Retry before opening the PTY so full-screen TUIs start at the
195 // correct size instead of staying at the worker's 80x24 default.
196 this.fit();
197 await nextAnimationFrame();
198 this.fit();
199 await waitForFontsReady();
200 await nextAnimationFrame();
201 this.fit();
202 
203 return {
204 cols: this.cols,
205 rows: this.rows,
206 };
207 }
208 
209 reset(): void {
210 this.terminal?.reset();
211 this.resumeRenderBurst(ACTIVE_BURST_MS);
212 }
213 
214 focus(): void {
215 this.terminal?.focus();
216 this.resumeRenderBurst(ACTIVE_BURST_MS);
217 this.startCursorPulse();
218 }
219 
220 dispose(): void {
221 for (const d of this.disposables) d.dispose();
222 this.disposables = [];
223 this.stopCursorPulse();
224 this.pauseRenderLoop();
225 this.fitAddon = null;
226 this.originalStartRenderLoop = null;
227 this.terminal?.dispose();
228 this.terminal = null;
229 }
230 
231 private getInternalTerminal(): GhosttyTerminalInternal | null {
232 return this.terminal as GhosttyTerminalInternal | null;
233 }
234 
235 private captureNativeRenderLoop(): void {
236 const terminal = this.getInternalTerminal();
237 if (!terminal) return;
238 
239 this.originalStartRenderLoop = terminal.startRenderLoop?.bind(this.terminal) ?? null;
240 }
241 
242 private resumeRenderBurst(durationMs: number): void {
243 if (document.visibilityState !== "visible") {
244 return;
245 }
246 
247 const terminal = this.getInternalTerminal();
248 if (!terminal || !this.originalStartRenderLoop) {
249 return;
250 }
251 
252 if (typeof terminal.animationFrameId !== "number") {
253 this.originalStartRenderLoop();
254 }
255 
256 this.scheduleRenderPause(durationMs);
257 }
258 
259 private scheduleRenderPause(durationMs: number): void {
260 if (this.renderPauseTimer !== null) {
261 clearTimeout(this.renderPauseTimer);
262 }
263 
264 this.renderPauseTimer = setTimeout(() => {
265 this.renderPauseTimer = null;
266 this.pauseRenderLoop();
267 }, durationMs);
268 }
269 
270 private pauseRenderLoop(): void {
271 if (this.renderPauseTimer !== null) {
272 clearTimeout(this.renderPauseTimer);
273 this.renderPauseTimer = null;
274 }
275 
276 const terminal = this.getInternalTerminal();
277 if (typeof terminal?.animationFrameId === "number") {
278 cancelAnimationFrame(terminal.animationFrameId);
279 terminal.animationFrameId = undefined;
280 }
281 }
282 
283 private syncCursorPulseState(): void {
284 if (this.shouldPulseCursor()) {
285 this.startCursorPulse();
286 return;
287 }
288 this.stopCursorPulse();
289 }
290 
291 private startCursorPulse(): void {
292 if (!this.opts.cursorBlink || this.cursorPulseIntervalId !== null) {
293 return;
294 }
295 
296 this.cursorPulseIntervalId = window.setInterval(() => {
297 if (!this.shouldPulseCursor()) {
298 this.stopCursorPulse();
299 return;
300 }
301 this.resumeRenderBurst(CURSOR_PULSE_BURST_MS);
302 }, CURSOR_PULSE_MS);
303 }
304 
305 private stopCursorPulse(): void {
306 if (this.cursorPulseIntervalId === null) {
307 return;
308 }
309 
310 clearInterval(this.cursorPulseIntervalId);
311 this.cursorPulseIntervalId = null;
312 }
313 
314 private shouldPulseCursor(): boolean {
315 if (document.visibilityState !== "visible") {
316 return false;
317 }
318 
319 const terminal = this.getInternalTerminal();
320 const element = terminal?.element;
321 if (!element) {
322 return false;
323 }
324 const activeElement = document.activeElement;
325 return !!activeElement && (activeElement === element || element.contains(activeElement));
326 }
327 
328 private stabilizeRendererMetrics(): void {
329 const terminal = this.getInternalTerminal();
330 const renderer = terminal?.renderer;
331 if (!terminal || !renderer?.remeasureFont) {
332 return;
333 }
334 
335 const fontSize = terminal.options?.fontSize ?? this.opts.fontSize ?? DEFAULT_FONT_SIZE;
336 const fontFamily = terminal.options?.fontFamily ?? this.opts.fontFamily ?? DEFAULT_FONT_FAMILY;
337 const measureFont = createTerminalMeasureFont(fontSize, fontFamily);
338 if (!measureFont) {
339 return;
340 }
341 
342 renderer.measureFont = measureFont;
343 renderer.remeasureFont();
344 
345 const cols = terminal.cols ?? this.cols;
346 const rows = terminal.rows ?? this.rows;
347 if (renderer.resize && cols > 0 && rows > 0) {
348 renderer.resize(cols, rows);
349 }
350 if (renderer.render && terminal.wasmTerm) {
351 renderer.render(terminal.wasmTerm, true, terminal.viewportY ?? 0, terminal);
352 }
353 }
354}
355 
356interface GhosttyRenderDisposable {
357 dispose(): void;
358}
359 
360interface GhosttyTerminalInternal {
361 animationFrameId?: number;
362 canvas?: HTMLCanvasElement;
363 cols?: number;
364 element?: HTMLElement;
365 onScroll?: (listener: () => void) => GhosttyRenderDisposable;
366 onSelectionChange?: (listener: () => void) => GhosttyRenderDisposable;
367 options?: {
368 fontFamily?: string;
369 fontSize?: number;
370 };
371 renderer?: GhosttyCanvasRendererInternal;
372 rows?: number;
373 startRenderLoop?: () => void;
374 viewportY?: number;
375 wasmTerm?: unknown;
376}
377 
378interface GhosttyCanvasRendererInternal {
379 measureFont?: () => GhosttyFontMetrics;
380 remeasureFont?: () => void;
381 render?: (
382 buffer: unknown,
383 forceAll?: boolean,
384 viewportY?: number,
385 scrollbackProvider?: unknown,
386 scrollbarOpacity?: number,
387 ) => void;
388 resize?: (cols: number, rows: number) => void;
389}
390 
391interface GhosttyFontMetrics {
392 baseline: number;
393 height: number;
394 width: number;
395}
396 
397function nextAnimationFrame(): Promise<void> {
398 return new Promise((resolve) => {
399 if (typeof requestAnimationFrame === "function") {
400 requestAnimationFrame(() => resolve());
401 return;
402 }
403 setTimeout(resolve, 16);
404 });
405}
406 
407async function waitForFontsReady(timeoutMs = 250): Promise<void> {
408 const ready = getDocumentFontsReady();
409 if (!ready) return;
410 
411 try {
412 await Promise.race([ready.then(() => undefined), new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
413 } catch {
414 // Best-effort only. If font loading fails, fall back to the current size.
415 }
416}
417 
418function getDocumentFontsReady(): Promise<unknown> | undefined {
419 const fonts = (
420 document as Document & {
421 fonts?: { ready?: Promise<unknown> };
422 }
423 ).fonts;
424 return fonts?.ready;
425}
426 
427function createTerminalMeasureFont(fontSize: number, fontFamily: string): (() => GhosttyFontMetrics) | undefined {
428 const metrics = measureTerminalFont(fontSize, fontFamily);
429 if (!metrics) {
430 return undefined;
431 }
432 
433 return () => measureTerminalFont(fontSize, fontFamily) ?? metrics;
434}
435 
436function measureTerminalFont(fontSize: number, fontFamily: string): GhosttyFontMetrics | undefined {
437 const context = document.createElement("canvas").getContext("2d");
438 if (!context) {
439 return undefined;
440 }
441 
442 context.font = `${fontSize}px ${fontFamily}`;
443 
444 // Use a descender in the sample so both Chromium and Firefox include enough
445 // vertical ink when deriving terminal cell height from text bounds.
446 const sampleMetrics = context.measureText("Mg");
447 const widthMetrics = context.measureText("M");
448 const ascent =
449 getTextMetric(sampleMetrics, "actualBoundingBoxAscent") ??
450 getTextMetric(sampleMetrics, "fontBoundingBoxAscent") ??
451 getTextMetric(sampleMetrics, "emHeightAscent") ??
452 fontSize * 0.8;
453 const descent =
454 getTextMetric(sampleMetrics, "actualBoundingBoxDescent") ??
455 getTextMetric(sampleMetrics, "fontBoundingBoxDescent") ??
456 getTextMetric(sampleMetrics, "emHeightDescent") ??
457 fontSize * 0.2;
458 
459 return {
460 width: Math.max(1, Math.ceil(widthMetrics.width)),
461 height: Math.max(1, Math.ceil(ascent + descent) + TERMINAL_HEIGHT_PADDING_PX),
462 baseline: Math.max(1, Math.ceil(ascent) + TERMINAL_BASELINE_PADDING_PX),
463 };
464}
465 
466function getTextMetric(
467 metrics: TextMetrics,
468 key:
469 | "actualBoundingBoxAscent"
470 | "actualBoundingBoxDescent"
471 | "emHeightAscent"
472 | "emHeightDescent"
473 | "fontBoundingBoxAscent"
474 | "fontBoundingBoxDescent",
475): number | undefined {
476 const value = metrics[key];
477 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
478 return undefined;
479 }
480 return value;
481}