548 lines
14 KiB
TypeScript
548 lines
14 KiB
TypeScript
/**
|
|
* ghostty-web terminal client for textual-webterm.
|
|
*
|
|
* Implements the WebSocket protocol compatible with local_server.py:
|
|
* - Client → Server: ["stdin", data], ["resize", {width, height}], ["ping", data]
|
|
* - Server → Client: ["stdout", data], ["pong", data], or binary frames
|
|
*/
|
|
|
|
import { Terminal, FitAddon, type ITerminalOptions, type ITheme } from "ghostty-web";
|
|
|
|
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
|
const DEFAULT_FONT_FAMILY =
|
|
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
|
|
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' +
|
|
'"DejaVu Sans Mono", "Courier New", monospace';
|
|
|
|
/** Predefined terminal themes */
|
|
const THEMES: Record<string, ITheme> = {
|
|
// Monokai Pro Ristretto - default theme
|
|
monokai: {
|
|
background: "#2d2a2e",
|
|
foreground: "#fcfcfa",
|
|
cursor: "#fcfcfa",
|
|
cursorAccent: "#2d2a2e",
|
|
selection: "#5b595c",
|
|
black: "#403e41",
|
|
red: "#ff6188",
|
|
green: "#a9dc76",
|
|
yellow: "#ffd866",
|
|
blue: "#fc9867",
|
|
magenta: "#ab9df2",
|
|
cyan: "#78dce8",
|
|
white: "#fcfcfa",
|
|
brightBlack: "#727072",
|
|
brightRed: "#ff6188",
|
|
brightGreen: "#a9dc76",
|
|
brightYellow: "#ffd866",
|
|
brightBlue: "#fc9867",
|
|
brightMagenta: "#ab9df2",
|
|
brightCyan: "#78dce8",
|
|
brightWhite: "#fcfcfa",
|
|
},
|
|
// Dark themes
|
|
dark: {
|
|
background: "#1e1e1e",
|
|
foreground: "#d4d4d4",
|
|
cursor: "#aeafad",
|
|
cursorAccent: "#1e1e1e",
|
|
selection: "#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: "#ffffff",
|
|
},
|
|
light: {
|
|
background: "#ffffff",
|
|
foreground: "#383a42",
|
|
cursor: "#526eff",
|
|
cursorAccent: "#ffffff",
|
|
selection: "#add6ff",
|
|
black: "#000000",
|
|
red: "#e45649",
|
|
green: "#50a14f",
|
|
yellow: "#c18401",
|
|
blue: "#4078f2",
|
|
magenta: "#a626a4",
|
|
cyan: "#0184bc",
|
|
white: "#a0a1a7",
|
|
brightBlack: "#5c6370",
|
|
brightRed: "#e06c75",
|
|
brightGreen: "#98c379",
|
|
brightYellow: "#d19a66",
|
|
brightBlue: "#61afef",
|
|
brightMagenta: "#c678dd",
|
|
brightCyan: "#56b6c2",
|
|
brightWhite: "#ffffff",
|
|
},
|
|
dracula: {
|
|
background: "#282a36",
|
|
foreground: "#f8f8f2",
|
|
cursor: "#f8f8f2",
|
|
cursorAccent: "#282a36",
|
|
selection: "#44475a",
|
|
black: "#21222c",
|
|
red: "#ff5555",
|
|
green: "#50fa7b",
|
|
yellow: "#f1fa8c",
|
|
blue: "#bd93f9",
|
|
magenta: "#ff79c6",
|
|
cyan: "#8be9fd",
|
|
white: "#f8f8f2",
|
|
brightBlack: "#6272a4",
|
|
brightRed: "#ff6e6e",
|
|
brightGreen: "#69ff94",
|
|
brightYellow: "#ffffa5",
|
|
brightBlue: "#d6acff",
|
|
brightMagenta: "#ff92df",
|
|
brightCyan: "#a4ffff",
|
|
brightWhite: "#ffffff",
|
|
},
|
|
catppuccin: {
|
|
background: "#1e1e2e",
|
|
foreground: "#cdd6f4",
|
|
cursor: "#f5e0dc",
|
|
cursorAccent: "#1e1e2e",
|
|
selection: "#45475a",
|
|
black: "#45475a",
|
|
red: "#f38ba8",
|
|
green: "#a6e3a1",
|
|
yellow: "#f9e2af",
|
|
blue: "#89b4fa",
|
|
magenta: "#f5c2e7",
|
|
cyan: "#94e2d5",
|
|
white: "#bac2de",
|
|
brightBlack: "#585b70",
|
|
brightRed: "#f38ba8",
|
|
brightGreen: "#a6e3a1",
|
|
brightYellow: "#f9e2af",
|
|
brightBlue: "#89b4fa",
|
|
brightMagenta: "#f5c2e7",
|
|
brightCyan: "#94e2d5",
|
|
brightWhite: "#a6adc8",
|
|
},
|
|
nord: {
|
|
background: "#2e3440",
|
|
foreground: "#d8dee9",
|
|
cursor: "#d8dee9",
|
|
cursorAccent: "#2e3440",
|
|
selection: "#434c5e",
|
|
black: "#3b4252",
|
|
red: "#bf616a",
|
|
green: "#a3be8c",
|
|
yellow: "#ebcb8b",
|
|
blue: "#81a1c1",
|
|
magenta: "#b48ead",
|
|
cyan: "#88c0d0",
|
|
white: "#e5e9f0",
|
|
brightBlack: "#4c566a",
|
|
brightRed: "#bf616a",
|
|
brightGreen: "#a3be8c",
|
|
brightYellow: "#ebcb8b",
|
|
brightBlue: "#81a1c1",
|
|
brightMagenta: "#b48ead",
|
|
brightCyan: "#8fbcbb",
|
|
brightWhite: "#eceff4",
|
|
},
|
|
gruvbox: {
|
|
background: "#282828",
|
|
foreground: "#ebdbb2",
|
|
cursor: "#ebdbb2",
|
|
cursorAccent: "#282828",
|
|
selection: "#504945",
|
|
black: "#282828",
|
|
red: "#cc241d",
|
|
green: "#98971a",
|
|
yellow: "#d79921",
|
|
blue: "#458588",
|
|
magenta: "#b16286",
|
|
cyan: "#689d6a",
|
|
white: "#a89984",
|
|
brightBlack: "#928374",
|
|
brightRed: "#fb4934",
|
|
brightGreen: "#b8bb26",
|
|
brightYellow: "#fabd2f",
|
|
brightBlue: "#83a598",
|
|
brightMagenta: "#d3869b",
|
|
brightCyan: "#8ec07c",
|
|
brightWhite: "#ebdbb2",
|
|
},
|
|
solarized: {
|
|
background: "#002b36",
|
|
foreground: "#839496",
|
|
cursor: "#839496",
|
|
cursorAccent: "#002b36",
|
|
selection: "#073642",
|
|
black: "#073642",
|
|
red: "#dc322f",
|
|
green: "#859900",
|
|
yellow: "#b58900",
|
|
blue: "#268bd2",
|
|
magenta: "#d33682",
|
|
cyan: "#2aa198",
|
|
white: "#eee8d5",
|
|
brightBlack: "#586e75",
|
|
brightRed: "#cb4b16",
|
|
brightGreen: "#586e75",
|
|
brightYellow: "#657b83",
|
|
brightBlue: "#839496",
|
|
brightMagenta: "#6c71c4",
|
|
brightCyan: "#93a1a1",
|
|
brightWhite: "#fdf6e3",
|
|
},
|
|
tokyo: {
|
|
background: "#1a1b26",
|
|
foreground: "#a9b1d6",
|
|
cursor: "#c0caf5",
|
|
cursorAccent: "#1a1b26",
|
|
selection: "#33467c",
|
|
black: "#15161e",
|
|
red: "#f7768e",
|
|
green: "#9ece6a",
|
|
yellow: "#e0af68",
|
|
blue: "#7aa2f7",
|
|
magenta: "#bb9af7",
|
|
cyan: "#7dcfff",
|
|
white: "#a9b1d6",
|
|
brightBlack: "#414868",
|
|
brightRed: "#f7768e",
|
|
brightGreen: "#9ece6a",
|
|
brightYellow: "#e0af68",
|
|
brightBlue: "#7aa2f7",
|
|
brightMagenta: "#bb9af7",
|
|
brightCyan: "#7dcfff",
|
|
brightWhite: "#c0caf5",
|
|
},
|
|
};
|
|
|
|
/** Configuration options passed via data attributes or window config */
|
|
interface TerminalConfig {
|
|
fontFamily?: string;
|
|
fontSize?: number;
|
|
scrollback?: number;
|
|
theme?: ITheme;
|
|
}
|
|
|
|
/** Parse configuration from element data attributes */
|
|
function parseConfig(element: HTMLElement): TerminalConfig {
|
|
const config: TerminalConfig = {};
|
|
|
|
if (element.dataset.fontFamily) {
|
|
config.fontFamily = element.dataset.fontFamily;
|
|
}
|
|
if (element.dataset.fontSize) {
|
|
config.fontSize = parseInt(element.dataset.fontSize, 10);
|
|
}
|
|
if (element.dataset.scrollback) {
|
|
config.scrollback = parseInt(element.dataset.scrollback, 10);
|
|
}
|
|
if (element.dataset.theme) {
|
|
const themeName = element.dataset.theme.toLowerCase();
|
|
if (themeName in THEMES) {
|
|
config.theme = THEMES[themeName];
|
|
} else {
|
|
// Try parsing as JSON for custom themes
|
|
try {
|
|
config.theme = JSON.parse(element.dataset.theme) as ITheme;
|
|
} catch {
|
|
console.warn(`Unknown theme "${element.dataset.theme}", using default`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
/** Get WASM path based on script location */
|
|
function getWasmPath(): string {
|
|
// Try to find the script element and derive path from it
|
|
const scripts = document.querySelectorAll('script[src*="terminal.js"]');
|
|
if (scripts.length > 0) {
|
|
const scriptSrc = (scripts[0] as HTMLScriptElement).src;
|
|
const basePath = scriptSrc.substring(0, scriptSrc.lastIndexOf('/') + 1);
|
|
return basePath + 'ghostty-vt.wasm';
|
|
}
|
|
// Fallback to common static paths
|
|
return '/static/js/ghostty-vt.wasm';
|
|
}
|
|
|
|
/**
|
|
* WebTerminal - wraps ghostty-web with WebSocket communication.
|
|
*/
|
|
class WebTerminal {
|
|
private terminal: Terminal;
|
|
private fitAddon: FitAddon;
|
|
private socket: WebSocket | null = null;
|
|
private element: HTMLElement;
|
|
private wsUrl: string;
|
|
private reconnectAttempts = 0;
|
|
private maxReconnectAttempts = 5;
|
|
private reconnectDelay = 1000;
|
|
private messageQueue: [string, unknown][] = [];
|
|
private lastValidSize: { cols: number; rows: number } | null = null;
|
|
|
|
private constructor(
|
|
container: HTMLElement,
|
|
wsUrl: string,
|
|
terminal: Terminal,
|
|
fitAddon: FitAddon
|
|
) {
|
|
this.element = container;
|
|
this.wsUrl = wsUrl;
|
|
this.terminal = terminal;
|
|
this.fitAddon = fitAddon;
|
|
}
|
|
|
|
/** Create and initialize a WebTerminal instance */
|
|
static async create(
|
|
container: HTMLElement,
|
|
wsUrl: string,
|
|
config: TerminalConfig
|
|
): Promise<WebTerminal> {
|
|
// Determine WASM path - try to find it relative to the script location
|
|
const wasmPath = getWasmPath();
|
|
|
|
// Build terminal options
|
|
const options: ITerminalOptions = {
|
|
fontFamily: config.fontFamily ?? DEFAULT_FONT_FAMILY,
|
|
fontSize: config.fontSize ?? 16,
|
|
scrollback: config.scrollback ?? 1000,
|
|
cursorBlink: true,
|
|
cursorStyle: "block",
|
|
theme: config.theme ?? THEMES.monokai,
|
|
wasmPath,
|
|
};
|
|
|
|
const terminal = new Terminal(options);
|
|
const fitAddon = new FitAddon();
|
|
terminal.loadAddon(fitAddon);
|
|
|
|
// Open terminal (this loads WASM and initializes everything)
|
|
await terminal.open(container);
|
|
|
|
const instance = new WebTerminal(container, wsUrl, terminal, fitAddon);
|
|
instance.initialize();
|
|
return instance;
|
|
}
|
|
|
|
/** Initialize event handlers and connect */
|
|
private initialize(): void {
|
|
// Wait for fonts to load before fitting to ensure correct measurements
|
|
this.waitForFonts().then(() => {
|
|
this.fitAddon.fit();
|
|
});
|
|
|
|
// Start observing resize immediately
|
|
this.fitAddon.observeResize();
|
|
|
|
// Handle window resize (some browsers don't trigger ResizeObserver on window resize)
|
|
window.addEventListener("resize", () => {
|
|
this.fitAddon.fit();
|
|
});
|
|
|
|
// Handle terminal input
|
|
this.terminal.onData((data) => {
|
|
this.send(["stdin", data]);
|
|
});
|
|
|
|
// Handle resize
|
|
this.terminal.onResize((size) => {
|
|
if (this.isValidSize(size.cols, size.rows)) {
|
|
this.lastValidSize = { cols: size.cols, rows: size.rows };
|
|
this.send(["resize", { width: size.cols, height: size.rows }]);
|
|
}
|
|
});
|
|
|
|
// Connect WebSocket
|
|
this.connect();
|
|
}
|
|
|
|
/** Wait for fonts to be loaded */
|
|
private async waitForFonts(): Promise<void> {
|
|
if (!("fonts" in document)) {
|
|
return;
|
|
}
|
|
try {
|
|
await document.fonts.ready;
|
|
} catch {
|
|
// Ignore font loading errors
|
|
}
|
|
}
|
|
|
|
/** Validate terminal dimensions */
|
|
private isValidSize(cols: number, rows: number): boolean {
|
|
return cols >= 2 && cols <= 500 && rows >= 1 && rows <= 200;
|
|
}
|
|
|
|
/** Connect to WebSocket server */
|
|
connect(): void {
|
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
this.socket = new WebSocket(this.wsUrl);
|
|
this.socket.binaryType = "arraybuffer";
|
|
|
|
this.socket.addEventListener("open", () => {
|
|
this.reconnectAttempts = 0;
|
|
this.element.classList.add("-connected");
|
|
this.element.classList.remove("-disconnected");
|
|
|
|
// Process any queued messages
|
|
this.processMessageQueue();
|
|
|
|
// Send initial size
|
|
const cols = this.terminal.cols;
|
|
const rows = this.terminal.rows;
|
|
if (this.isValidSize(cols, rows)) {
|
|
this.lastValidSize = { cols, rows };
|
|
this.send(["resize", { width: cols, height: rows }]);
|
|
}
|
|
|
|
// Focus terminal
|
|
this.terminal.focus();
|
|
});
|
|
|
|
this.socket.addEventListener("close", () => {
|
|
this.element.classList.remove("-connected");
|
|
this.element.classList.add("-disconnected");
|
|
this.scheduleReconnect();
|
|
});
|
|
|
|
this.socket.addEventListener("error", () => {
|
|
// Error handling - close event will follow
|
|
});
|
|
|
|
this.socket.addEventListener("message", (event) => {
|
|
this.handleMessage(event.data);
|
|
});
|
|
}
|
|
|
|
/** Handle incoming WebSocket message */
|
|
private handleMessage(data: string | ArrayBuffer): void {
|
|
if (data instanceof ArrayBuffer) {
|
|
// Binary data - write directly to terminal
|
|
const text = new TextDecoder().decode(data);
|
|
this.terminal.write(text);
|
|
return;
|
|
}
|
|
|
|
// JSON message
|
|
try {
|
|
const envelope = JSON.parse(data) as [string, unknown];
|
|
const [type, payload] = envelope;
|
|
|
|
switch (type) {
|
|
case "stdout":
|
|
this.terminal.write(payload as string);
|
|
break;
|
|
case "pong":
|
|
// Keep-alive response - nothing to do
|
|
break;
|
|
default:
|
|
console.debug("Unknown message type:", type);
|
|
}
|
|
} catch {
|
|
// Not JSON - treat as raw text
|
|
this.terminal.write(data);
|
|
}
|
|
}
|
|
|
|
/** Send message to server with queueing support */
|
|
private send(message: [string, unknown]): void {
|
|
this.messageQueue.push(message);
|
|
this.processMessageQueue();
|
|
}
|
|
|
|
/** Process queued messages when WebSocket is ready */
|
|
private processMessageQueue(): void {
|
|
if (this.socket?.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
while (this.messageQueue.length > 0) {
|
|
const message = this.messageQueue.shift();
|
|
try {
|
|
if (message) {
|
|
this.socket.send(JSON.stringify(message));
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to send message:", e, message);
|
|
if (message) {
|
|
this.messageQueue.unshift(message);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Schedule reconnection attempt */
|
|
private scheduleReconnect(): void {
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
console.error("Max reconnection attempts reached");
|
|
return;
|
|
}
|
|
|
|
this.reconnectAttempts++;
|
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
|
|
setTimeout(() => {
|
|
console.log(`Reconnecting (attempt ${this.reconnectAttempts})...`);
|
|
this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
/** Clean up resources */
|
|
dispose(): void {
|
|
this.socket?.close();
|
|
this.fitAddon.dispose();
|
|
this.terminal.dispose();
|
|
}
|
|
}
|
|
|
|
// Store instances for potential external access
|
|
const instances: Map<HTMLElement, WebTerminal> = new Map();
|
|
|
|
/** Initialize all terminal containers on page load */
|
|
async function initTerminals(): Promise<void> {
|
|
const containers = document.querySelectorAll<HTMLElement>(".textual-terminal");
|
|
|
|
for (const el of containers) {
|
|
const wsUrl = el.dataset.sessionWebsocketUrl;
|
|
if (!wsUrl) {
|
|
console.error("Missing data-session-websocket-url on terminal container");
|
|
continue;
|
|
}
|
|
|
|
const config = parseConfig(el);
|
|
try {
|
|
const terminal = await WebTerminal.create(el, wsUrl, config);
|
|
instances.set(el, terminal);
|
|
} catch (e) {
|
|
console.error("Failed to create terminal:", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-initialize on DOM ready
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", () => initTerminals());
|
|
} else {
|
|
initTerminals();
|
|
}
|
|
|
|
// Export for potential external use
|
|
export { WebTerminal, initTerminals, instances, THEMES };
|