feat(inspector): replace xterm.js with ghostty-web

- Replace @xterm/xterm, @xterm/addon-fit, and @xterm/addon-web-links with ghostty-web
- Update Terminal component to use ghostty-web API:
  - Add async WASM initialization via init()
  - Use FitAddon with observeResize() for auto-fitting
  - Use onResize callback for terminal resize events
- ghostty-web is API-compatible with xterm.js but uses Ghostty's WASM-compiled VT100 parser
- Benefits:
  - Better rendering of complex scripts (Devanagari, Arabic)
  - XTPUSHSGR/XTPOPSGR support
  - Same battle-tested code as native Ghostty app

Ref: https://github.com/coder/ghostty-web
This commit is contained in:
Nathan Flurry 2026-01-30 13:26:09 -08:00
parent ac0a22cd07
commit 1d1069d6fb
3 changed files with 128 additions and 110 deletions

View file

@ -22,8 +22,6 @@
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0"
"ghostty-web": "^0.3.0"
}
}

View file

@ -1,8 +1,5 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
import { init, Terminal as GhosttyTerminal, FitAddon } from "ghostty-web";
export interface TerminalProps {
/** WebSocket URL for terminal connection */
@ -28,6 +25,21 @@ interface TerminalMessage {
message?: string;
}
// Module-level initialization state
let ghosttyInitialized = false;
let ghosttyInitPromise: Promise<void> | null = null;
async function ensureGhosttyInitialized(): Promise<void> {
if (ghosttyInitialized) return;
if (ghosttyInitPromise) return ghosttyInitPromise;
ghosttyInitPromise = init().then(() => {
ghosttyInitialized = true;
});
return ghosttyInitPromise;
}
const Terminal = ({
wsUrl,
active = true,
@ -37,79 +49,12 @@ const Terminal = ({
rows = 24,
}: TerminalProps) => {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const termRef = useRef<GhosttyTerminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize terminal
useEffect(() => {
if (!terminalRef.current) return;
const term = new XTerm({
cursorBlink: true,
fontSize: 13,
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1a1a1a",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
cursorAccent: "#1a1a1a",
selectionBackground: "#264f78",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
},
cols,
rows,
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);
term.open(terminalRef.current);
// Fit terminal to container
setTimeout(() => fitAddon.fit(), 0);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Handle window resize
const handleResize = () => {
if (fitAddonRef.current && xtermRef.current) {
fitAddonRef.current.fit();
// Send resize to server
const { cols, rows } = xtermRef.current;
sendResize(cols, rows);
}
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
term.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
};
}, [cols, rows]);
const [initialized, setInitialized] = useState(false);
// Send resize message
const sendResize = useCallback((cols: number, rows: number) => {
@ -119,9 +64,95 @@ const Terminal = ({
}
}, []);
// Connect WebSocket
// Initialize ghostty-web and terminal
useEffect(() => {
if (!wsUrl || !xtermRef.current) return;
if (!terminalRef.current) return;
let disposed = false;
let term: GhosttyTerminal | null = null;
let fitAddon: FitAddon | null = null;
const initTerminal = async () => {
// Initialize WASM module
await ensureGhosttyInitialized();
if (disposed || !terminalRef.current) return;
term = new GhosttyTerminal({
cursorBlink: true,
fontSize: 13,
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1a1a1a",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
cursorAccent: "#1a1a1a",
selectionBackground: "#264f78",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
},
cols,
rows,
scrollback: 5000,
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalRef.current);
// Fit terminal to container
setTimeout(() => fitAddon?.fit(), 0);
// Enable auto-resize when container changes
fitAddon.observeResize();
// Handle terminal resize events from ghostty-web
term.onResize((size: { cols: number; rows: number }) => {
sendResize(size.cols, size.rows);
});
termRef.current = term;
fitAddonRef.current = fitAddon;
setInitialized(true);
};
initTerminal().catch((err) => {
console.error("Failed to initialize ghostty-web:", err);
setError("Failed to initialize terminal");
});
// Handle window resize (backup for browsers that don't trigger ResizeObserver)
const handleResize = () => {
fitAddonRef.current?.fit();
};
window.addEventListener("resize", handleResize);
return () => {
disposed = true;
window.removeEventListener("resize", handleResize);
term?.dispose();
termRef.current = null;
fitAddonRef.current = null;
};
}, [cols, rows, sendResize]);
// Connect WebSocket after terminal is initialized
useEffect(() => {
if (!wsUrl || !initialized || !termRef.current) return;
setError(null);
const ws = new WebSocket(wsUrl);
@ -130,12 +161,12 @@ const Terminal = ({
ws.onopen = () => {
setConnected(true);
onConnectionChange?.(true);
xtermRef.current?.writeln("\x1b[32m● Connected to terminal\x1b[0m\r\n");
termRef.current?.writeln("\x1b[32m● Connected to terminal\x1b[0m\r\n");
// Send initial resize
if (fitAddonRef.current && xtermRef.current) {
if (fitAddonRef.current && termRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = xtermRef.current;
const { cols, rows } = termRef.current;
sendResize(cols, rows);
}
};
@ -147,23 +178,23 @@ const Terminal = ({
switch (msg.type) {
case "data":
if (msg.data) {
xtermRef.current?.write(msg.data);
termRef.current?.write(msg.data);
}
break;
case "exit":
xtermRef.current?.writeln(`\r\n\x1b[33m● Process exited with code ${msg.code ?? "unknown"}\x1b[0m`);
termRef.current?.writeln(`\r\n\x1b[33m● Process exited with code ${msg.code ?? "unknown"}\x1b[0m`);
onClose?.();
break;
case "error":
setError(msg.message || "Unknown error");
xtermRef.current?.writeln(`\r\n\x1b[31m● Error: ${msg.message}\x1b[0m`);
termRef.current?.writeln(`\r\n\x1b[31m● Error: ${msg.message}\x1b[0m`);
break;
}
} catch (e) {
// Handle binary data
if (event.data instanceof Blob) {
event.data.text().then((text: string) => {
xtermRef.current?.write(text);
termRef.current?.write(text);
});
}
}
@ -178,11 +209,11 @@ const Terminal = ({
ws.onclose = () => {
setConnected(false);
onConnectionChange?.(false);
xtermRef.current?.writeln("\r\n\x1b[31m● Disconnected from terminal\x1b[0m");
termRef.current?.writeln("\r\n\x1b[31m● Disconnected from terminal\x1b[0m");
};
// Handle terminal input
const onData = xtermRef.current.onData((data) => {
const onData = termRef.current.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
const msg: TerminalMessage = { type: "input", data };
ws.send(JSON.stringify(msg));
@ -194,33 +225,14 @@ const Terminal = ({
ws.close();
wsRef.current = null;
};
}, [wsUrl, onClose, onConnectionChange, sendResize]);
// Handle container resize with ResizeObserver
useEffect(() => {
if (!terminalRef.current) return;
const resizeObserver = new ResizeObserver(() => {
if (fitAddonRef.current && xtermRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = xtermRef.current;
sendResize(cols, rows);
}
});
resizeObserver.observe(terminalRef.current);
return () => {
resizeObserver.disconnect();
};
}, [sendResize]);
}, [wsUrl, initialized, onClose, onConnectionChange, sendResize]);
// Focus terminal when active
useEffect(() => {
if (active && xtermRef.current) {
xtermRef.current.focus();
if (active && termRef.current) {
termRef.current.focus();
}
}, [active]);
}, [active, initialized]);
return (
<div className="terminal-container" style={{ height: "100%", position: "relative" }}>

8
pnpm-lock.yaml generated
View file

@ -99,6 +99,9 @@ importers:
frontend/packages/inspector:
dependencies:
ghostty-web:
specifier: ^0.3.0
version: 0.3.0
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@18.3.1)
@ -2572,6 +2575,9 @@ packages:
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
ghostty-web@0.3.0:
resolution: {integrity: sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A==}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
@ -6787,6 +6793,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
ghostty-web@0.3.0: {}
github-slugger@2.0.0: {}
glob-parent@5.1.2: