Files
webterm/src/textual_webterm/static/js/terminal.ts
T

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 };