Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
22 kB · 672 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673/** * TypeScript wrapper for libghostty-vt WASM API * * High-performance terminal emulation using Ghostty's battle-tested VT100 parser. * The key optimization is the RenderState API which provides a pre-computed * snapshot of all render data in a single update call. */
import { CellFlags, type Cursor, DirtyState, GHOSTTY_CONFIG_SIZE, type GhosttyCell, type GhosttyTerminalConfig, type GhosttyWasmExports, KeyEncoderOption, type KeyEvent, type KittyKeyFlags, type RGB, type RenderStateColors, type RenderStateCursor, type TerminalHandle,} from './types';
// Re-export types for convenienceexport { CellFlags, type Cursor, DirtyState, type GhosttyCell, KeyEncoderOption, type RGB, type RenderStateColors, type RenderStateCursor,};
/** * Main Ghostty WASM wrapper class */export class Ghostty { private exports: GhosttyWasmExports; private memory: WebAssembly.Memory;
constructor(wasmInstance: WebAssembly.Instance) { this.exports = wasmInstance.exports as GhosttyWasmExports; this.memory = this.exports.memory; }
createKeyEncoder(): KeyEncoder { return new KeyEncoder(this.exports); }
createTerminal( cols: number = 80, rows: number = 24, config?: GhosttyTerminalConfig ): GhosttyTerminal { return new GhosttyTerminal(this.exports, this.memory, cols, rows, config); }
static async load(wasmPath?: string): Promise<Ghostty> { // If explicit path provided, use it if (wasmPath) { return Ghostty.loadFromPath(wasmPath); }
// Resolve path relative to this module const moduleUrl = new URL('../ghostty-vt.wasm', import.meta.url);
// Build paths to try, prioritizing file system paths for Node/Bun const defaultPaths: string[] = [];
// For Node/Bun: try absolute file path first (strip file:// protocol) if (moduleUrl.protocol === 'file:') { let filePath = moduleUrl.pathname; // Remove leading slash on Windows paths (e.g., /C:/ -> C:/) if (filePath.match(/^\/[A-Za-z]:\//)) { filePath = filePath.slice(1); } defaultPaths.push(filePath); }
// Also try other common paths defaultPaths.push(moduleUrl.href, './ghostty-vt.wasm', '/ghostty-vt.wasm');
let lastError: Error | null = null; for (const path of defaultPaths) { try { return await Ghostty.loadFromPath(path); } catch (e) { lastError = e instanceof Error ? e : new Error(String(e)); } } throw lastError || new Error('Failed to load Ghostty WASM'); }
private static async loadFromPath(path: string): Promise<Ghostty> { let wasmBytes: ArrayBuffer | undefined;
// Try Bun.file first (for Bun environments) if (typeof Bun !== 'undefined' && typeof Bun.file === 'function') { try { const file = Bun.file(path); if (await file.exists()) { wasmBytes = await file.arrayBuffer(); } } catch { // Bun.file failed, try next method } }
// Try Node.js fs module if Bun.file didn't work if (!wasmBytes) { try { const fs = await import('fs/promises'); const buffer = await fs.readFile(path); wasmBytes = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); } catch { // fs failed, try fetch } }
// Fall back to fetch (for browser environments) if (!wasmBytes) { const response = await fetch(path); if (!response.ok) { throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`); } wasmBytes = await response.arrayBuffer(); if (wasmBytes.byteLength === 0) { throw new Error(`WASM file is empty (0 bytes). Check path: ${path}`); } }
if (!wasmBytes) { throw new Error(`Could not load WASM from path: ${path}`); }
const wasmModule = await WebAssembly.compile(wasmBytes); const wasmInstance = await WebAssembly.instantiate(wasmModule, { env: { log: (ptr: number, len: number) => { const bytes = new Uint8Array( (wasmInstance.exports as GhosttyWasmExports).memory.buffer, ptr, len ); console.log('[ghostty-vt]', new TextDecoder().decode(bytes)); }, }, }); return new Ghostty(wasmInstance); }}
/** * Key Encoder - converts keyboard events into terminal escape sequences */export class KeyEncoder { private exports: GhosttyWasmExports; private encoder: number = 0;
constructor(exports: GhosttyWasmExports) { this.exports = exports; const encoderPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); const result = this.exports.ghostty_key_encoder_new(0, encoderPtrPtr); if (result !== 0) throw new Error(`Failed to create key encoder: ${result}`); const view = new DataView(this.exports.memory.buffer); this.encoder = view.getUint32(encoderPtrPtr, true); this.exports.ghostty_wasm_free_opaque(encoderPtrPtr); }
setOption(option: KeyEncoderOption, value: boolean | number): void { const valuePtr = this.exports.ghostty_wasm_alloc_u8(); const view = new DataView(this.exports.memory.buffer); view.setUint8(valuePtr, typeof value === 'boolean' ? (value ? 1 : 0) : value); this.exports.ghostty_key_encoder_setopt(this.encoder, option, valuePtr); this.exports.ghostty_wasm_free_u8(valuePtr); }
setKittyFlags(flags: KittyKeyFlags): void { this.setOption(KeyEncoderOption.KITTY_KEYBOARD_FLAGS, flags); }
encode(event: KeyEvent): Uint8Array { const eventPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); const createResult = this.exports.ghostty_key_event_new(0, eventPtrPtr); if (createResult !== 0) throw new Error(`Failed to create key event: ${createResult}`);
const view = new DataView(this.exports.memory.buffer); const eventPtr = view.getUint32(eventPtrPtr, true); this.exports.ghostty_wasm_free_opaque(eventPtrPtr);
this.exports.ghostty_key_event_set_action(eventPtr, event.action); this.exports.ghostty_key_event_set_key(eventPtr, event.key); this.exports.ghostty_key_event_set_mods(eventPtr, event.mods);
if (event.utf8) { const encoder = new TextEncoder(); const utf8Bytes = encoder.encode(event.utf8); const utf8Ptr = this.exports.ghostty_wasm_alloc_u8_array(utf8Bytes.length); new Uint8Array(this.exports.memory.buffer).set(utf8Bytes, utf8Ptr); this.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Bytes.length); this.exports.ghostty_wasm_free_u8_array(utf8Ptr, utf8Bytes.length); }
const bufferSize = 32; const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufferSize); const writtenPtr = this.exports.ghostty_wasm_alloc_usize();
const encodeResult = this.exports.ghostty_key_encoder_encode( this.encoder, eventPtr, bufPtr, bufferSize, writtenPtr );
if (encodeResult !== 0) { this.exports.ghostty_wasm_free_u8_array(bufPtr, bufferSize); this.exports.ghostty_wasm_free_usize(writtenPtr); this.exports.ghostty_key_event_free(eventPtr); throw new Error(`Failed to encode key: ${encodeResult}`); }
const bytesWritten = view.getUint32(writtenPtr, true); const encoded = new Uint8Array(this.exports.memory.buffer, bufPtr, bytesWritten).slice();
this.exports.ghostty_wasm_free_u8_array(bufPtr, bufferSize); this.exports.ghostty_wasm_free_usize(writtenPtr); this.exports.ghostty_key_event_free(eventPtr);
return encoded; }
dispose(): void { if (this.encoder) { this.exports.ghostty_key_encoder_free(this.encoder); this.encoder = 0; } }}
/** * GhosttyTerminal - High-performance terminal emulator * * Uses Ghostty's native RenderState for optimal performance: * - ONE call to update all state (renderStateUpdate) * - ONE call to get all cells (getViewport) * - No per-row WASM boundary crossings! */export class GhosttyTerminal { private exports: GhosttyWasmExports; private memory: WebAssembly.Memory; private handle: TerminalHandle; private _cols: number; private _rows: number;
/** Size of GhosttyCell in WASM (16 bytes) */ private static readonly CELL_SIZE = 16;
/** Reusable buffer for viewport operations */ private viewportBufferPtr: number = 0; private viewportBufferSize: number = 0;
/** Cell pool for zero-allocation rendering */ private cellPool: GhosttyCell[] = [];
constructor( exports: GhosttyWasmExports, memory: WebAssembly.Memory, cols: number, rows: number, config?: GhosttyTerminalConfig ) { this.exports = exports; this.memory = memory; this._cols = cols; this._rows = rows;
if (config) { // Allocate config struct in WASM memory const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE); if (configPtr === 0) { throw new Error('Failed to allocate config (out of memory)'); }
try { // Write config to WASM memory const view = new DataView(this.memory.buffer); let offset = configPtr;
// scrollback_limit (u32) view.setUint32(offset, config.scrollbackLimit ?? 10000, true); offset += 4;
// fg_color (u32) view.setUint32(offset, config.fgColor ?? 0, true); offset += 4;
// bg_color (u32) view.setUint32(offset, config.bgColor ?? 0, true); offset += 4;
// cursor_color (u32) view.setUint32(offset, config.cursorColor ?? 0, true); offset += 4;
// palette[16] (u32 * 16) for (let i = 0; i < 16; i++) { view.setUint32(offset, config.palette?.[i] ?? 0, true); offset += 4; }
this.handle = this.exports.ghostty_terminal_new_with_config(cols, rows, configPtr); } finally { // Free the config memory this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE); } } else { this.handle = this.exports.ghostty_terminal_new(cols, rows); }
if (!this.handle) throw new Error('Failed to create terminal');
this.initCellPool(); }
get cols(): number { return this._cols; } get rows(): number { return this._rows; }
// ========================================================================== // Lifecycle // ==========================================================================
write(data: string | Uint8Array): void { const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; const ptr = this.exports.ghostty_wasm_alloc_u8_array(bytes.length); new Uint8Array(this.memory.buffer).set(bytes, ptr); this.exports.ghostty_terminal_write(this.handle, ptr, bytes.length); this.exports.ghostty_wasm_free_u8_array(ptr, bytes.length); }
resize(cols: number, rows: number): void { if (cols === this._cols && rows === this._rows) return; this._cols = cols; this._rows = rows; this.exports.ghostty_terminal_resize(this.handle, cols, rows); this.invalidateBuffers(); this.initCellPool(); }
free(): void { if (this.viewportBufferPtr) { this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); this.viewportBufferPtr = 0; } this.exports.ghostty_terminal_free(this.handle); }
// ========================================================================== // RenderState API - The key performance optimization // ==========================================================================
/** * Update render state from terminal. * * This syncs the RenderState with the current Terminal state. * The dirty state (full/partial/none) is stored in the WASM RenderState * and can be queried via isRowDirty(). When dirty==full, isRowDirty() * returns true for ALL rows. * * The WASM layer automatically detects screen switches (normal <-> alternate) * and returns FULL dirty state when switching screens (e.g., vim exit). * * Safe to call multiple times - dirty state persists until markClean(). */ update(): DirtyState { return this.exports.ghostty_render_state_update(this.handle) as DirtyState; }
/** * Get cursor state from render state. * Ensures render state is fresh by calling update(). */ getCursor(): RenderStateCursor { // Call update() to ensure render state is fresh. // This is safe to call multiple times - dirty state persists until markClean(). this.update(); return { x: this.exports.ghostty_render_state_get_cursor_x(this.handle), y: this.exports.ghostty_render_state_get_cursor_y(this.handle), viewportX: this.exports.ghostty_render_state_get_cursor_x(this.handle), viewportY: this.exports.ghostty_render_state_get_cursor_y(this.handle), visible: this.exports.ghostty_render_state_get_cursor_visible(this.handle), blinking: false, // TODO: Add blinking support style: 'block', // TODO: Add style support }; }
/** * Get default colors from render state */ getColors(): RenderStateColors { const bg = this.exports.ghostty_render_state_get_bg_color(this.handle); const fg = this.exports.ghostty_render_state_get_fg_color(this.handle); return { background: { r: (bg >> 16) & 0xff, g: (bg >> 8) & 0xff, b: bg & 0xff, }, foreground: { r: (fg >> 16) & 0xff, g: (fg >> 8) & 0xff, b: fg & 0xff, }, cursor: null, // TODO: Add cursor color support }; }
/** * Check if a specific row is dirty */ isRowDirty(y: number): boolean { return this.exports.ghostty_render_state_is_row_dirty(this.handle, y); }
/** * Mark render state as clean (call after rendering) */ markClean(): void { this.exports.ghostty_render_state_mark_clean(this.handle); }
/** * Get ALL viewport cells in ONE WASM call - the key performance optimization! * Returns a reusable cell array (zero allocation after warmup). */ getViewport(): GhosttyCell[] { const totalCells = this._cols * this._rows; const neededSize = totalCells * GhosttyTerminal.CELL_SIZE;
// Ensure buffer is allocated if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { if (this.viewportBufferPtr) { this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); } this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); this.viewportBufferSize = neededSize; }
// Get all cells in one call const count = this.exports.ghostty_render_state_get_viewport( this.handle, this.viewportBufferPtr, totalCells );
if (count < 0) return this.cellPool;
// Parse cells into pool (reuses existing objects) this.parseCellsIntoPool(this.viewportBufferPtr, totalCells); return this.cellPool; }
// ========================================================================== // Compatibility methods (delegate to render state) // ==========================================================================
/** * Get line - for compatibility, extracts from viewport. * Ensures render state is fresh by calling update(). * Returns a COPY of the cells to avoid pool reference issues. */ getLine(y: number): GhosttyCell[] | null { if (y < 0 || y >= this._rows) return null; // Call update() to ensure render state is fresh. // This is safe to call multiple times - dirty state persists until markClean(). this.update(); const viewport = this.getViewport(); const start = y * this._cols; // Return deep copies to avoid cell pool reference issues return viewport.slice(start, start + this._cols).map((cell) => ({ ...cell })); }
/** For compatibility with old API */ isDirty(): boolean { return this.update() !== DirtyState.NONE; }
/** * Check if a full redraw is needed (screen change, resize, etc.) * Note: This calls update() to ensure fresh state. Safe to call multiple times. */ needsFullRedraw(): boolean { return this.update() === DirtyState.FULL; }
/** Mark render state as clean after rendering */ clearDirty(): void { this.markClean(); }
// ========================================================================== // Terminal modes // ==========================================================================
isAlternateScreen(): boolean { return !!this.exports.ghostty_terminal_is_alternate_screen(this.handle); }
hasBracketedPaste(): boolean { // Mode 2004 = bracketed paste (DEC mode) return this.getMode(2004, false); }
hasFocusEvents(): boolean { // Mode 1004 = focus events (DEC mode) return this.getMode(1004, false); }
hasMouseTracking(): boolean { return this.exports.ghostty_terminal_has_mouse_tracking(this.handle) !== 0; }
// ========================================================================== // Extended API (scrollback, modes, etc.) // ==========================================================================
/** Get dimensions - for compatibility */ getDimensions(): { cols: number; rows: number } { return { cols: this._cols, rows: this._rows }; }
/** Get number of scrollback lines (history, not including active screen) */ getScrollbackLength(): number { return this.exports.ghostty_terminal_get_scrollback_length(this.handle); }
/** * Get a line from the scrollback buffer. * Ensures render state is fresh by calling update(). * @param offset 0 = oldest line, (length-1) = most recent scrollback line */ getScrollbackLine(offset: number): GhosttyCell[] | null { const neededSize = this._cols * GhosttyTerminal.CELL_SIZE;
// Ensure buffer is allocated if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { if (this.viewportBufferPtr) { this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); } this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); this.viewportBufferSize = neededSize; }
// Call update() to ensure render state is fresh (needed for colors). // This is safe to call multiple times - dirty state persists until markClean(). this.update();
const count = this.exports.ghostty_terminal_get_scrollback_line( this.handle, offset, this.viewportBufferPtr, this._cols );
if (count < 0) return null;
// Parse cells const cells: GhosttyCell[] = []; const buffer = this.memory.buffer; const u8 = new Uint8Array(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); const view = new DataView(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE);
for (let i = 0; i < count; i++) { const cellOffset = i * GhosttyTerminal.CELL_SIZE; cells.push({ codepoint: view.getUint32(cellOffset, true), fg_r: u8[cellOffset + 4], fg_g: u8[cellOffset + 5], fg_b: u8[cellOffset + 6], bg_r: u8[cellOffset + 7], bg_g: u8[cellOffset + 8], bg_b: u8[cellOffset + 9], flags: u8[cellOffset + 10], width: u8[cellOffset + 11], hyperlink_id: view.getUint16(cellOffset + 12, true), }); }
return cells; }
/** Check if a row in the active screen is wrapped (soft-wrapped to next line) */ isRowWrapped(row: number): boolean { return this.exports.ghostty_terminal_is_row_wrapped(this.handle, row) !== 0; }
/** Hyperlink URI not yet exposed in simplified API */ getHyperlinkUri(_id: number): string | null { return null; // TODO: Add hyperlink support }
/** * Query arbitrary terminal mode by number * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) * @param isAnsi True for ANSI modes, false for DEC modes (default: false) */ getMode(mode: number, isAnsi: boolean = false): boolean { return this.exports.ghostty_terminal_get_mode(this.handle, mode, isAnsi) !== 0; }
// ========================================================================== // Private helpers // ==========================================================================
private initCellPool(): void { const total = this._cols * this._rows; if (this.cellPool.length < total) { for (let i = this.cellPool.length; i < total; i++) { this.cellPool.push({ codepoint: 0, fg_r: 204, fg_g: 204, fg_b: 204, bg_r: 0, bg_g: 0, bg_b: 0, flags: 0, width: 1, hyperlink_id: 0, }); } } }
private parseCellsIntoPool(ptr: number, count: number): void { const buffer = this.memory.buffer; const u8 = new Uint8Array(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); const view = new DataView(buffer, ptr, count * GhosttyTerminal.CELL_SIZE);
for (let i = 0; i < count; i++) { const offset = i * GhosttyTerminal.CELL_SIZE; const cell = this.cellPool[i]; cell.codepoint = view.getUint32(offset, true); cell.fg_r = u8[offset + 4]; cell.fg_g = u8[offset + 5]; cell.fg_b = u8[offset + 6]; cell.bg_r = u8[offset + 7]; cell.bg_g = u8[offset + 8]; cell.bg_b = u8[offset + 9]; cell.flags = u8[offset + 10]; cell.width = u8[offset + 11]; cell.hyperlink_id = view.getUint16(offset + 12, true); } }
private invalidateBuffers(): void { if (this.viewportBufferPtr) { this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); this.viewportBufferPtr = 0; this.viewportBufferSize = 0; } }}