// libghostty-vt as wasm: terminals to write to, snapshot and restore, read back for drawing, and encode keys for. // Shared by the server and the browser, so it uses nothing but WebAssembly. /** A struct, union or enum as `ghostty_type_json()` describes it. */ type TypeInfo = { kind: string; size: number; fields: Record; values: Record; }; type Exports = Record number> & { memory: WebAssembly.Memory; __indirect_function_table: WebAssembly.Table; }; const SUCCESS = 0; const RESULTS: Record = { [-1]: "out of memory", [-2]: "invalid value", [-3]: "out of space", [-4]: "no value", }; const MODS = { shift: 1, ctrl: 2, alt: 4, super: 8 }; const CONTINUATION_BYTES = 64 * 1024; const BRACKETED_PASTE = 2004; /** A host function as a funcref the module's table takes: what a callback option is set to. */ function hostFunction(fn: (...args: number[]) => void, params: number): WebAssembly.ExportValue { const section = (id: number, body: number[]) => [id, body.length, ...body]; const f = 0x66; const module = new Uint8Array([ ...[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00], ...section(1, [1, 0x60, params, ...Array(params).fill(0x7f), 0x00]), ...section(2, [1, 1, f, 1, f, 0x00, 0x00]), ...section(7, [1, 1, f, 0x00, 0x00]), ]); return new WebAssembly.Instance(new WebAssembly.Module(module), { f: { f: fn } }).exports.f; } /** One instance of the module, and the terminals made in it. */ export class Ghostty { readonly #x: Exports; readonly #types: Record; /** Scratch space for small out-parameters, reused by every call. */ readonly #scratch: number; private constructor(exports: Exports) { this.#x = exports; const json = JSON.parse(this.string(exports.ghostty_type_json())) as { types: Record; }; this.#types = json.types; this.#scratch = exports.ghostty_wasm_alloc(1024); } static async load(source: Response | BufferSource): Promise { const { instance } = source instanceof Response ? await WebAssembly.instantiateStreaming(source, {}) : await WebAssembly.instantiate(source, {}); return new Ghostty(instance.exports as unknown as Exports); } get x(): Exports { return this.#x; } /** A view of the module's memory as it is now, since a call that allocates may grow it. */ view(): DataView { return new DataView(this.#x.memory.buffer); } bytes(): Uint8Array { return new Uint8Array(this.#x.memory.buffer); } string(pointer: number, length?: number): string { const bytes = this.bytes(); let end = pointer; if (length === undefined) { while (bytes[end]) end++; } else end = pointer + length; return new TextDecoder().decode(bytes.slice(pointer, end)); } type(name: string): TypeInfo { const type = this.#types[name]; if (type === undefined) throw new Error(`libghostty-vt has no type ${name}`); return type; } value(type: string, name: string): number { const value = this.type(type).values[name]; if (value === undefined) throw new Error(`${type} has no ${name}`); return value; } check(result: number, what: string) { if (result !== SUCCESS) throw new Error(`${what}: ${RESULTS[result] ?? result}`); } /** Scratch space of `size` bytes at `at` within it, zeroed. */ scratch(size: number, at = 0): number { const pointer = this.#scratch + at; this.bytes().fill(0, pointer, pointer + size); return pointer; } /** A sized struct in scratch space: zeroed, with its `size` field set. */ sized(name: string, at = 0): number { const type = this.type(name); const pointer = this.scratch(type.size, at); this.view().setUint32(pointer + type.fields.size.offset, type.size, true); return pointer; } field(type: string, pointer: number, name: string): number { const { offset, size } = this.type(type).fields[name]; const view = this.view(); return size === 1 ? view.getUint8(pointer + offset) : size === 2 ? view.getUint16(pointer + offset, true) : view.getUint32(pointer + offset, true); } /** Make something through an out-pointer, as the C API's constructors do. */ make(construct: (out: number) => number, what: string): number { const out = this.#x.ghostty_wasm_alloc_opaque(); try { this.check(construct(out), what); return this.#x.ghostty_wasm_take_opaque(out); } finally { this.#x.ghostty_wasm_free_opaque(out); } } /** Copy `data` into the module for the length of `use`. */ with(data: Uint8Array, use: (pointer: number, length: number) => T): T { const pointer = this.#x.ghostty_wasm_alloc(Math.max(1, data.length)); try { this.bytes().set(data, pointer); return use(pointer, data.length); } finally { this.#x.ghostty_wasm_free(pointer, Math.max(1, data.length)); } } /** What an allocating call returns through a pointer and a length, copied out and freed. */ allocated(produce: (out: number, length: number) => number, what: string): Uint8Array { const out = this.#x.ghostty_wasm_alloc_opaque(); const length = this.#x.ghostty_wasm_alloc(4); try { this.check(produce(out, length), what); const pointer = this.#x.ghostty_wasm_take_opaque(out); const size = this.view().getUint32(length, true); const copy = this.bytes().slice(pointer, pointer + size); this.#x.ghostty_free(0, pointer, size); return copy; } finally { this.#x.ghostty_wasm_free_opaque(out); this.#x.ghostty_wasm_free(length, 4); } } /** A host function in the module's table, as a function pointer to set a callback option to. */ callback(fn: (...args: number[]) => void, params: number): number { const table = this.#x.__indirect_function_table; const index = table.grow(1); table.set(index, hostFunction(fn, params) as never); return index; } terminal(cols: number, rows: number): Terminal { const handle = this.make((out) => this.#x.ghostty_terminal_new(0, out, cols, rows), "terminal"); // a snapshot carries unfinished sequences only if the terminal kept them from the start const limit = this.scratch(4); this.view().setUint32(limit, CONTINUATION_BYTES, true); this.check( this.#x.ghostty_terminal_set( handle, this.value("GhosttyTerminalOption", "CONTINUATION_MAX_BYTES"), limit, ), "continuation", ); return new Terminal(this, handle); } /** A terminal as a snapshot left it. */ restore(snapshot: Uint8Array): Terminal { // the decoder reads the snapshot where it lies, so it stays until decoding is done return this.with(snapshot, (pointer, length) => { const decoder = this.make( (out) => this.#x.ghostty_snapshot_decoder_new_buf(0, out, pointer, length), "decoder", ); try { return new Terminal( this, this.make((out) => this.#x.ghostty_snapshot_decoder_decode(decoder, out), "restore"), ); } finally { this.#x.ghostty_snapshot_decoder_free(decoder); } }); } } export type Rgb = readonly [number, number, number]; /** A run of cells in one style; colors are absent where the terminal's default applies. */ export type Run = { text: string; fg?: Rgb; bg?: Rgb; bold?: boolean; italic?: boolean; faint?: boolean; /** 0 for none; otherwise single, double, curly, dotted or dashed. */ underline?: number; strikethrough?: boolean; invisible?: boolean; /** A double-width character, which takes two cells. */ wide?: boolean; }; export type Cursor = { x: number; y: number; style: "bar" | "block" | "underline" | "hollow" }; /** What changed since the last frame: the rows that did, by their place in the viewport, and the cursor. */ export type Frame = { cols: number; rows: number; changed: Map; cursor: Cursor | null; }; export type KeyInput = { code: string; key: string; shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; repeat: boolean; }; const CURSORS = ["bar", "block", "underline", "hollow"] as const; /** One terminal's state, in its module. */ export class Terminal { readonly #g: Ghostty; readonly handle: number; #render: { state: number; rows: number; cells: number } | null = null; #keys: { encoder: number; event: number } | null = null; constructor(ghostty: Ghostty, handle: number) { this.#g = ghostty; this.handle = handle; } write(data: Uint8Array) { this.#g.with( data, (pointer, length) => this.#g.x.ghostty_terminal_vt_write(this.handle, pointer, length), ); } resize(cols: number, rows: number) { this.#g.check(this.#g.x.ghostty_terminal_resize(this.handle, cols, rows, 0, 0), "resize"); } /** Send what the terminal answers the program with, such as its reply to a query, to `write`. */ answer(write: (data: Uint8Array) => void) { const g = this.#g; const pointer = g.callback((_terminal, _userdata, data, length) => { write(g.bytes().slice(data, data + length)); }, 4); g.check( g.x.ghostty_terminal_set(this.handle, g.value("GhosttyTerminalOption", "WRITE_PTY"), pointer), "answer", ); } /** Everything needed to draw and carry on this terminal elsewhere, scrollback included. */ snapshot(): Uint8Array { return this.#g.allocated( (out, length) => this.#g.x.ghostty_snapshot_encode_alloc(this.handle, 0, out, length), "snapshot", ); } /** The viewport as plain text, for tests and for the curious. */ text(): string { const g = this.#g; const options = g.sized("GhosttyFormatterTerminalOptions"); const fields = g.type("GhosttyFormatterTerminalOptions").fields; const view = g.view(); view.setUint32(options + fields.emit.offset, g.value("GhosttyFormatterFormat", "PLAIN"), true); view.setUint8(options + fields.trim.offset, 1); const extra = options + fields.extra.offset; view.setUint32(extra, g.type("GhosttyFormatterTerminalExtra").size, true); const screen = extra + g.type("GhosttyFormatterTerminalExtra").fields.screen.offset; view.setUint32(screen, g.type("GhosttyFormatterScreenExtra").size, true); // passed by value, which wasm's C ABI passes as a pointer const formatter = g.make( (out) => g.x.ghostty_formatter_terminal_new(0, out, this.handle, options), "formatter", ); try { return new TextDecoder().decode( g.allocated( (out, length) => g.x.ghostty_formatter_format_alloc(formatter, 0, out, length), "format", ), ); } finally { g.x.ghostty_formatter_free(formatter); } } /** Scroll the viewport by `delta` rows, up when negative, or back to the bottom. */ scroll(delta: number | "bottom") { const g = this.#g; const type = g.type("GhosttyTerminalScrollViewport"); const behavior = g.scratch(type.size); const view = g.view(); view.setUint32( behavior + type.fields.tag.offset, g.value("GhosttyTerminalScrollViewportTag", delta === "bottom" ? "BOTTOM" : "DELTA"), true, ); if (delta !== "bottom") view.setInt32(behavior + type.fields.value.offset, delta, true); g.x.ghostty_terminal_scroll_viewport(this.handle, behavior); } mode(mode: number, ansi = false): boolean { const g = this.#g; const config = g.scratch(4); g.view().setUint16(config, (mode & 0x7fff) | (ansi ? 0x8000 : 0), true); g.check( g.x.ghostty_terminal_get(this.handle, g.value("GhosttyTerminalData", "MODE"), config), "mode", ); return g.field("GhosttyTerminalModeConfig", config, "value") !== 0; } /** Set the colors the program sees as its defaults: foreground, background, cursor, and the 256-color palette. */ colors({ foreground, background, cursor, palette }: { foreground: Rgb; background: Rgb; cursor: Rgb; palette: readonly Rgb[]; }) { const g = this.#g; const set = (option: string, colors: readonly Rgb[]) => { const pointer = g.scratch(colors.length * 3); g.bytes().set(colors.flat(), pointer); g.check( g.x.ghostty_terminal_set(this.handle, g.value("GhosttyTerminalOption", option), pointer), option, ); }; set("COLOR_FOREGROUND", [foreground]); set("COLOR_BACKGROUND", [background]); set("COLOR_CURSOR", [cursor]); const full = [...palette]; const defaults = g.scratch(768, 0); g.x.ghostty_color_palette_default(defaults); for (let i = full.length; i < 256; i++) { const at = defaults + i * 3; const bytes = g.bytes(); full.push([bytes[at], bytes[at + 1], bytes[at + 2]]); } set("COLOR_PALETTE", full); } /** What has changed since the last frame, in runs of one style. */ frame(): Frame { const g = this.#g; const x = g.x; this.#render ??= { state: g.make((out) => x.ghostty_render_state_new(0, out), "render state"), rows: g.make((out) => x.ghostty_render_state_row_iterator_new(0, out), "rows"), cells: g.make((out) => x.ghostty_render_state_row_cells_new(0, out), "cells"), }; const { state, rows, cells } = this.#render; g.check(x.ghostty_render_state_update(state, this.handle), "render"); const data = (name: string) => g.value("GhosttyRenderStateData", name); const rowData = (name: string) => g.value("GhosttyRenderStateRowData", name); const cellData = (name: string) => g.value("GhosttyRenderStateRowCellsData", name); const out = g.scratch(8); g.check(x.ghostty_render_state_get(state, data("COLS"), out), "cols"); const cols = g.view().getUint16(out, true); g.check(x.ghostty_render_state_get(state, data("ROWS"), out), "rows"); const height = g.view().getUint16(out, true); const colors = g.sized("GhosttyRenderStateColors", 64); g.check(x.ghostty_render_state_get(state, data("COLORS"), colors), "colors"); const rgbAt = (pointer: number): Rgb => { const bytes = g.bytes(); return [bytes[pointer], bytes[pointer + 1], bytes[pointer + 2]]; }; const colorFields = g.type("GhosttyRenderStateColors").fields; const foreground = rgbAt(colors + colorFields.foreground.offset); const background = rgbAt(colors + colorFields.background.offset); const cursorStruct = g.sized("GhosttyRenderStateCursor", 900); g.check(x.ghostty_render_state_get(state, data("CURSOR"), cursorStruct), "cursor"); const cursorField = (name: string) => g.field("GhosttyRenderStateCursor", cursorStruct, name); const cursor: Cursor | null = cursorField("visible") && cursorField("viewport_has_value") ? { x: cursorField("viewport_x"), y: cursorField("viewport_y"), style: CURSORS[cursorField("visual_style")] ?? "block", } : null; const changed = new Map(); const handlePointer = g.scratch(4, 16); g.view().setUint32(handlePointer, rows, true); g.check(x.ghostty_render_state_get(state, data("ROW_ITERATOR"), handlePointer), "row iterator"); const cellsPointer = g.scratch(4, 20); g.view().setUint32(cellsPointer, cells, true); const styleType = g.type("GhosttyStyle"); const style = g.scratch(styleType.size, 120); const length = g.scratch(4, 24); const graphemes = x.ghostty_wasm_alloc(4 * 64); const rawCell = g.scratch(8, 32); const wideOut = g.scratch(4, 40); const rgb = g.scratch(4, 44); const y = g.scratch(2, 48); const wideTag = g.value("GhosttyCellData", "WIDE"); const WIDE = g.value("GhosttyCellWide", "WIDE"); const SPACER = [ g.value("GhosttyCellWide", "SPACER_TAIL"), g.value("GhosttyCellWide", "SPACER_HEAD"), ]; const flag = (name: string) => g.view().getUint8(style + styleType.fields[name].offset) !== 0; try { while (x.ghostty_render_state_row_iterator_next_dirty(rows, y)) { const row = g.view().getUint16(y, true); g.check(x.ghostty_render_state_row_get(rows, rowData("CELLS"), cellsPointer), "row cells"); const runs: Run[] = []; let blank = 0; while (x.ghostty_render_state_row_cells_next(cells)) { x.ghostty_render_state_row_cells_get(cells, cellData("RAW"), rawCell); // a cell is 64 bits, which a wasm32 call takes as a 64-bit integer const cell = g.view().getBigUint64(rawCell, true); (x.ghostty_cell_get as unknown as (cell: bigint, data: number, out: number) => number)( cell, wideTag, wideOut, ); const wide = g.view().getUint32(wideOut, true); if (SPACER.includes(wide)) continue; g.check( x.ghostty_render_state_row_cells_get(cells, cellData("GRAPHEMES_LEN"), length), "graphemes", ); const count = g.view().getUint32(length, true); let text = " "; if (count > 0) { if (count <= 64) { x.ghostty_render_state_row_cells_get(cells, cellData("GRAPHEMES_BUF"), graphemes); text = ""; for (let i = 0; i < count; i++) { text += String.fromCodePoint(g.view().getUint32(graphemes + i * 4, true)); } } else text = "\uFFFD"; } const run: Run = { text }; if (wide === WIDE) run.wide = true; x.ghostty_render_state_row_cells_get(cells, cellData("HAS_STYLING"), length); if (g.view().getUint8(length)) { g.view().setUint32(style + styleType.fields.size.offset, styleType.size, true); x.ghostty_render_state_row_cells_get(cells, cellData("STYLE"), style); if ( x.ghostty_render_state_row_cells_get(cells, cellData("FG_COLOR"), rgb) === SUCCESS ) { run.fg = rgbAt(rgb); } if ( x.ghostty_render_state_row_cells_get(cells, cellData("BG_COLOR"), rgb) === SUCCESS ) { run.bg = rgbAt(rgb); } if (flag("inverse")) [run.fg, run.bg] = [run.bg ?? background, run.fg ?? foreground]; if (flag("bold")) run.bold = true; if (flag("italic")) run.italic = true; if (flag("faint")) run.faint = true; if (flag("strikethrough")) run.strikethrough = true; if (flag("invisible")) run.invisible = true; const underline = g.view().getInt32(style + styleType.fields.underline.offset, true); if (underline) run.underline = underline; } const plain = Object.keys(run).length === 1; const last = runs.at(-1); if (last && !run.wide && !last.wide && sameStyle(last, run)) last.text += run.text; else runs.push(run); // trailing blanks in the default style are dropped, so copied text has no trailing spaces blank = plain && text === " " ? blank + 1 : 0; } if (blank > 0) { const last = runs.at(-1)!; last.text = last.text.slice(0, last.text.length - blank); if (last.text === "") runs.pop(); } changed.set(row, runs); g.view().setUint8(length, 0); x.ghostty_render_state_row_set( rows, g.value("GhosttyRenderStateRowOption", "DIRTY"), length, ); } } finally { x.ghostty_wasm_free(graphemes, 4 * 64); } g.view().setUint32(out, g.value("GhosttyRenderStateDirty", "FALSE"), true); x.ghostty_render_state_set(state, g.value("GhosttyRenderStateOption", "DIRTY"), out); return { cols, rows: height, changed, cursor }; } /** The bytes a key press sends the program, as the terminal's modes say to encode it; `null` for none. */ key(input: KeyInput, action: "press" | "release" = "press"): Uint8Array | null { const g = this.#g; const x = g.x; this.#keys ??= { encoder: g.make((out) => x.ghostty_key_encoder_new(0, out), "key encoder"), event: g.make((out) => x.ghostty_key_event_new(0, out), "key event"), }; const { encoder, event } = this.#keys; const name = input.code.replace(/^Key(?=[A-Z]$)/, "").replace(/([a-z])([A-Z0-9])/g, "$1_$2") .toUpperCase(); const key = g.type("GhosttyKey").values[name] ?? g.value("GhosttyKey", "UNIDENTIFIED"); x.ghostty_key_event_set_action( event, g.value( "GhosttyKeyAction", action === "release" ? "RELEASE" : input.repeat ? "REPEAT" : "PRESS", ), ); x.ghostty_key_event_set_key(event, key); x.ghostty_key_event_set_mods( event, (input.shiftKey ? MODS.shift : 0) | (input.ctrlKey ? MODS.ctrl : 0) | (input.altKey ? MODS.alt : 0) | (input.metaKey ? MODS.super : 0), ); // a key that types something says what, such as "a"; named keys, such as "Enter", type nothing of their own const typed = [...input.key].length === 1 ? input.key : ""; const text = new TextEncoder().encode(typed); const textPointer = x.ghostty_wasm_alloc(Math.max(1, text.length)); g.bytes().set(text, textPointer); x.ghostty_key_event_set_utf8(event, textPointer, text.length); x.ghostty_key_event_set_unshifted_codepoint( event, typed ? typed.toLowerCase().codePointAt(0)! : 0, ); x.ghostty_key_encoder_setopt_from_terminal(encoder, this.handle); const output = x.ghostty_wasm_alloc(128); const length = g.scratch(4); try { const result = x.ghostty_key_encoder_encode(encoder, event, output, 128, length); const size = g.view().getUint32(length, true); return result === SUCCESS && size > 0 ? g.bytes().slice(output, output + size) : null; } finally { x.ghostty_wasm_free(output, 128); x.ghostty_wasm_free(textPointer, Math.max(1, text.length)); } } /** Pasted text as the program should get it: bracketed if it asked for that. */ paste(text: string): Uint8Array { const g = this.#g; const data = new TextEncoder().encode(text); const capacity = data.length + 16; const output = g.x.ghostty_wasm_alloc(capacity); const length = g.scratch(4); try { return g.with(data, (pointer, size) => { g.check( g.x.ghostty_paste_encode( pointer, size, this.mode(BRACKETED_PASTE) ? 1 : 0, output, capacity, length, ), "paste", ); return g.bytes().slice(output, output + g.view().getUint32(length, true)); }); } finally { g.x.ghostty_wasm_free(output, capacity); } } free() { const x = this.#g.x; if (this.#render) { x.ghostty_render_state_row_cells_free(this.#render.cells); x.ghostty_render_state_row_iterator_free(this.#render.rows); x.ghostty_render_state_free(this.#render.state); } if (this.#keys) { x.ghostty_key_event_free(this.#keys.event); x.ghostty_key_encoder_free(this.#keys.encoder); } x.ghostty_terminal_free(this.handle); } } const STYLE_KEYS = [ "fg", "bg", "bold", "italic", "faint", "underline", "strikethrough", "invisible", ] as const; function sameStyle(a: Run, b: Run): boolean { return STYLE_KEYS.every((key) => { const [p, q] = [a[key], b[key]]; return Array.isArray(p) && Array.isArray(q) ? p.every((v, i) => v === q[i]) : p === q; }); }