char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somadd terminal extension6334b24

main
23.0 KiB633 linesraw
1// libghostty-vt as wasm: terminals to write to, snapshot and restore, read back for drawing, and encode keys for.
2// Shared by the server and the browser, so it uses nothing but WebAssembly.
3
4/** A struct, union or enum as `ghostty_type_json()` describes it. */
5type TypeInfo = {
6  kind: string;
7  size: number;
8  fields: Record<string, { offset: number; size: number }>;
9  values: Record<string, number>;
10};
11
12type Exports = Record<string, (...args: number[]) => number> & {
13  memory: WebAssembly.Memory;
14  __indirect_function_table: WebAssembly.Table;
15};
16
17const SUCCESS = 0;
18const RESULTS: Record<number, string> = {
19  [-1]: "out of memory",
20  [-2]: "invalid value",
21  [-3]: "out of space",
22  [-4]: "no value",
23};
24
25const MODS = { shift: 1, ctrl: 2, alt: 4, super: 8 };
26const CONTINUATION_BYTES = 64 * 1024;
27const BRACKETED_PASTE = 2004;
28
29/** A host function as a funcref the module's table takes: what a callback option is set to. */
30function hostFunction(fn: (...args: number[]) => void, params: number): WebAssembly.ExportValue {
31  const section = (id: number, body: number[]) => [id, body.length, ...body];
32  const f = 0x66;
33  const module = new Uint8Array([
34    ...[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00],
35    ...section(1, [1, 0x60, params, ...Array(params).fill(0x7f), 0x00]),
36    ...section(2, [1, 1, f, 1, f, 0x00, 0x00]),
37    ...section(7, [1, 1, f, 0x00, 0x00]),
38  ]);
39  return new WebAssembly.Instance(new WebAssembly.Module(module), { f: { f: fn } }).exports.f;
40}
41
42/** One instance of the module, and the terminals made in it. */
43export class Ghostty {
44  readonly #x: Exports;
45  readonly #types: Record<string, TypeInfo>;
46  /** Scratch space for small out-parameters, reused by every call. */
47  readonly #scratch: number;
48
49  private constructor(exports: Exports) {
50    this.#x = exports;
51    const json = JSON.parse(this.string(exports.ghostty_type_json())) as {
52      types: Record<string, TypeInfo>;
53    };
54    this.#types = json.types;
55    this.#scratch = exports.ghostty_wasm_alloc(1024);
56  }
57
58  static async load(source: Response | BufferSource): Promise<Ghostty> {
59    const { instance } = source instanceof Response
60      ? await WebAssembly.instantiateStreaming(source, {})
61      : await WebAssembly.instantiate(source, {});
62    return new Ghostty(instance.exports as unknown as Exports);
63  }
64
65  get x(): Exports {
66    return this.#x;
67  }
68
69  /** A view of the module's memory as it is now, since a call that allocates may grow it. */
70  view(): DataView {
71    return new DataView(this.#x.memory.buffer);
72  }
73
74  bytes(): Uint8Array {
75    return new Uint8Array(this.#x.memory.buffer);
76  }
77
78  string(pointer: number, length?: number): string {
79    const bytes = this.bytes();
80    let end = pointer;
81    if (length === undefined) { while (bytes[end]) end++; }
82    else end = pointer + length;
83    return new TextDecoder().decode(bytes.slice(pointer, end));
84  }
85
86  type(name: string): TypeInfo {
87    const type = this.#types[name];
88    if (type === undefined) throw new Error(`libghostty-vt has no type ${name}`);
89    return type;
90  }
91
92  value(type: string, name: string): number {
93    const value = this.type(type).values[name];
94    if (value === undefined) throw new Error(`${type} has no ${name}`);
95    return value;
96  }
97
98  check(result: number, what: string) {
99    if (result !== SUCCESS) throw new Error(`${what}: ${RESULTS[result] ?? result}`);
100  }
101
102  /** Scratch space of `size` bytes at `at` within it, zeroed. */
103  scratch(size: number, at = 0): number {
104    const pointer = this.#scratch + at;
105    this.bytes().fill(0, pointer, pointer + size);
106    return pointer;
107  }
108
109  /** A sized struct in scratch space: zeroed, with its `size` field set. */
110  sized(name: string, at = 0): number {
111    const type = this.type(name);
112    const pointer = this.scratch(type.size, at);
113    this.view().setUint32(pointer + type.fields.size.offset, type.size, true);
114    return pointer;
115  }
116
117  field(type: string, pointer: number, name: string): number {
118    const { offset, size } = this.type(type).fields[name];
119    const view = this.view();
120    return size === 1
121      ? view.getUint8(pointer + offset)
122      : size === 2
123      ? view.getUint16(pointer + offset, true)
124      : view.getUint32(pointer + offset, true);
125  }
126
127  /** Make something through an out-pointer, as the C API's constructors do. */
128  make(construct: (out: number) => number, what: string): number {
129    const out = this.#x.ghostty_wasm_alloc_opaque();
130    try {
131      this.check(construct(out), what);
132      return this.#x.ghostty_wasm_take_opaque(out);
133    } finally {
134      this.#x.ghostty_wasm_free_opaque(out);
135    }
136  }
137
138  /** Copy `data` into the module for the length of `use`. */
139  with<T>(data: Uint8Array, use: (pointer: number, length: number) => T): T {
140    const pointer = this.#x.ghostty_wasm_alloc(Math.max(1, data.length));
141    try {
142      this.bytes().set(data, pointer);
143      return use(pointer, data.length);
144    } finally {
145      this.#x.ghostty_wasm_free(pointer, Math.max(1, data.length));
146    }
147  }
148
149  /** What an allocating call returns through a pointer and a length, copied out and freed. */
150  allocated(produce: (out: number, length: number) => number, what: string): Uint8Array {
151    const out = this.#x.ghostty_wasm_alloc_opaque();
152    const length = this.#x.ghostty_wasm_alloc(4);
153    try {
154      this.check(produce(out, length), what);
155      const pointer = this.#x.ghostty_wasm_take_opaque(out);
156      const size = this.view().getUint32(length, true);
157      const copy = this.bytes().slice(pointer, pointer + size);
158      this.#x.ghostty_free(0, pointer, size);
159      return copy;
160    } finally {
161      this.#x.ghostty_wasm_free_opaque(out);
162      this.#x.ghostty_wasm_free(length, 4);
163    }
164  }
165
166  /** A host function in the module's table, as a function pointer to set a callback option to. */
167  callback(fn: (...args: number[]) => void, params: number): number {
168    const table = this.#x.__indirect_function_table;
169    const index = table.grow(1);
170    table.set(index, hostFunction(fn, params) as never);
171    return index;
172  }
173
174  terminal(cols: number, rows: number): Terminal {
175    const handle = this.make((out) => this.#x.ghostty_terminal_new(0, out, cols, rows), "terminal");
176    // a snapshot carries unfinished sequences only if the terminal kept them from the start
177    const limit = this.scratch(4);
178    this.view().setUint32(limit, CONTINUATION_BYTES, true);
179    this.check(
180      this.#x.ghostty_terminal_set(
181        handle,
182        this.value("GhosttyTerminalOption", "CONTINUATION_MAX_BYTES"),
183        limit,
184      ),
185      "continuation",
186    );
187    return new Terminal(this, handle);
188  }
189
190  /** A terminal as a snapshot left it. */
191  restore(snapshot: Uint8Array): Terminal {
192    // the decoder reads the snapshot where it lies, so it stays until decoding is done
193    return this.with(snapshot, (pointer, length) => {
194      const decoder = this.make(
195        (out) => this.#x.ghostty_snapshot_decoder_new_buf(0, out, pointer, length),
196        "decoder",
197      );
198      try {
199        return new Terminal(
200          this,
201          this.make((out) => this.#x.ghostty_snapshot_decoder_decode(decoder, out), "restore"),
202        );
203      } finally {
204        this.#x.ghostty_snapshot_decoder_free(decoder);
205      }
206    });
207  }
208}
209
210export type Rgb = readonly [number, number, number];
211
212/** A run of cells in one style; colors are absent where the terminal's default applies. */
213export type Run = {
214  text: string;
215  fg?: Rgb;
216  bg?: Rgb;
217  bold?: boolean;
218  italic?: boolean;
219  faint?: boolean;
220  /** 0 for none; otherwise single, double, curly, dotted or dashed. */
221  underline?: number;
222  strikethrough?: boolean;
223  invisible?: boolean;
224  /** A double-width character, which takes two cells. */
225  wide?: boolean;
226};
227
228export type Cursor = { x: number; y: number; style: "bar" | "block" | "underline" | "hollow" };
229
230/** What changed since the last frame: the rows that did, by their place in the viewport, and the cursor. */
231export type Frame = {
232  cols: number;
233  rows: number;
234  changed: Map<number, Run[]>;
235  cursor: Cursor | null;
236};
237
238export type KeyInput = {
239  code: string;
240  key: string;
241  shiftKey: boolean;
242  ctrlKey: boolean;
243  altKey: boolean;
244  metaKey: boolean;
245  repeat: boolean;
246};
247
248const CURSORS = ["bar", "block", "underline", "hollow"] as const;
249
250/** One terminal's state, in its module. */
251export class Terminal {
252  readonly #g: Ghostty;
253  readonly handle: number;
254  #render: { state: number; rows: number; cells: number } | null = null;
255  #keys: { encoder: number; event: number } | null = null;
256
257  constructor(ghostty: Ghostty, handle: number) {
258    this.#g = ghostty;
259    this.handle = handle;
260  }
261
262  write(data: Uint8Array) {
263    this.#g.with(
264      data,
265      (pointer, length) => this.#g.x.ghostty_terminal_vt_write(this.handle, pointer, length),
266    );
267  }
268
269  resize(cols: number, rows: number) {
270    this.#g.check(this.#g.x.ghostty_terminal_resize(this.handle, cols, rows, 0, 0), "resize");
271  }
272
273  /** Send what the terminal answers the program with, such as its reply to a query, to `write`. */
274  answer(write: (data: Uint8Array) => void) {
275    const g = this.#g;
276    const pointer = g.callback((_terminal, _userdata, data, length) => {
277      write(g.bytes().slice(data, data + length));
278    }, 4);
279    g.check(
280      g.x.ghostty_terminal_set(this.handle, g.value("GhosttyTerminalOption", "WRITE_PTY"), pointer),
281      "answer",
282    );
283  }
284
285  /** Everything needed to draw and carry on this terminal elsewhere, scrollback included. */
286  snapshot(): Uint8Array {
287    return this.#g.allocated(
288      (out, length) => this.#g.x.ghostty_snapshot_encode_alloc(this.handle, 0, out, length),
289      "snapshot",
290    );
291  }
292
293  /** The viewport as plain text, for tests and for the curious. */
294  text(): string {
295    const g = this.#g;
296    const options = g.sized("GhosttyFormatterTerminalOptions");
297    const fields = g.type("GhosttyFormatterTerminalOptions").fields;
298    const view = g.view();
299    view.setUint32(options + fields.emit.offset, g.value("GhosttyFormatterFormat", "PLAIN"), true);
300    view.setUint8(options + fields.trim.offset, 1);
301    const extra = options + fields.extra.offset;
302    view.setUint32(extra, g.type("GhosttyFormatterTerminalExtra").size, true);
303    const screen = extra + g.type("GhosttyFormatterTerminalExtra").fields.screen.offset;
304    view.setUint32(screen, g.type("GhosttyFormatterScreenExtra").size, true);
305    // passed by value, which wasm's C ABI passes as a pointer
306    const formatter = g.make(
307      (out) => g.x.ghostty_formatter_terminal_new(0, out, this.handle, options),
308      "formatter",
309    );
310    try {
311      return new TextDecoder().decode(
312        g.allocated(
313          (out, length) => g.x.ghostty_formatter_format_alloc(formatter, 0, out, length),
314          "format",
315        ),
316      );
317    } finally {
318      g.x.ghostty_formatter_free(formatter);
319    }
320  }
321
322  /** Scroll the viewport by `delta` rows, up when negative, or back to the bottom. */
323  scroll(delta: number | "bottom") {
324    const g = this.#g;
325    const type = g.type("GhosttyTerminalScrollViewport");
326    const behavior = g.scratch(type.size);
327    const view = g.view();
328    view.setUint32(
329      behavior + type.fields.tag.offset,
330      g.value("GhosttyTerminalScrollViewportTag", delta === "bottom" ? "BOTTOM" : "DELTA"),
331      true,
332    );
333    if (delta !== "bottom") view.setInt32(behavior + type.fields.value.offset, delta, true);
334    g.x.ghostty_terminal_scroll_viewport(this.handle, behavior);
335  }
336
337  mode(mode: number, ansi = false): boolean {
338    const g = this.#g;
339    const config = g.scratch(4);
340    g.view().setUint16(config, (mode & 0x7fff) | (ansi ? 0x8000 : 0), true);
341    g.check(
342      g.x.ghostty_terminal_get(this.handle, g.value("GhosttyTerminalData", "MODE"), config),
343      "mode",
344    );
345    return g.field("GhosttyTerminalModeConfig", config, "value") !== 0;
346  }
347
348  /** Set the colors the program sees as its defaults: foreground, background, cursor, and the 256-color palette. */
349  colors({ foreground, background, cursor, palette }: {
350    foreground: Rgb;
351    background: Rgb;
352    cursor: Rgb;
353    palette: readonly Rgb[];
354  }) {
355    const g = this.#g;
356    const set = (option: string, colors: readonly Rgb[]) => {
357      const pointer = g.scratch(colors.length * 3);
358      g.bytes().set(colors.flat(), pointer);
359      g.check(
360        g.x.ghostty_terminal_set(this.handle, g.value("GhosttyTerminalOption", option), pointer),
361        option,
362      );
363    };
364    set("COLOR_FOREGROUND", [foreground]);
365    set("COLOR_BACKGROUND", [background]);
366    set("COLOR_CURSOR", [cursor]);
367    const full = [...palette];
368    const defaults = g.scratch(768, 0);
369    g.x.ghostty_color_palette_default(defaults);
370    for (let i = full.length; i < 256; i++) {
371      const at = defaults + i * 3;
372      const bytes = g.bytes();
373      full.push([bytes[at], bytes[at + 1], bytes[at + 2]]);
374    }
375    set("COLOR_PALETTE", full);
376  }
377
378  /** What has changed since the last frame, in runs of one style. */
379  frame(): Frame {
380    const g = this.#g;
381    const x = g.x;
382    this.#render ??= {
383      state: g.make((out) => x.ghostty_render_state_new(0, out), "render state"),
384      rows: g.make((out) => x.ghostty_render_state_row_iterator_new(0, out), "rows"),
385      cells: g.make((out) => x.ghostty_render_state_row_cells_new(0, out), "cells"),
386    };
387    const { state, rows, cells } = this.#render;
388    g.check(x.ghostty_render_state_update(state, this.handle), "render");
389    const data = (name: string) => g.value("GhosttyRenderStateData", name);
390    const rowData = (name: string) => g.value("GhosttyRenderStateRowData", name);
391    const cellData = (name: string) => g.value("GhosttyRenderStateRowCellsData", name);
392
393    const out = g.scratch(8);
394    g.check(x.ghostty_render_state_get(state, data("COLS"), out), "cols");
395    const cols = g.view().getUint16(out, true);
396    g.check(x.ghostty_render_state_get(state, data("ROWS"), out), "rows");
397    const height = g.view().getUint16(out, true);
398
399    const colors = g.sized("GhosttyRenderStateColors", 64);
400    g.check(x.ghostty_render_state_get(state, data("COLORS"), colors), "colors");
401    const rgbAt = (pointer: number): Rgb => {
402      const bytes = g.bytes();
403      return [bytes[pointer], bytes[pointer + 1], bytes[pointer + 2]];
404    };
405    const colorFields = g.type("GhosttyRenderStateColors").fields;
406    const foreground = rgbAt(colors + colorFields.foreground.offset);
407    const background = rgbAt(colors + colorFields.background.offset);
408
409    const cursorStruct = g.sized("GhosttyRenderStateCursor", 900);
410    g.check(x.ghostty_render_state_get(state, data("CURSOR"), cursorStruct), "cursor");
411    const cursorField = (name: string) => g.field("GhosttyRenderStateCursor", cursorStruct, name);
412    const cursor: Cursor | null = cursorField("visible") && cursorField("viewport_has_value")
413      ? {
414        x: cursorField("viewport_x"),
415        y: cursorField("viewport_y"),
416        style: CURSORS[cursorField("visual_style")] ?? "block",
417      }
418      : null;
419
420    const changed = new Map<number, Run[]>();
421    const handlePointer = g.scratch(4, 16);
422    g.view().setUint32(handlePointer, rows, true);
423    g.check(x.ghostty_render_state_get(state, data("ROW_ITERATOR"), handlePointer), "row iterator");
424    const cellsPointer = g.scratch(4, 20);
425    g.view().setUint32(cellsPointer, cells, true);
426    const styleType = g.type("GhosttyStyle");
427    const style = g.scratch(styleType.size, 120);
428    const length = g.scratch(4, 24);
429    const graphemes = x.ghostty_wasm_alloc(4 * 64);
430    const rawCell = g.scratch(8, 32);
431    const wideOut = g.scratch(4, 40);
432    const rgb = g.scratch(4, 44);
433    const y = g.scratch(2, 48);
434    const wideTag = g.value("GhosttyCellData", "WIDE");
435    const WIDE = g.value("GhosttyCellWide", "WIDE");
436    const SPACER = [
437      g.value("GhosttyCellWide", "SPACER_TAIL"),
438      g.value("GhosttyCellWide", "SPACER_HEAD"),
439    ];
440    const flag = (name: string) => g.view().getUint8(style + styleType.fields[name].offset) !== 0;
441    try {
442      while (x.ghostty_render_state_row_iterator_next_dirty(rows, y)) {
443        const row = g.view().getUint16(y, true);
444        g.check(x.ghostty_render_state_row_get(rows, rowData("CELLS"), cellsPointer), "row cells");
445        const runs: Run[] = [];
446        let blank = 0;
447        while (x.ghostty_render_state_row_cells_next(cells)) {
448          x.ghostty_render_state_row_cells_get(cells, cellData("RAW"), rawCell);
449          // a cell is 64 bits, which a wasm32 call takes as a 64-bit integer
450          const cell = g.view().getBigUint64(rawCell, true);
451          (x.ghostty_cell_get as unknown as (cell: bigint, data: number, out: number) => number)(
452            cell,
453            wideTag,
454            wideOut,
455          );
456          const wide = g.view().getUint32(wideOut, true);
457          if (SPACER.includes(wide)) continue;
458          g.check(
459            x.ghostty_render_state_row_cells_get(cells, cellData("GRAPHEMES_LEN"), length),
460            "graphemes",
461          );
462          const count = g.view().getUint32(length, true);
463          let text = " ";
464          if (count > 0) {
465            if (count <= 64) {
466              x.ghostty_render_state_row_cells_get(cells, cellData("GRAPHEMES_BUF"), graphemes);
467              text = "";
468              for (let i = 0; i < count; i++) {
469                text += String.fromCodePoint(g.view().getUint32(graphemes + i * 4, true));
470              }
471            } else text = "\uFFFD";
472          }
473          const run: Run = { text };
474          if (wide === WIDE) run.wide = true;
475          x.ghostty_render_state_row_cells_get(cells, cellData("HAS_STYLING"), length);
476          if (g.view().getUint8(length)) {
477            g.view().setUint32(style + styleType.fields.size.offset, styleType.size, true);
478            x.ghostty_render_state_row_cells_get(cells, cellData("STYLE"), style);
479            if (
480              x.ghostty_render_state_row_cells_get(cells, cellData("FG_COLOR"), rgb) === SUCCESS
481            ) {
482              run.fg = rgbAt(rgb);
483            }
484            if (
485              x.ghostty_render_state_row_cells_get(cells, cellData("BG_COLOR"), rgb) === SUCCESS
486            ) {
487              run.bg = rgbAt(rgb);
488            }
489            if (flag("inverse")) [run.fg, run.bg] = [run.bg ?? background, run.fg ?? foreground];
490            if (flag("bold")) run.bold = true;
491            if (flag("italic")) run.italic = true;
492            if (flag("faint")) run.faint = true;
493            if (flag("strikethrough")) run.strikethrough = true;
494            if (flag("invisible")) run.invisible = true;
495            const underline = g.view().getInt32(style + styleType.fields.underline.offset, true);
496            if (underline) run.underline = underline;
497          }
498          const plain = Object.keys(run).length === 1;
499          const last = runs.at(-1);
500          if (last && !run.wide && !last.wide && sameStyle(last, run)) last.text += run.text;
501          else runs.push(run);
502          // trailing blanks in the default style are dropped, so copied text has no trailing spaces
503          blank = plain && text === " " ? blank + 1 : 0;
504        }
505        if (blank > 0) {
506          const last = runs.at(-1)!;
507          last.text = last.text.slice(0, last.text.length - blank);
508          if (last.text === "") runs.pop();
509        }
510        changed.set(row, runs);
511        g.view().setUint8(length, 0);
512        x.ghostty_render_state_row_set(
513          rows,
514          g.value("GhosttyRenderStateRowOption", "DIRTY"),
515          length,
516        );
517      }
518    } finally {
519      x.ghostty_wasm_free(graphemes, 4 * 64);
520    }
521    g.view().setUint32(out, g.value("GhosttyRenderStateDirty", "FALSE"), true);
522    x.ghostty_render_state_set(state, g.value("GhosttyRenderStateOption", "DIRTY"), out);
523    return { cols, rows: height, changed, cursor };
524  }
525
526  /** The bytes a key press sends the program, as the terminal's modes say to encode it; `null` for none. */
527  key(input: KeyInput, action: "press" | "release" = "press"): Uint8Array | null {
528    const g = this.#g;
529    const x = g.x;
530    this.#keys ??= {
531      encoder: g.make((out) => x.ghostty_key_encoder_new(0, out), "key encoder"),
532      event: g.make((out) => x.ghostty_key_event_new(0, out), "key event"),
533    };
534    const { encoder, event } = this.#keys;
535    const name = input.code.replace(/^Key(?=[A-Z]$)/, "").replace(/([a-z])([A-Z0-9])/g, "$1_$2")
536      .toUpperCase();
537    const key = g.type("GhosttyKey").values[name] ?? g.value("GhosttyKey", "UNIDENTIFIED");
538    x.ghostty_key_event_set_action(
539      event,
540      g.value(
541        "GhosttyKeyAction",
542        action === "release" ? "RELEASE" : input.repeat ? "REPEAT" : "PRESS",
543      ),
544    );
545    x.ghostty_key_event_set_key(event, key);
546    x.ghostty_key_event_set_mods(
547      event,
548      (input.shiftKey ? MODS.shift : 0) | (input.ctrlKey ? MODS.ctrl : 0) |
549        (input.altKey ? MODS.alt : 0) |
550        (input.metaKey ? MODS.super : 0),
551    );
552    // a key that types something says what, such as "a"; named keys, such as "Enter", type nothing of their own
553    const typed = [...input.key].length === 1 ? input.key : "";
554    const text = new TextEncoder().encode(typed);
555    const textPointer = x.ghostty_wasm_alloc(Math.max(1, text.length));
556    g.bytes().set(text, textPointer);
557    x.ghostty_key_event_set_utf8(event, textPointer, text.length);
558    x.ghostty_key_event_set_unshifted_codepoint(
559      event,
560      typed ? typed.toLowerCase().codePointAt(0)! : 0,
561    );
562    x.ghostty_key_encoder_setopt_from_terminal(encoder, this.handle);
563    const output = x.ghostty_wasm_alloc(128);
564    const length = g.scratch(4);
565    try {
566      const result = x.ghostty_key_encoder_encode(encoder, event, output, 128, length);
567      const size = g.view().getUint32(length, true);
568      return result === SUCCESS && size > 0 ? g.bytes().slice(output, output + size) : null;
569    } finally {
570      x.ghostty_wasm_free(output, 128);
571      x.ghostty_wasm_free(textPointer, Math.max(1, text.length));
572    }
573  }
574
575  /** Pasted text as the program should get it: bracketed if it asked for that. */
576  paste(text: string): Uint8Array {
577    const g = this.#g;
578    const data = new TextEncoder().encode(text);
579    const capacity = data.length + 16;
580    const output = g.x.ghostty_wasm_alloc(capacity);
581    const length = g.scratch(4);
582    try {
583      return g.with(data, (pointer, size) => {
584        g.check(
585          g.x.ghostty_paste_encode(
586            pointer,
587            size,
588            this.mode(BRACKETED_PASTE) ? 1 : 0,
589            output,
590            capacity,
591            length,
592          ),
593          "paste",
594        );
595        return g.bytes().slice(output, output + g.view().getUint32(length, true));
596      });
597    } finally {
598      g.x.ghostty_wasm_free(output, capacity);
599    }
600  }
601
602  free() {
603    const x = this.#g.x;
604    if (this.#render) {
605      x.ghostty_render_state_row_cells_free(this.#render.cells);
606      x.ghostty_render_state_row_iterator_free(this.#render.rows);
607      x.ghostty_render_state_free(this.#render.state);
608    }
609    if (this.#keys) {
610      x.ghostty_key_event_free(this.#keys.event);
611      x.ghostty_key_encoder_free(this.#keys.encoder);
612    }
613    x.ghostty_terminal_free(this.handle);
614  }
615}
616
617const STYLE_KEYS = [
618  "fg",
619  "bg",
620  "bold",
621  "italic",
622  "faint",
623  "underline",
624  "strikethrough",
625  "invisible",
626] as const;
627
628function sameStyle(a: Run, b: Run): boolean {
629  return STYLE_KEYS.every((key) => {
630    const [p, q] = [a[key], b[key]];
631    return Array.isArray(p) && Array.isArray(q) ? p.every((v, i) => v === q[i]) : p === q;
632  });
633}