1// The headless terminal: libghostty-vt bound to a bytes-in / cells-out surface.
2// Wire realities this layer absorbs: deltas arrive as JS strings that may split
3// escape sequences AND surrogate pairs at arbitrary boundaries (host windows are
4// UTF-16-code-unit sized), and a replayed scrollback head may open mid-sequence.
5// The parser is streaming (split sequences carry across writes); lone surrogates
6// at write boundaries are held back and re-joined here (`pendingSurrogate`).
7
8import { domCodeToGhosttyKey, domEventMods } from "../keys.ts";
9import { ShellCommands, type CommandAnchor, type ShellCommand } from "../shell/commands.ts";
10import { parseShellMark, type ShellMark } from "../shell/marks.ts";
11import { OscScanner } from "../shell/osc.ts";
12import {
13 GhosttyCellContentTag,
14 GhosttyCellData,
15 GhosttyCellSemanticContent,
16 GhosttyCellWide,
17 GhosttyClipboardLocation,
18 GhosttyClipboardWriteResult,
19 GhosttyKeyAction,
20 GhosttyMouseAction,
21 GhosttyMouseButton,
22 GhosttyMouseEncoderOption,
23 GhosttyMouseTrackingMode,
24 GhosttyPointTag,
25 GhosttyRowData,
26 GhosttyRenderStateData,
27 GhosttyRenderStateDirty,
28 GhosttyRenderStateOption,
29 GhosttyRenderStateRowCellsData,
30 GhosttyRenderStateRowData,
31 GhosttyRenderStateRowOption,
32 GhosttyResult,
33 GhosttyStyleColorTag,
34 GhosttyRowSemanticPrompt,
35 GhosttyTerminalScrollViewportTag,
36 GhosttyTerminalData,
37 GhosttyTerminalScreen,
38 GhosttyTerminalOption,
39} from "./constants.ts";
40import { GhosttyModule, type WasmTrampoline } from "./module.ts";
41
42export interface TerminalOptions {
43 cols: number;
44 rows: number;
45 /** Scrollback limit in BYTES of page memory (libghostty semantics), not lines. */
46 maxScrollbackBytes?: number;
47 /**
48 * Watch the byte stream for OSC 133/633 and maintain `terminal.shell`.
49 * Costs one JS pass over incoming bytes (ESC-skipping, so cheap on ordinary
50 * output) plus a tracked grid ref per command boundary. Default true.
51 */
52 shellIntegration?: boolean;
53 /** Retained commands; anchors beyond this are freed. Default 512. */
54 commandLimit?: number;
55}
56
57export interface TerminalEvents {
58 /** Query responses (DA/DSR/…) the host must forward to the pty as input. */
59 onOutput?: (bytes: Uint8Array) => void;
60 onBell?: () => void;
61 onTitleChanged?: (title: string) => void;
62 onPwdChanged?: (pwd: string) => void;
63 /** Every recognized shell-integration mark, before the command model folds it. */
64 onShellMark?: (mark: ShellMark) => void;
65 /** A command was submitted (OSC 133/633 `C`). */
66 onCommandStart?: (command: ShellCommand) => void;
67 /** A command finished (`D`), or was closed out by the next prompt. */
68 onCommandEnd?: (command: ShellCommand) => void;
69 /**
70 * A program asked to write the clipboard (OSC 52 / iTerm2 OSC 1337 Copy).
71 * ***Denied unless this handler returns true.*** Silent clipboard writes from
72 * whatever is running in a terminal — including something on the far end of
73 * an ssh session — are a real hazard, so the package never writes the
74 * clipboard itself and never opts you in. Reads (`OSC 52 ?`) are refused by
75 * the core and never surface here.
76 */
77 onClipboardWrite?: (write: ClipboardWrite) => boolean | void;
78}
79
80export interface ClipboardWrite {
81 /** Which selection the program targeted. */
82 location: "standard" | "selection" | "primary";
83 /** `text/plain` content, or "" when the program sent none. */
84 text: string;
85 /** Every representation offered, in order; commit atomically or not at all. */
86 entries: Array<{ mime: string; data: string }>;
87 /** True when the program asked to CLEAR the destination (no content). */
88 clear: boolean;
89}
90
91export interface CellSnapshot {
92 /** Grapheme text; "" for an empty cell. */
93 text: string;
94 /** 1 or 2 columns. Spacer heads own one column; spacer tails are omitted. */
95 width: number;
96 /** A soft-wrap spacer head owns a blank cell but contributes no grapheme. */
97 spacer: "none" | "head";
98 /** DECSCA protection; protected cells survive selective erase. */
99 protected: boolean;
100 /** OSC 133 semantic content. Output is Ghostty's zero/default value. */
101 semantic: "output" | "input" | "prompt";
102 /** Raw OSC 8 presence; true with a null URI only when URI bytes are malformed. */
103 hasOsc8: boolean;
104 /** Exact OSC 8 URI, or null when absent/malformed; use `hasOsc8` to distinguish. */
105 osc8Uri: string | null;
106 /** Resolved RGB as 0xRRGGBB, or -1 for "default". */
107 fg: number;
108 bg: number;
109 /** Resolved underline RGB as 0xRRGGBB, or -1 for the current foreground. */
110 underlineColor: number;
111 bold: boolean;
112 italic: boolean;
113 faint: boolean;
114 blink: boolean;
115 inverse: boolean;
116 invisible: boolean;
117 strikethrough: boolean;
118 overline: boolean;
119 /** GhosttyStyleUnderline value; 0 = none. */
120 underline: number;
121}
122
123/** True only when removing this trailing cell loses no canonical presentation state. */
124export function isCanonicalDefaultCell(cell: CellSnapshot): boolean {
125 return (
126 cell.text === "" &&
127 cell.width === 1 &&
128 cell.spacer === "none" &&
129 !cell.protected &&
130 cell.semantic === "output" &&
131 !cell.hasOsc8 &&
132 cell.osc8Uri === null &&
133 cell.fg === -1 &&
134 cell.bg === -1 &&
135 cell.underlineColor === -1 &&
136 !cell.bold &&
137 !cell.italic &&
138 !cell.faint &&
139 !cell.blink &&
140 !cell.inverse &&
141 !cell.invisible &&
142 !cell.strikethrough &&
143 !cell.overline &&
144 cell.underline === 0
145 );
146}
147
148export function trimCanonicalDefaultTail(cells: CellSnapshot[]): void {
149 let end = cells.length;
150 while (end > 0 && isCanonicalDefaultCell(cells[end - 1]!)) end--;
151 cells.length = end;
152}
153
154export interface RowSnapshot {
155 index: number;
156 wrap: boolean;
157 wrapContinuation: boolean;
158 semanticPrompt: "none" | "prompt" | "continuation";
159 hyperlink: boolean;
160 cells: CellSnapshot[];
161}
162
163type RowMetadata = Omit<RowSnapshot, "index" | "cells">;
164
165export interface CursorSnapshot {
166 x: number;
167 y: number;
168 style: "bar" | "block" | "underline" | "hollow";
169 blinking: boolean;
170 wide: boolean;
171 /** Explicit OSC 12 RGB as 0xRRGGBB, or -1 for the CSS theme fallback. */
172 color: number;
173}
174
175export interface FrameSnapshot {
176 cols: number;
177 rows: number;
178 dirty: "none" | "partial" | "full";
179 /** Only rows that need repainting (all rows when dirty === "full" or `full` was requested). */
180 dirtyRows: RowSnapshot[];
181 cursor: CursorSnapshot | null;
182 /** The program's OSC 10/11 override as 0xRRGGBB, or -1 when the theme decides. */
183 foreground: number;
184 background: number;
185}
186
187/** Exclusive consumer of dirty snapshots; dispose it before attaching another renderer. */
188export interface FrameReader {
189 snapshot(options?: { full?: boolean }): FrameSnapshot;
190 dispose(): void;
191}
192
193export interface Scrollbar {
194 total: number;
195 offset: number;
196 len: number;
197}
198
199const CURSOR_STYLES = ["bar", "block", "underline", "hollow"] as const;
200const encoder = new TextEncoder();
201// URI identity must not inherit TextDecoder's default leading-BOM removal or
202// replacement-character collisions for malformed UTF-8.
203const osc8Decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
204
205const CLIPBOARD_LOCATIONS: Record<number, "standard" | "selection" | "primary"> = {
206 [GhosttyClipboardLocation.GHOSTTY_CLIPBOARD_LOCATION_STANDARD]: "standard",
207 [GhosttyClipboardLocation.GHOSTTY_CLIPBOARD_LOCATION_SELECTION]: "selection",
208 [GhosttyClipboardLocation.GHOSTTY_CLIPBOARD_LOCATION_PRIMARY]: "primary",
209};
210
211const MOUSE_ACTIONS = {
212 press: GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_PRESS,
213 release: GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_RELEASE,
214 motion: GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_MOTION,
215} as const;
216
217/**
218 * DOM `MouseEvent.button` uses 0/1/2 = left/middle/right; the VT numbering
219 * (and libghostty's) puts right before middle. Wheel arrives as buttons 4-7,
220 * which terminals report as presses of the corresponding button.
221 */
222function domButton(button: number): number | null {
223 switch (button) {
224 case 0:
225 return GhosttyMouseButton.GHOSTTY_MOUSE_BUTTON_LEFT;
226 case 1:
227 return GhosttyMouseButton.GHOSTTY_MOUSE_BUTTON_MIDDLE;
228 case 2:
229 return GhosttyMouseButton.GHOSTTY_MOUSE_BUTTON_RIGHT;
230 case 3:
231 return GhosttyMouseButton.GHOSTTY_MOUSE_BUTTON_FOUR;
232 case 4:
233 return GhosttyMouseButton.GHOSTTY_MOUSE_BUTTON_FIVE;
234 case 5:
235 return GhosttyMouseButton.GHOSTTY_MOUSE_BUTTON_SIX;
236 case 6:
237 return GhosttyMouseButton.GHOSTTY_MOUSE_BUTTON_SEVEN;
238 default:
239 return null;
240 }
241}
242
243const INITIAL_GRAPHEME_CAPACITY = 64;
244const INITIAL_HYPERLINK_URI_CAPACITY = 256;
245const MAX_RETAINED_INPUT_CAPACITY = 65_536;
246const SYNCHRONIZED_OUTPUT_MODE = 2026;
247const UINT16_MAX = 0xffff;
248const UINT32_MAX = 0xffff_ffff;
249const INT32_MIN = -0x8000_0000;
250const INT32_MAX = 0x7fff_ffff;
251const FLOAT32_MAX = 3.4028234663852886e38;
252
253interface HyperlinkScratch {
254 point: ReturnType<GhosttyModule["allocStruct"]>;
255 ref: ReturnType<GhosttyModule["allocStruct"]>;
256 outLen: ReturnType<GhosttyModule["allocScratch"]>;
257 uri: ReturnType<GhosttyModule["allocScratch"]>;
258 uriCapacity: number;
259}
260
261function codepointsToString(view: DataView, ptr: number, count: number): string {
262 const pieces: string[] = [];
263 for (let start = 0; start < count; start += 1024) {
264 const codepoints: number[] = [];
265 const end = Math.min(start + 1024, count);
266 for (let i = start; i < end; i++) codepoints.push(view.getUint32(ptr + i * 4, true));
267 pieces.push(String.fromCodePoint(...codepoints));
268 }
269 return pieces.join("");
270}
271
272function assertGhosttySuccess(result: number, operation: string): void {
273 if (result !== GhosttyResult.GHOSTTY_SUCCESS) throw new Error(`${operation} failed: ${result}`);
274}
275
276function integerInRange(name: string, value: number, min: number, max: number): number {
277 if (!Number.isInteger(value) || value < min || value > max) {
278 throw new RangeError(`${name} must be an integer from ${min} through ${max}: ${value}`);
279 }
280 return value;
281}
282
283/**
284 * A libghostty tracked grid ref behind the pure `CommandAnchor` seam. The
285 * handle stays valid after the terminal is freed (it then reports no value),
286 * so double-free is the only real hazard — `free()` is idempotent here.
287 */
288class TrackedAnchor implements CommandAnchor {
289 private mod: GhosttyModule;
290 private handle: number;
291
292 constructor(mod: GhosttyModule, handle: number) {
293 this.mod = mod;
294 this.handle = handle;
295 }
296
297 point(): { x: number; y: number } | null {
298 return this.read(GhosttyPointTag.GHOSTTY_POINT_TAG_VIEWPORT);
299 }
300
301 screenPoint(): { x: number; y: number } | null {
302 return this.read(GhosttyPointTag.GHOSTTY_POINT_TAG_SCREEN);
303 }
304
305 free(): void {
306 if (this.handle === 0) return;
307 this.mod.fn("ghostty_tracked_grid_ref_free")(this.handle);
308 this.handle = 0;
309 }
310
311 private read(tag: number): { x: number; y: number } | null {
312 if (this.handle === 0) return null;
313 const coord = this.mod.allocStruct("GhosttyPointCoordinate");
314 const result = this.mod.fn("ghostty_tracked_grid_ref_point")(this.handle, tag, coord.ptr);
315 let out: { x: number; y: number } | null = null;
316 if (result === GhosttyResult.GHOSTTY_SUCCESS) {
317 const view = this.mod.view();
318 out = {
319 x: view.getUint16(coord.ptr + this.mod.field("GhosttyPointCoordinate", "x").offset, true),
320 y: view.getUint32(coord.ptr + this.mod.field("GhosttyPointCoordinate", "y").offset, true),
321 };
322 }
323 coord.free();
324 return out;
325 }
326}
327
328export class Terminal {
329 readonly mod: GhosttyModule;
330 /** Shell-integration command model; empty unless the shell is instrumented. */
331 readonly shell: ShellCommands;
332 private scanner: OscScanner | null;
333 private term = 0;
334 private renderState = 0;
335 private rowIter = 0;
336 private rowCells = 0;
337 private rowMetadata: RowMetadata[] = [];
338 private hyperlinkScratch: HyperlinkScratch | null = null;
339 private asciiRowScratch: { ptr: number; capacity: number; free: () => void } | null = null;
340 private inputScratch: { ptr: number; capacity: number; free: () => void } | null = null;
341 private keyEncoder = 0;
342 private keyEvent = 0;
343 private mouseEncoder = 0;
344 private mouseEvent = 0;
345 private modeValue = 0;
346 private colsNow: number;
347 private rowsNow: number;
348 private viewportRevisionNow = 0;
349 private pendingSurrogate = "";
350 private events: TerminalEvents;
351 private disposed = false;
352 private disposePending = false;
353 private writing = false;
354 private callbackErrors: unknown[] | null = null;
355 private trampolines: WasmTrampoline[] = [];
356 private frameReaderOwner: object | null = null;
357
358 constructor(mod: GhosttyModule, options: TerminalOptions, events: TerminalEvents = {}) {
359 const cols = integerInRange("cols", options.cols, 1, UINT16_MAX);
360 const rows = integerInRange("rows", options.rows, 1, UINT16_MAX);
361 const maxScrollbackBytes = integerInRange(
362 "maxScrollbackBytes",
363 options.maxScrollbackBytes ?? 4_194_304,
364 0,
365 UINT32_MAX,
366 );
367 const commandLimit =
368 options.commandLimit === undefined
369 ? undefined
370 : integerInRange("commandLimit", options.commandLimit, 0, Number.MAX_SAFE_INTEGER);
371 this.mod = mod;
372 this.events = events;
373 this.colsNow = cols;
374 this.rowsNow = rows;
375 this.scanner = options.shellIntegration === false ? null : new OscScanner();
376 this.shell = new ShellCommands({
377 anchor: () => this.anchorAtCursor(),
378 readCommandLine: (from, to) => this.textBetween(from, to),
379 ...(commandLimit !== undefined ? { limit: commandLimit } : {}),
380 onCommandStart: (command) =>
381 this.invokeCallback(() => this.events.onCommandStart?.(command), undefined),
382 onCommandEnd: (command) =>
383 this.invokeCallback(() => this.events.onCommandEnd?.(command), undefined),
384 });
385
386 try {
387 const opts = mod.allocStruct("GhosttyTerminalOptions");
388 try {
389 const f = (name: string) => mod.field("GhosttyTerminalOptions", name);
390 mod.view().setUint16(opts.ptr + f("cols").offset, cols, true);
391 mod.view().setUint16(opts.ptr + f("rows").offset, rows, true);
392 mod.view().setUint32(opts.ptr + f("max_scrollback").offset, maxScrollbackBytes, true);
393 this.term = mod.create("ghostty_terminal_new", opts.ptr);
394 } finally {
395 opts.free();
396 }
397
398 this.renderState = mod.create("ghostty_render_state_new");
399 this.rowIter = mod.create("ghostty_render_state_row_iterator_new");
400 this.rowCells = mod.create("ghostty_render_state_row_cells_new");
401 this.keyEncoder = mod.create("ghostty_key_encoder_new");
402 this.keyEvent = mod.create("ghostty_key_event_new");
403 this.mouseEncoder = mod.create("ghostty_mouse_encoder_new");
404 this.mouseEvent = mod.create("ghostty_mouse_event_new");
405 this.modeValue = mod.fn("ghostty_wasm_alloc_u8")();
406 if (this.modeValue === 0) throw new Error("ghostty wasm alloc failed");
407
408 // ***Verified inert at the pinned vendor commit.*** Keep the renderer's
409 // cell dedup until a vendor bump proves this encoder option effective.
410 const trackLastCell = mod.fn("ghostty_wasm_alloc_u8")();
411 if (trackLastCell === 0) throw new Error("ghostty wasm alloc failed");
412 try {
413 mod.view().setUint8(trackLastCell, 1);
414 mod.fn("ghostty_mouse_encoder_setopt")(
415 this.mouseEncoder,
416 GhosttyMouseEncoderOption.GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL,
417 trackLastCell,
418 );
419 } finally {
420 mod.fn("ghostty_wasm_free_u8")(trackLastCell);
421 }
422
423 const setEffect = (option: number, trampoline: WasmTrampoline) => {
424 this.trampolines.push(trampoline);
425 assertGhosttySuccess(
426 mod.fn("ghostty_terminal_set")(this.term, option, trampoline.index),
427 "terminal_set(effect)",
428 );
429 };
430 setEffect(
431 GhosttyTerminalOption.GHOSTTY_TERMINAL_OPT_WRITE_PTY,
432 mod.trampoline(4, (_t, _u, data, len) =>
433 this.invokeCallback(() => this.events.onOutput?.(mod.copyBytes(data, len)), undefined),
434 ),
435 );
436 setEffect(
437 GhosttyTerminalOption.GHOSTTY_TERMINAL_OPT_BELL,
438 mod.trampoline(2, () => this.invokeCallback(() => this.events.onBell?.(), undefined)),
439 );
440 setEffect(
441 GhosttyTerminalOption.GHOSTTY_TERMINAL_OPT_TITLE_CHANGED,
442 mod.trampoline(2, () =>
443 this.invokeCallback(() => {
444 const title = this.title();
445 if (title !== null) this.events.onTitleChanged?.(title);
446 }, undefined),
447 ),
448 );
449 setEffect(
450 GhosttyTerminalOption.GHOSTTY_TERMINAL_OPT_PWD_CHANGED,
451 mod.trampoline(2, () =>
452 this.invokeCallback(() => {
453 const pwd = this.pwd();
454 if (pwd !== null) this.events.onPwdChanged?.(pwd);
455 }, undefined),
456 ),
457 );
458 setEffect(
459 GhosttyTerminalOption.GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE,
460 mod.trampoline(
461 3,
462 (_t, _u, writePtr) =>
463 this.invokeCallback(
464 () => this.onClipboardWrite(writePtr),
465 GhosttyClipboardWriteResult.GHOSTTY_CLIPBOARD_WRITE_RESULT_IO_ERROR,
466 ),
467 1,
468 ),
469 );
470
471 // Grapheme clustering (mode 2027) matches the Ghostty app.
472 assertGhosttySuccess(
473 mod.fn("ghostty_terminal_mode_set")(this.term, 2027, 1),
474 "terminal_mode_set(grapheme clustering)",
475 );
476 } catch (error) {
477 this.releaseWasmResources();
478 this.shell.dispose();
479 this.disposed = true;
480 throw error;
481 }
482 }
483
484 get cols(): number {
485 return this.colsNow;
486 }
487
488 get rows(): number {
489 return this.rowsNow;
490 }
491
492 /** Changes when public geometry or scroll methods can invalidate a view's projection. */
493 get viewportRevision(): number {
494 return this.viewportRevisionNow;
495 }
496
497 /** DEC mode 2026 suppresses presentation until the application closes the update. */
498 get synchronizedOutput(): boolean {
499 this.assertLive();
500 this.mod.view().setUint8(this.modeValue, 0);
501 assertGhosttySuccess(
502 this.mod.fn("ghostty_terminal_mode_get")(this.term, SYNCHRONIZED_OUTPUT_MODE, this.modeValue),
503 "terminal_mode_get(synchronized_output)",
504 );
505 return this.mod.view().getUint8(this.modeValue) !== 0;
506 }
507
508 /** Release an unterminated synchronized update so untrusted output cannot freeze presentation. */
509 resetSynchronizedOutput(): void {
510 this.assertLive();
511 assertGhosttySuccess(
512 this.mod.fn("ghostty_terminal_mode_set")(this.term, SYNCHRONIZED_OUTPUT_MODE, 0),
513 "terminal_mode_set(synchronized_output)",
514 );
515 }
516
517 /** Feed pty output. Strings may split surrogate pairs at chunk boundaries. */
518 write(data: string | Uint8Array): void {
519 this.assertLive();
520 if (this.writing) throw new Error("terminal writes are not reentrant");
521 this.writing = true;
522 const callbackErrors: unknown[] = [];
523 this.callbackErrors = callbackErrors;
524 try {
525 let bytes: Uint8Array;
526 if (typeof data === "string") {
527 let text = this.pendingSurrogate + data;
528 this.pendingSurrogate = "";
529 const last = text.charCodeAt(text.length - 1);
530 if (last >= 0xd800 && last <= 0xdbff) {
531 this.pendingSurrogate = text.slice(-1);
532 text = text.slice(0, -1);
533 }
534 if (text.length === 0) return;
535 bytes = encoder.encode(text);
536 } else {
537 if (this.pendingSurrogate === "") bytes = data;
538 else {
539 const prefix = encoder.encode(this.pendingSurrogate);
540 this.pendingSurrogate = "";
541 bytes = new Uint8Array(prefix.length + data.length);
542 bytes.set(prefix);
543 bytes.set(data, prefix.length);
544 }
545 }
546 if (bytes.length === 0) return;
547 this.processBytes(bytes);
548 if (callbackErrors.length === 1) throw callbackErrors[0];
549 if (callbackErrors.length > 1) {
550 throw new AggregateError(callbackErrors, "terminal callbacks failed");
551 }
552 } finally {
553 this.callbackErrors = null;
554 this.writing = false;
555 if (this.disposePending) {
556 this.disposePending = false;
557 this.free();
558 }
559 }
560 }
561
562 private processBytes(bytes: Uint8Array): void {
563 const scanner = this.scanner;
564 if (scanner === null) {
565 this.feed(bytes);
566 return;
567 }
568 // Marks are anchored to the cursor, so each terminator is fed before its
569 // anchor is taken. Callback failures are retained until the whole chunk lands.
570 const spans = scanner.scan(bytes);
571 if (spans.length === 0) {
572 this.feed(bytes);
573 return;
574 }
575 let from = 0;
576 for (const span of spans) {
577 this.feed(bytes.subarray(from, span.end));
578 from = span.end;
579 const mark = parseShellMark(span.payload);
580 if (mark === null) continue;
581 this.invokeCallback(() => this.events.onShellMark?.(mark), undefined);
582 this.shell.handle(mark);
583 }
584 if (from < bytes.length) this.feed(bytes.subarray(from));
585 }
586
587 private invokeCallback<T>(callback: () => T, fallback: T): T {
588 try {
589 return callback();
590 } catch (error) {
591 if (this.callbackErrors === null) throw error;
592 this.callbackErrors.push(error);
593 return fallback;
594 }
595 }
596
597 private feed(bytes: Uint8Array): void {
598 if (bytes.length === 0) return;
599 if (bytes.length <= MAX_RETAINED_INPUT_CAPACITY) {
600 if (this.inputScratch === null || this.inputScratch.capacity < bytes.length) {
601 const capacity = 2 ** Math.ceil(Math.log2(bytes.length));
602 const next = this.mod.allocScratch(capacity);
603 this.inputScratch?.free();
604 this.inputScratch = { ...next, capacity };
605 }
606 this.mod.bytes(this.inputScratch.ptr, bytes.length).set(bytes);
607 this.mod.fn("ghostty_terminal_vt_write")(this.term, this.inputScratch.ptr, bytes.length);
608 return;
609 }
610 const buf = this.mod.allocBytes(bytes);
611 try {
612 this.mod.fn("ghostty_terminal_vt_write")(this.term, buf.ptr, buf.len);
613 } finally {
614 buf.free();
615 }
616 }
617
618 /**
619 * Anchor a point so it survives scrolling, scrollback pruning and reflow
620 * (libghostty tracked grid ref). Each live anchor adds bookkeeping to every
621 * terminal mutation — keep them few and free them.
622 */
623 trackPoint(
624 x: number,
625 y: number,
626 space: "active" | "viewport" | "screen" = "active",
627 ): CommandAnchor | null {
628 this.assertLive();
629 integerInRange("x", x, 0, UINT16_MAX);
630 integerInRange("y", y, 0, UINT32_MAX);
631 const mod = this.mod;
632 const tag =
633 space === "viewport"
634 ? GhosttyPointTag.GHOSTTY_POINT_TAG_VIEWPORT
635 : space === "screen"
636 ? GhosttyPointTag.GHOSTTY_POINT_TAG_SCREEN
637 : GhosttyPointTag.GHOSTTY_POINT_TAG_ACTIVE;
638 const point = this.allocPoint(tag, x, y);
639 try {
640 const out = mod.allocScratch(4);
641 try {
642 const result = mod.fn("ghostty_terminal_grid_ref_track")(this.term, point.ptr, out.ptr);
643 const handle = mod.view().getUint32(out.ptr, true);
644 if (result !== GhosttyResult.GHOSTTY_SUCCESS || handle === 0) return null;
645 return new TrackedAnchor(mod, handle);
646 } finally {
647 out.free();
648 }
649 } finally {
650 point.free();
651 }
652 }
653
654 private anchorAtCursor(): CommandAnchor | null {
655 const scratch = this.mod.allocScratch(8);
656 try {
657 const read = (what: number) => {
658 this.mod.view().setUint32(scratch.ptr, 0, true);
659 const result = this.mod.fn("ghostty_terminal_get")(this.term, what, scratch.ptr);
660 return result === GhosttyResult.GHOSTTY_SUCCESS
661 ? this.mod.view().getUint16(scratch.ptr, true)
662 : -1;
663 };
664 const x = read(GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_CURSOR_X);
665 const y = read(GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_CURSOR_Y);
666 if (x < 0 || y < 0) return null;
667 return this.trackPoint(x, y, "active");
668 } finally {
669 scratch.free();
670 }
671 }
672
673 private allocPoint(tag: number, x: number, y: number) {
674 const point = this.mod.allocStruct("GhosttyPoint");
675 try {
676 const f = (name: string) => this.mod.field("GhosttyPoint", name).offset;
677 const coord = point.ptr + f("value");
678 this.mod.view().setUint32(point.ptr + f("tag"), tag, true);
679 this.mod
680 .view()
681 .setUint16(coord + this.mod.field("GhosttyPointCoordinate", "x").offset, x, true);
682 this.mod
683 .view()
684 .setUint32(coord + this.mod.field("GhosttyPointCoordinate", "y").offset, y, true);
685 return point;
686 } catch (error) {
687 point.free();
688 throw error;
689 }
690 }
691
692 /**
693 * Plain text between two anchors (`from` inclusive, `to` exclusive), reading
694 * the grid directly. Soft-wrapped rows are joined without a newline. Returns
695 * null when either anchor has been discarded.
696 *
697 * This is the command-line fallback for shells that emit OSC 133 without
698 * VS Code's `633;E`, and the basis for copy-output.
699 */
700 textBetween(from: CommandAnchor, to: CommandAnchor): string | null {
701 this.assertLive();
702 const start = from.screenPoint();
703 const end = to.screenPoint();
704 if (start === null || end === null) return null;
705 if (end.y < start.y || (end.y === start.y && end.x <= start.x)) return "";
706
707 const mod = this.mod;
708 const allocations: Array<{ free(): void }> = [];
709 const own = <T extends { free(): void }>(allocation: T): T => {
710 allocations.push(allocation);
711 return allocation;
712 };
713 let text = "";
714 try {
715 const ref = own(mod.allocStruct("GhosttyGridRef"));
716 const xOffset = mod.field("GhosttyGridRef", "x").offset;
717 let graphemeCapacity = INITIAL_GRAPHEME_CAPACITY;
718 let graphemes = own(mod.allocScratch(graphemeCapacity * 4));
719 const outLen = own(mod.allocScratch(4));
720 const cellOut = own(mod.allocScratch(8));
721 // GhosttyRow, like GhosttyCell, is a packed u64 value rather than a pointer.
722 const rowOut = own(mod.allocScratch(8));
723 const scratch = own(mod.allocScratch(8));
724 for (let y = start.y; y <= end.y; y++) {
725 const point = this.allocPoint(GhosttyPointTag.GHOSTTY_POINT_TAG_SCREEN, 0, y);
726 let resolved: number;
727 try {
728 resolved = mod.fn("ghostty_terminal_grid_ref")(this.term, point.ptr, ref.ptr);
729 } finally {
730 point.free();
731 }
732 if (resolved !== GhosttyResult.GHOSTTY_SUCCESS) break;
733
734 const firstX = y === start.y ? start.x : 0;
735 const lastX = y === end.y ? end.x : this.colsNow;
736 // The end anchor usually sits at column 0 of the row *after* the last
737 // line of content (the cursor after a newline). That row contributes
738 // nothing, and emitting it would add a phantom trailing line.
739 if (lastX <= firstX) break;
740 let line = "";
741 for (let x = firstX; x < lastX; x++) {
742 // The ref is a resolved position in one page node; x is the column
743 // within it, so a row is walked by bumping x rather than re-resolving
744 // (screen-tag lookups can traverse the whole page list).
745 mod.view().setUint16(ref.ptr + xOffset, x, true);
746 mod.view().setBigUint64(cellOut.ptr, 0n, true);
747 if (
748 mod.fn("ghostty_grid_ref_cell")(ref.ptr, cellOut.ptr) === GhosttyResult.GHOSTTY_SUCCESS
749 ) {
750 const raw = mod.view().getBigUint64(cellOut.ptr, true);
751 mod.view().setUint32(scratch.ptr, 0, true);
752 mod.fn("ghostty_cell_get")(raw, GhosttyCellData.GHOSTTY_CELL_DATA_WIDE, scratch.ptr);
753 const wide = mod.view().getUint32(scratch.ptr, true);
754 // Spacers carry no text of their own; emitting them would double
755 // the space after every wide glyph.
756 if (
757 wide === GhosttyCellWide.GHOSTTY_CELL_WIDE_SPACER_TAIL ||
758 wide === GhosttyCellWide.GHOSTTY_CELL_WIDE_SPACER_HEAD
759 ) {
760 continue;
761 }
762 }
763 mod.view().setUint32(outLen.ptr, 0, true);
764 let g = mod.fn("ghostty_grid_ref_graphemes")(
765 ref.ptr,
766 graphemes.ptr,
767 graphemeCapacity,
768 outLen.ptr,
769 );
770 if (g === GhosttyResult.GHOSTTY_OUT_OF_SPACE) {
771 const required = mod.view().getUint32(outLen.ptr, true);
772 const nextCapacity = 2 ** Math.ceil(Math.log2(required));
773 const next = own(mod.allocScratch(nextCapacity * 4));
774 graphemes.free();
775 graphemeCapacity = nextCapacity;
776 graphemes = next;
777 g = mod.fn("ghostty_grid_ref_graphemes")(
778 ref.ptr,
779 graphemes.ptr,
780 graphemeCapacity,
781 outLen.ptr,
782 );
783 }
784 if (g !== GhosttyResult.GHOSTTY_SUCCESS) continue;
785 const count = mod.view().getUint32(outLen.ptr, true);
786 if (count === 0) {
787 line += " ";
788 continue;
789 }
790 line += codepointsToString(mod.view(), graphemes.ptr, count);
791 }
792
793 // Soft-wrapped rows continue the same logical line.
794 mod.view().setUint16(ref.ptr + xOffset, 0, true);
795 let wrapped = false;
796 mod.view().setBigUint64(rowOut.ptr, 0n, true);
797 if (mod.fn("ghostty_grid_ref_row")(ref.ptr, rowOut.ptr) === GhosttyResult.GHOSTTY_SUCCESS) {
798 const row = mod.view().getBigUint64(rowOut.ptr, true);
799 mod.view().setUint8(scratch.ptr, 0);
800 if (
801 mod.fn("ghostty_row_get")(row, GhosttyRowData.GHOSTTY_ROW_DATA_WRAP, scratch.ptr) ===
802 GhosttyResult.GHOSTTY_SUCCESS
803 ) {
804 wrapped = mod.view().getUint8(scratch.ptr) !== 0;
805 }
806 }
807 text += wrapped ? line : `${line.replace(/\s+$/, "")}\n`;
808 }
809 } finally {
810 for (let index = allocations.length - 1; index >= 0; index--) allocations[index]!.free();
811 }
812 return text.replace(/\n$/, "");
813 }
814
815 /** The output a finished command produced, or null if it can't be recovered. */
816 outputText(command: ShellCommand): string | null {
817 if (command.outputStart === null || command.outputEnd === null) return null;
818 return this.textBetween(command.outputStart, command.outputEnd);
819 }
820
821 resize(cols: number, rows: number): void {
822 this.assertLive();
823 integerInRange("cols", cols, 1, UINT16_MAX);
824 integerInRange("rows", rows, 1, UINT16_MAX);
825 if (cols === this.colsNow && rows === this.rowsNow) return;
826 const result = this.mod.fn("ghostty_terminal_resize")(this.term, cols, rows);
827 if (result !== GhosttyResult.GHOSTTY_SUCCESS) throw new Error(`resize failed: ${result}`);
828 this.colsNow = cols;
829 this.rowsNow = rows;
830 this.viewportRevisionNow++;
831 }
832
833 scrollDelta(rows: number): void {
834 this.scroll(GhosttyTerminalScrollViewportTag.GHOSTTY_SCROLL_VIEWPORT_DELTA, rows);
835 }
836
837 scrollToBottom(): void {
838 this.scroll(GhosttyTerminalScrollViewportTag.GHOSTTY_SCROLL_VIEWPORT_BOTTOM, 0);
839 }
840
841 scrollToTop(): void {
842 this.scroll(GhosttyTerminalScrollViewportTag.GHOSTTY_SCROLL_VIEWPORT_TOP, 0);
843 }
844
845 scrollToRow(row: number): void {
846 this.scroll(GhosttyTerminalScrollViewportTag.GHOSTTY_SCROLL_VIEWPORT_ROW, row);
847 }
848
849 private scroll(tag: number, value: number): void {
850 this.assertLive();
851 if (tag === GhosttyTerminalScrollViewportTag.GHOSTTY_SCROLL_VIEWPORT_DELTA) {
852 integerInRange("scroll delta", value, INT32_MIN, INT32_MAX);
853 } else if (tag === GhosttyTerminalScrollViewportTag.GHOSTTY_SCROLL_VIEWPORT_ROW) {
854 integerInRange("scroll row", value, 0, UINT32_MAX);
855 }
856 const s = this.mod.allocStruct("GhosttyTerminalScrollViewport");
857 const tagField = this.mod.field("GhosttyTerminalScrollViewport", "tag");
858 const valueField = this.mod.field("GhosttyTerminalScrollViewport", "value");
859 this.mod.view().setUint32(s.ptr + tagField.offset, tag, true);
860 if (tag === GhosttyTerminalScrollViewportTag.GHOSTTY_SCROLL_VIEWPORT_DELTA) {
861 this.mod.view().setInt32(s.ptr + valueField.offset, value, true);
862 } else {
863 this.mod.view().setUint32(s.ptr + valueField.offset, value, true);
864 }
865 this.mod.fn("ghostty_terminal_scroll_viewport")(this.term, s.ptr);
866 s.free();
867 this.viewportRevisionNow++;
868 }
869
870 /**
871 * The theme's own colors, so the core can answer a program's `OSC 10/11/12`.
872 *
873 * ***This is a report, not a paint instruction.*** Painting stays
874 * CSS-custom-property-only: a snapshot color is the program's OSC OVERRIDE or
875 * `-1`, and setting a default here never makes one appear. What it buys is the
876 * query — codex asks the background before it draws, and a terminal that
877 * cannot answer gets a composer with no background at all rather than a
878 * fallback one.
879 *
880 * A `null` clears that default and takes the answer away again.
881 */
882 setDefaultColors(colors: {
883 foreground?: number | null;
884 background?: number | null;
885 cursor?: number | null;
886 }): void {
887 this.assertLive();
888 const mod = this.mod;
889 const set = (name: string, option: number, color: number | null | undefined) => {
890 if (color === undefined) return;
891 if (color === null) {
892 assertGhosttySuccess(
893 mod.fn("ghostty_terminal_set")(this.term, option, 0),
894 `terminal_set(${name})`,
895 );
896 return;
897 }
898 const rgb = integerInRange(name, color, 0, 0xffffff);
899 const struct = mod.allocStruct("GhosttyColorRgb");
900 try {
901 const view = mod.view();
902 view.setUint8(struct.ptr + mod.field("GhosttyColorRgb", "r").offset, (rgb >> 16) & 0xff);
903 view.setUint8(struct.ptr + mod.field("GhosttyColorRgb", "g").offset, (rgb >> 8) & 0xff);
904 view.setUint8(struct.ptr + mod.field("GhosttyColorRgb", "b").offset, rgb & 0xff);
905 assertGhosttySuccess(
906 mod.fn("ghostty_terminal_set")(this.term, option, struct.ptr),
907 `terminal_set(${name})`,
908 );
909 } finally {
910 struct.free();
911 }
912 };
913 set(
914 "foreground",
915 GhosttyTerminalOption.GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND,
916 colors.foreground,
917 );
918 set(
919 "background",
920 GhosttyTerminalOption.GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND,
921 colors.background,
922 );
923 set("cursor", GhosttyTerminalOption.GHOSTTY_TERMINAL_OPT_COLOR_CURSOR, colors.cursor);
924 }
925
926 /**
927 * The theme's 16 ANSI colors, as the palette a cell's color index resolves through.
928 *
929 * ***Unlike the default fore/background this is paint***: the core resolves an
930 * indexed color before the snapshot leaves it, so an unset palette is not the
931 * theme's and not neutral either — it is ghostty's own xterm defaults, dark
932 * terminal and all, under every theme. Entries 16-255 keep their defaults, a
933 * `null` slot keeps its own, and a program's `OSC 4` override outranks all of
934 * it.
935 *
936 * **_A palette the core already holds is never re-set_**: the set dirties the
937 * screen, and a host republishes its theme on every metric refresh — a full
938 * repaint per font load, per probe resize, over colors nobody changed.
939 */
940 setDefaultPalette(ansi: readonly (number | null)[]): void {
941 this.assertLive();
942 if (ansi.every((color) => color === null)) return;
943 const mod = this.mod;
944 const rgb = mod.layout["GhosttyColorRgb"]!;
945 const palette = mod.allocScratch(rgb.size * 256);
946 try {
947 assertGhosttySuccess(
948 mod.fn("ghostty_terminal_get")(
949 this.term,
950 GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT,
951 palette.ptr,
952 ),
953 "terminal_get(color_palette_default)",
954 );
955 const view = mod.view();
956 let changed = false;
957 ansi.slice(0, 16).forEach((color, index) => {
958 if (color === null) return;
959 const value = integerInRange(`ansi[${index}]`, color, 0, 0xffffff);
960 const entry = palette.ptr + index * rgb.size;
961 const channels = [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
962 for (const [offset, channel] of ["r", "g", "b"].entries()) {
963 const at = entry + rgb.fields[channel]!.offset;
964 if (view.getUint8(at) === channels[offset]!) continue;
965 view.setUint8(at, channels[offset]!);
966 changed = true;
967 }
968 });
969 if (!changed) return;
970 assertGhosttySuccess(
971 mod.fn("ghostty_terminal_set")(
972 this.term,
973 GhosttyTerminalOption.GHOSTTY_TERMINAL_OPT_COLOR_PALETTE,
974 palette.ptr,
975 ),
976 "terminal_set(color_palette)",
977 );
978 } finally {
979 palette.free();
980 }
981 }
982
983 scrollbar(): Scrollbar {
984 this.assertLive();
985 const s = this.mod.allocStruct("GhosttyTerminalScrollbar");
986 const result = this.mod.fn("ghostty_terminal_get")(
987 this.term,
988 GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_SCROLLBAR,
989 s.ptr,
990 );
991 if (result !== GhosttyResult.GHOSTTY_SUCCESS) {
992 s.free();
993 return { total: this.rowsNow, offset: 0, len: this.rowsNow };
994 }
995 const g = (name: string) =>
996 Number(
997 this.mod
998 .view()
999 .getBigUint64(s.ptr + this.mod.field("GhosttyTerminalScrollbar", name).offset, true),
1000 );
1001 const out = { total: g("total"), offset: g("offset"), len: g("len") };
1002 s.free();
1003 return out;
1004 }
1005
1006 title(): string | null {
1007 return this.getString(GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_TITLE);
1008 }
1009
1010 pwd(): string | null {
1011 return this.getString(GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_PWD);
1012 }
1013
1014 private getString(data: number): string | null {
1015 this.assertLive();
1016 const s = this.mod.allocStruct("GhosttyString");
1017 const result = this.mod.fn("ghostty_terminal_get")(this.term, data, s.ptr);
1018 if (result !== GhosttyResult.GHOSTTY_SUCCESS) {
1019 s.free();
1020 return null;
1021 }
1022 const ptr = this.mod
1023 .view()
1024 .getUint32(s.ptr + this.mod.field("GhosttyString", "ptr").offset, true);
1025 const len = this.mod
1026 .view()
1027 .getUint32(s.ptr + this.mod.field("GhosttyString", "len").offset, true);
1028 const text = len > 0 ? this.mod.readCString(ptr, len) : "";
1029 s.free();
1030 return text;
1031 }
1032
1033 /** Encode a DOM keydown into pty bytes. Returns null when the key isn't terminal input. */
1034 encodeKey(event: {
1035 code: string;
1036 key: string;
1037 ctrlKey: boolean;
1038 altKey: boolean;
1039 metaKey: boolean;
1040 shiftKey: boolean;
1041 repeat?: boolean;
1042 }): Uint8Array | null {
1043 this.assertLive();
1044 const keyValue = domCodeToGhosttyKey(event.code);
1045 if (keyValue === null) return null;
1046 const mod = this.mod;
1047 const setoptFromTerminal = mod.fn("ghostty_key_encoder_setopt_from_terminal");
1048 setoptFromTerminal(this.keyEncoder, this.term);
1049
1050 const ev = this.keyEvent;
1051 mod.fn("ghostty_key_event_set_action")(
1052 ev,
1053 event.repeat
1054 ? GhosttyKeyAction.GHOSTTY_KEY_ACTION_REPEAT
1055 : GhosttyKeyAction.GHOSTTY_KEY_ACTION_PRESS,
1056 );
1057 mod.fn("ghostty_key_event_set_key")(ev, keyValue);
1058 mod.fn("ghostty_key_event_set_mods")(ev, domEventMods(event));
1059
1060 const printable = event.key.length === 1 && !event.ctrlKey && !event.metaKey;
1061 let utf8Buf: { ptr: number; len: number; free: () => void } | null = null;
1062 let out: ReturnType<GhosttyModule["allocScratch"]> | null = null;
1063 let written: ReturnType<GhosttyModule["allocScratch"]> | null = null;
1064 try {
1065 if (printable) {
1066 utf8Buf = mod.encodeString(event.key);
1067 mod.fn("ghostty_key_event_set_utf8")(ev, utf8Buf.ptr, utf8Buf.len);
1068 // Shift/alt that produced the character are consumed by translation.
1069 mod.fn("ghostty_key_event_set_consumed_mods")(ev, domEventMods(event) & 0b101);
1070 const cp = event.key.codePointAt(0) ?? 0;
1071 mod.fn("ghostty_key_event_set_unshifted_codepoint")(
1072 ev,
1073 cp >= 0x41 && cp <= 0x5a ? cp + 0x20 : cp,
1074 );
1075 } else {
1076 mod.fn("ghostty_key_event_set_utf8")(ev, 0, 0);
1077 mod.fn("ghostty_key_event_set_consumed_mods")(ev, 0);
1078 mod.fn("ghostty_key_event_set_unshifted_codepoint")(ev, 0);
1079 }
1080
1081 out = mod.allocScratch(128);
1082 written = mod.allocScratch(4);
1083 const result = mod.fn("ghostty_key_encoder_encode")(
1084 this.keyEncoder,
1085 ev,
1086 out.ptr,
1087 128,
1088 written.ptr,
1089 );
1090 if (result !== GhosttyResult.GHOSTTY_SUCCESS) return null;
1091 const len = mod.view().getUint32(written.ptr, true);
1092 return len > 0 ? mod.copyBytes(out.ptr, len) : null;
1093 } finally {
1094 written?.free();
1095 out?.free();
1096 utf8Buf?.free();
1097 }
1098 }
1099
1100 /**
1101 * Decode a borrowed clipboard-write request and ask the embedder. Everything
1102 * the pointer refers to is valid only for this call, so strings are copied
1103 * before the handler runs.
1104 */
1105 private onClipboardWrite(writePtr: number): number {
1106 const mod = this.mod;
1107 const RESULT = GhosttyClipboardWriteResult;
1108 if (this.events.onClipboardWrite === undefined) {
1109 return RESULT.GHOSTTY_CLIPBOARD_WRITE_RESULT_DENIED;
1110 }
1111 const field = (name: string) => mod.field("GhosttyClipboardWrite", name).offset;
1112 const view = mod.view();
1113 const location = view.getUint32(writePtr + field("location"), true);
1114 const contents = view.getUint32(writePtr + field("contents"), true);
1115 const count = view.getUint32(writePtr + field("contents_len"), true);
1116
1117 const stringAt = (ptr: number): string => {
1118 const v = mod.view();
1119 const dataPtr = v.getUint32(ptr + mod.field("GhosttyString", "ptr").offset, true);
1120 const len = v.getUint32(ptr + mod.field("GhosttyString", "len").offset, true);
1121 return len > 0 ? mod.readCString(dataPtr, len) : "";
1122 };
1123
1124 const entrySize = mod.layout["GhosttyClipboardContent"]?.size ?? 16;
1125 const mimeOffset = mod.field("GhosttyClipboardContent", "mime").offset;
1126 const dataOffset = mod.field("GhosttyClipboardContent", "data").offset;
1127 const entries: Array<{ mime: string; data: string }> = [];
1128 for (let i = 0; i < count; i++) {
1129 const entry = contents + i * entrySize;
1130 entries.push({ mime: stringAt(entry + mimeOffset), data: stringAt(entry + dataOffset) });
1131 }
1132
1133 const write: ClipboardWrite = {
1134 location: CLIPBOARD_LOCATIONS[location] ?? "standard",
1135 text: entries.find((e) => e.mime === "text/plain" || e.mime === "")?.data ?? "",
1136 entries,
1137 // Zero representations means "clear", which is distinct from an entry
1138 // whose data happens to be empty.
1139 clear: count === 0,
1140 };
1141 const accepted = this.events.onClipboardWrite(write) === true;
1142 return accepted
1143 ? RESULT.GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS
1144 : RESULT.GHOSTTY_CLIPBOARD_WRITE_RESULT_DENIED;
1145 }
1146
1147 get alternateScreen(): boolean {
1148 this.assertLive();
1149 const scratch = this.mod.allocScratch(4);
1150 try {
1151 assertGhosttySuccess(
1152 this.mod.fn("ghostty_terminal_get")(
1153 this.term,
1154 GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN,
1155 scratch.ptr,
1156 ),
1157 "terminal_get(active_screen)",
1158 );
1159 return (
1160 this.mod.view().getUint32(scratch.ptr, true) ===
1161 GhosttyTerminalScreen.GHOSTTY_TERMINAL_SCREEN_ALTERNATE
1162 );
1163 } finally {
1164 scratch.free();
1165 }
1166 }
1167
1168 /**
1169 * Whether the running program asked for mouse events. False means the
1170 * renderer should leave the mouse to the browser — that is what keeps native
1171 * text selection working outside full-screen apps.
1172 */
1173 mouseTracking(): boolean {
1174 this.assertLive();
1175 const scratch = this.mod.allocScratch(8);
1176 this.mod.view().setUint32(scratch.ptr, 0, true);
1177 const result = this.mod.fn("ghostty_terminal_get")(
1178 this.term,
1179 GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING,
1180 scratch.ptr,
1181 );
1182 const mode = this.mod.view().getUint32(scratch.ptr, true);
1183 scratch.free();
1184 return (
1185 result === GhosttyResult.GHOSTTY_SUCCESS &&
1186 mode !== GhosttyMouseTrackingMode.GHOSTTY_MOUSE_TRACKING_NONE
1187 );
1188 }
1189
1190 /**
1191 * Tell the mouse encoder the pixel geometry it needs to turn a surface-space
1192 * position into a cell. Call on resize and whenever metrics change; encoding
1193 * before this is set reports cell 0,0 for everything.
1194 */
1195 setMouseGeometry(geometry: {
1196 width: number;
1197 height: number;
1198 cellWidth: number;
1199 cellHeight: number;
1200 paddingTop?: number;
1201 paddingBottom?: number;
1202 paddingLeft?: number;
1203 paddingRight?: number;
1204 }): void {
1205 this.assertLive();
1206 const mod = this.mod;
1207 const value = (field: string, input: number, min = 0) =>
1208 integerInRange(`mouse geometry ${field}`, Math.round(input), min, UINT32_MAX);
1209 const values: Record<string, number> = {
1210 screen_width: value("screen_width", geometry.width),
1211 screen_height: value("screen_height", geometry.height),
1212 cell_width: value("cell_width", geometry.cellWidth, 1),
1213 cell_height: value("cell_height", geometry.cellHeight, 1),
1214 padding_top: value("padding_top", geometry.paddingTop ?? 0),
1215 padding_bottom: value("padding_bottom", geometry.paddingBottom ?? 0),
1216 padding_left: value("padding_left", geometry.paddingLeft ?? 0),
1217 padding_right: value("padding_right", geometry.paddingRight ?? 0),
1218 };
1219 const size = mod.allocStruct("GhosttyMouseEncoderSize");
1220 try {
1221 for (const [field, fieldValue] of Object.entries(values)) {
1222 mod
1223 .view()
1224 .setUint32(
1225 size.ptr + mod.field("GhosttyMouseEncoderSize", field).offset,
1226 fieldValue,
1227 true,
1228 );
1229 }
1230 mod.fn("ghostty_mouse_encoder_setopt")(
1231 this.mouseEncoder,
1232 GhosttyMouseEncoderOption.GHOSTTY_MOUSE_ENCODER_OPT_SIZE,
1233 size.ptr,
1234 );
1235 } finally {
1236 size.free();
1237 }
1238 }
1239
1240 /**
1241 * Encode a mouse event for the running program. `x`/`y` are surface-space
1242 * pixels (offsets within the terminal element), not cells — libghostty does
1243 * the cell math, including the pixel-precise SGR formats.
1244 *
1245 * Returns null when the event produces no output, which is the common case:
1246 * motion without tracking, a button the mode does not report, or a duplicate
1247 * cell in cell-granular formats.
1248 */
1249 encodeMouse(event: {
1250 action: "press" | "release" | "motion";
1251 /** DOM button number, or null for motion with no button held. */
1252 button: number | null;
1253 x: number;
1254 y: number;
1255 ctrlKey?: boolean;
1256 altKey?: boolean;
1257 metaKey?: boolean;
1258 shiftKey?: boolean;
1259 }): Uint8Array | null {
1260 this.assertLive();
1261 if (
1262 !Number.isFinite(event.x) ||
1263 !Number.isFinite(event.y) ||
1264 Math.abs(event.x) > FLOAT32_MAX ||
1265 Math.abs(event.y) > FLOAT32_MAX
1266 ) {
1267 throw new RangeError(`mouse position must fit float32: ${event.x}, ${event.y}`);
1268 }
1269 const mod = this.mod;
1270 mod.fn("ghostty_mouse_encoder_setopt_from_terminal")(this.mouseEncoder, this.term);
1271
1272 const ev = this.mouseEvent;
1273 mod.fn("ghostty_mouse_event_set_action")(ev, MOUSE_ACTIONS[event.action]);
1274 const button = event.button === null ? null : domButton(event.button);
1275 if (button === null) mod.fn("ghostty_mouse_event_clear_button")(ev);
1276 else mod.fn("ghostty_mouse_event_set_button")(ev, button);
1277 mod.fn("ghostty_mouse_event_set_mods")(
1278 ev,
1279 domEventMods({
1280 ctrlKey: event.ctrlKey === true,
1281 altKey: event.altKey === true,
1282 metaKey: event.metaKey === true,
1283 shiftKey: event.shiftKey === true,
1284 }),
1285 );
1286
1287 const position = mod.allocStruct("GhosttyMousePosition");
1288 try {
1289 const view = mod.view();
1290 view.setFloat32(position.ptr + mod.field("GhosttyMousePosition", "x").offset, event.x, true);
1291 view.setFloat32(position.ptr + mod.field("GhosttyMousePosition", "y").offset, event.y, true);
1292 mod.fn("ghostty_mouse_event_set_position")(ev, position.ptr);
1293 } finally {
1294 position.free();
1295 }
1296
1297 const out = mod.allocScratch(64);
1298 let written: ReturnType<GhosttyModule["allocScratch"]> | null = null;
1299 try {
1300 written = mod.allocScratch(4);
1301 const result = mod.fn("ghostty_mouse_encoder_encode")(
1302 this.mouseEncoder,
1303 ev,
1304 out.ptr,
1305 64,
1306 written.ptr,
1307 );
1308 if (result !== GhosttyResult.GHOSTTY_SUCCESS) return null;
1309 const len = mod.view().getUint32(written.ptr, true);
1310 return len > 0 ? mod.copyBytes(out.ptr, len) : null;
1311 } finally {
1312 written?.free();
1313 out.free();
1314 }
1315 }
1316
1317 /** Encode a paste with bracketed-paste handling driven by terminal mode 2004. */
1318 encodePaste(text: string): Uint8Array {
1319 this.assertLive();
1320 const mod = this.mod;
1321 // GHOSTTY_MODE_BRACKETED_PASTE = ghostty_mode_new(2004, ansi=false) = 2004.
1322 const flag = mod.allocScratch(1);
1323 let bracketed: number;
1324 try {
1325 mod.view().setUint8(flag.ptr, 0);
1326 mod.fn("ghostty_terminal_mode_get")(this.term, 2004, flag.ptr);
1327 bracketed = mod.view().getUint8(flag.ptr) !== 0 ? 1 : 0;
1328 } finally {
1329 flag.free();
1330 }
1331 const input = mod.encodeString(text);
1332 let out: ReturnType<GhosttyModule["allocScratch"]> | null = null;
1333 let written: ReturnType<GhosttyModule["allocScratch"]> | null = null;
1334 try {
1335 const outLen = input.len + 16;
1336 out = mod.allocScratch(outLen);
1337 written = mod.allocScratch(4);
1338 const result = mod.fn("ghostty_paste_encode")(
1339 input.ptr,
1340 input.len,
1341 bracketed,
1342 out.ptr,
1343 outLen,
1344 written.ptr,
1345 );
1346 return result === GhosttyResult.GHOSTTY_SUCCESS
1347 ? mod.copyBytes(out.ptr, mod.view().getUint32(written.ptr, true))
1348 : encoder.encode(text);
1349 } finally {
1350 written?.free();
1351 out?.free();
1352 input.free();
1353 }
1354 }
1355
1356 /** Claim the dirty snapshot stream for one renderer until its reader is disposed. */
1357 acquireFrameReader(): FrameReader {
1358 this.assertLive();
1359 if (this.frameReaderOwner !== null) {
1360 throw new Error("terminal frame snapshots already have a reader");
1361 }
1362 const owner = {};
1363 this.frameReaderOwner = owner;
1364 let released = false;
1365 return {
1366 snapshot: (options = {}) => {
1367 this.assertLive();
1368 if (released || this.frameReaderOwner !== owner) {
1369 throw new Error("terminal frame reader is disposed");
1370 }
1371 return this.readSnapshot(options);
1372 },
1373 dispose: () => {
1374 if (released) return;
1375 released = true;
1376 if (this.frameReaderOwner === owner) this.frameReaderOwner = null;
1377 },
1378 };
1379 }
1380
1381 /**
1382 * Read headless state when no renderer owns the dirty snapshot stream. With
1383 * `full`, every viewport row is returned. Every read consumes dirty flags.
1384 */
1385 snapshot(options: { full?: boolean } = {}): FrameSnapshot {
1386 this.assertLive();
1387 if (this.frameReaderOwner !== null) {
1388 throw new Error("terminal frame snapshots are owned by an attached reader");
1389 }
1390 return this.readSnapshot(options);
1391 }
1392
1393 private readSnapshot(options: { full?: boolean }): FrameSnapshot {
1394 const mod = this.mod;
1395 const ex = {
1396 update: mod.fn("ghostty_render_state_update"),
1397 get: mod.fn("ghostty_render_state_get"),
1398 set: mod.fn("ghostty_render_state_set"),
1399 rowNext: mod.fn("ghostty_render_state_row_iterator_next"),
1400 rowGet: mod.fn("ghostty_render_state_row_get"),
1401 rowSet: mod.fn("ghostty_render_state_row_set"),
1402 cellsNext: mod.fn("ghostty_render_state_row_cells_next"),
1403 cellsGet: mod.fn("ghostty_render_state_row_cells_get"),
1404 cellGet: mod.fn("ghostty_cell_get"),
1405 };
1406 const result = ex.update(this.renderState, this.term);
1407 if (result !== GhosttyResult.GHOSTTY_SUCCESS)
1408 throw new Error(`render_state_update failed: ${result}`);
1409
1410 const D = GhosttyRenderStateData;
1411 const scratch = mod.allocScratch(16);
1412 try {
1413 const readU16 = (what: number) => {
1414 assertGhosttySuccess(ex.get(this.renderState, what, scratch.ptr), "render_state_get(u16)");
1415 return mod.view().getUint16(scratch.ptr, true);
1416 };
1417 const readU32 = (what: number) => {
1418 assertGhosttySuccess(ex.get(this.renderState, what, scratch.ptr), "render_state_get(u32)");
1419 return mod.view().getUint32(scratch.ptr, true);
1420 };
1421 const readBool = (what: number) => {
1422 mod.view().setUint8(scratch.ptr, 0);
1423 assertGhosttySuccess(
1424 ex.get(this.renderState, what, scratch.ptr),
1425 "render_state_get(boolean)",
1426 );
1427 return mod.view().getUint8(scratch.ptr) !== 0;
1428 };
1429 const colorFields = {
1430 r: mod.field("GhosttyColorRgb", "r").offset,
1431 g: mod.field("GhosttyColorRgb", "g").offset,
1432 b: mod.field("GhosttyColorRgb", "b").offset,
1433 };
1434 let palette: number[] | null = null;
1435 let paletteRead = false;
1436 const readPaletteColor = (index: number) => {
1437 if (!paletteRead) {
1438 paletteRead = true;
1439 const rgb = mod.layout["GhosttyColorRgb"]!;
1440 const out = mod.allocScratch(rgb.size * 256);
1441 try {
1442 assertGhosttySuccess(
1443 ex.get(this.renderState, D.GHOSTTY_RENDER_STATE_DATA_COLOR_PALETTE, out.ptr),
1444 "render_state_get(color_palette)",
1445 );
1446 const view = mod.view();
1447 palette = Array.from({ length: 256 }, (_, paletteIndex) => {
1448 const ptr = out.ptr + paletteIndex * rgb.size;
1449 return (
1450 (view.getUint8(ptr + colorFields.r) << 16) |
1451 (view.getUint8(ptr + colorFields.g) << 8) |
1452 view.getUint8(ptr + colorFields.b)
1453 );
1454 });
1455 } finally {
1456 out.free();
1457 }
1458 }
1459 return palette?.[index] ?? -1;
1460 };
1461 const readRenderColor = (what: number) => {
1462 const before = mod.view();
1463 before.setUint8(scratch.ptr + colorFields.r, 0);
1464 before.setUint8(scratch.ptr + colorFields.g, 0);
1465 before.setUint8(scratch.ptr + colorFields.b, 0);
1466 assertGhosttySuccess(
1467 ex.get(this.renderState, what, scratch.ptr),
1468 "render_state_get(color)",
1469 );
1470 const after = mod.view();
1471 return (
1472 (after.getUint8(scratch.ptr + colorFields.r) << 16) |
1473 (after.getUint8(scratch.ptr + colorFields.g) << 8) |
1474 after.getUint8(scratch.ptr + colorFields.b)
1475 );
1476 };
1477
1478 const cols = readU16(D.GHOSTTY_RENDER_STATE_DATA_COLS);
1479 const rows = readU16(D.GHOSTTY_RENDER_STATE_DATA_ROWS);
1480 const dirtyRaw = readU32(D.GHOSTTY_RENDER_STATE_DATA_DIRTY);
1481 let dirty: FrameSnapshot["dirty"] =
1482 dirtyRaw === GhosttyRenderStateDirty.GHOSTTY_RENDER_STATE_DIRTY_FULL
1483 ? "full"
1484 : dirtyRaw === GhosttyRenderStateDirty.GHOSTTY_RENDER_STATE_DIRTY_PARTIAL
1485 ? "partial"
1486 : "none";
1487
1488 // Colors: the program's OSC OVERRIDE, or -1 meaning "the CSS theme
1489 // decides". ***The effective value is not the answer***: `setDefaultColors`
1490 // puts the theme's own colors in the core so it can answer `OSC 10/11/12`,
1491 // and reporting those back would pin `--clo-term-effective-*` to the theme
1492 // and freeze it there — a later theme change would repaint nothing. An
1493 // override that happens to equal the default reads as no override, which
1494 // is the same pixels either way.
1495 const terminalGet = mod.fn("ghostty_terminal_get");
1496 const readColor = (what: number) => {
1497 const v = mod.view();
1498 v.setUint8(scratch.ptr, 0);
1499 v.setUint8(scratch.ptr + 1, 0);
1500 v.setUint8(scratch.ptr + 2, 0);
1501 const r = terminalGet(this.term, what, scratch.ptr);
1502 if (r !== GhosttyResult.GHOSTTY_SUCCESS) return -1;
1503 const v2 = mod.view();
1504 return (
1505 (v2.getUint8(scratch.ptr) << 16) |
1506 (v2.getUint8(scratch.ptr + 1) << 8) |
1507 v2.getUint8(scratch.ptr + 2)
1508 );
1509 };
1510 const overrideColor = (effective: number, fallback: number) => {
1511 const value = readColor(effective);
1512 return value === readColor(fallback) ? -1 : value;
1513 };
1514 const foreground = overrideColor(
1515 GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND,
1516 GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT,
1517 );
1518 const background = overrideColor(
1519 GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND,
1520 GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT,
1521 );
1522
1523 // Cursor: the render state carries the effective color, so the same
1524 // override rule applies to it as to foreground and background.
1525 const cursorDefault = readColor(
1526 GhosttyTerminalData.GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT,
1527 );
1528 const cursorEffective = readBool(D.GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR_HAS_VALUE)
1529 ? readRenderColor(D.GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR)
1530 : -1;
1531 const cursorColor = cursorEffective === cursorDefault ? -1 : cursorEffective;
1532 let cursor: CursorSnapshot | null = null;
1533 if (
1534 readBool(D.GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE) &&
1535 readBool(D.GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE)
1536 ) {
1537 const x = readU16(D.GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X);
1538 const y = readU16(D.GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y);
1539 const styleRaw = readU32(D.GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE);
1540 const blinking = readBool(D.GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING);
1541 const wide = readBool(D.GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL);
1542 cursor = {
1543 x,
1544 y,
1545 style: CURSOR_STYLES[styleRaw] ?? "block",
1546 blinking,
1547 wide,
1548 color: cursorColor,
1549 };
1550 }
1551
1552 // Rows
1553 const wantAll = options.full === true || dirty === "full";
1554 const acquireRows = (renderState = this.renderState) => {
1555 const iterOut = mod.allocScratch(4);
1556 try {
1557 mod.view().setUint32(iterOut.ptr, this.rowIter, true);
1558 assertGhosttySuccess(
1559 ex.get(renderState, D.GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, iterOut.ptr),
1560 "render_state_get(row_iterator)",
1561 );
1562 } finally {
1563 iterOut.free();
1564 }
1565 };
1566 acquireRows();
1567
1568 const clean = mod.allocScratch(1);
1569 let cleanState: ReturnType<GhosttyModule["allocScratch"]>;
1570 try {
1571 cleanState = mod.allocScratch(4);
1572 } catch (error) {
1573 clean.free();
1574 throw error;
1575 }
1576 // Incremental render state can retain stale raw row summaries when Ghostty
1577 // changes a non-painted boundary flag without dirtying that row. Preserve
1578 // and clear its dirty set, then decode through a fresh render state.
1579 let originalRowDirty: boolean[] | null = null;
1580 const refreshRawRows = options.full === true || dirty !== "none";
1581 let canonicalRenderState = 0;
1582 const dirtyRows: RowSnapshot[] = [];
1583 try {
1584 mod.view().setUint8(clean.ptr, 0);
1585 if (refreshRawRows) {
1586 originalRowDirty = [];
1587 while (ex.rowNext(this.rowIter) !== 0) {
1588 mod.view().setUint8(scratch.ptr, 0);
1589 assertGhosttySuccess(
1590 ex.rowGet(
1591 this.rowIter,
1592 GhosttyRenderStateRowData.GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY,
1593 scratch.ptr,
1594 ),
1595 "render_state_row_get(dirty)",
1596 );
1597 const rowDirty = mod.view().getUint8(scratch.ptr) !== 0;
1598 originalRowDirty.push(rowDirty);
1599 }
1600 canonicalRenderState = mod.create("ghostty_render_state_new");
1601 const refresh = ex.update(canonicalRenderState, this.term);
1602 if (refresh !== GhosttyResult.GHOSTTY_SUCCESS) {
1603 throw new Error(`render_state_update failed while refreshing rows: ${refresh}`);
1604 }
1605 acquireRows(canonicalRenderState);
1606 }
1607
1608 const rowGet = mod.fn("ghostty_row_get");
1609 const rowBool = (rawRow: bigint, what: number) => {
1610 mod.view().setUint8(scratch.ptr, 0);
1611 assertGhosttySuccess(rowGet(rawRow, what, scratch.ptr), "row_get(boolean)");
1612 return mod.view().getUint8(scratch.ptr) !== 0;
1613 };
1614 let rowIndex = 0;
1615 const nextRowMetadata: RowMetadata[] = [];
1616 while (refreshRawRows && ex.rowNext(this.rowIter) !== 0) {
1617 const rowDirty = originalRowDirty?.[rowIndex] ?? false;
1618 mod.view().setBigUint64(scratch.ptr, 0n, true);
1619 assertGhosttySuccess(
1620 ex.rowGet(
1621 this.rowIter,
1622 GhosttyRenderStateRowData.GHOSTTY_RENDER_STATE_ROW_DATA_RAW,
1623 scratch.ptr,
1624 ),
1625 "render_state_row_get(raw)",
1626 );
1627 const rawRow = mod.view().getBigUint64(scratch.ptr, true);
1628 mod.view().setUint32(scratch.ptr, 0, true);
1629 const semanticResult = rowGet(
1630 rawRow,
1631 GhosttyRowData.GHOSTTY_ROW_DATA_SEMANTIC_PROMPT,
1632 scratch.ptr,
1633 );
1634 assertGhosttySuccess(semanticResult, "row_get(semantic_prompt)");
1635 const semanticRaw = mod.view().getUint32(scratch.ptr, true);
1636 const metadata: RowMetadata = {
1637 wrap: rowBool(rawRow, GhosttyRowData.GHOSTTY_ROW_DATA_WRAP),
1638 wrapContinuation: rowBool(rawRow, GhosttyRowData.GHOSTTY_ROW_DATA_WRAP_CONTINUATION),
1639 semanticPrompt:
1640 semanticRaw === GhosttyRowSemanticPrompt.GHOSTTY_ROW_SEMANTIC_PROMPT
1641 ? "prompt"
1642 : semanticRaw === GhosttyRowSemanticPrompt.GHOSTTY_ROW_SEMANTIC_PROMPT_CONTINUATION
1643 ? "continuation"
1644 : "none",
1645 hyperlink: rowBool(rawRow, GhosttyRowData.GHOSTTY_ROW_DATA_HYPERLINK),
1646 };
1647 const previous = this.rowMetadata[rowIndex];
1648 const metadataChanged =
1649 previous === undefined ||
1650 previous.wrap !== metadata.wrap ||
1651 previous.wrapContinuation !== metadata.wrapContinuation ||
1652 previous.semanticPrompt !== metadata.semanticPrompt ||
1653 previous.hyperlink !== metadata.hyperlink;
1654 nextRowMetadata[rowIndex] = metadata;
1655 // Replacing a continuation row can remove the predecessor's spacer
1656 // head without dirtying that predecessor in Ghostty. A wrapped row
1657 // therefore shares the continuation row's dirty boundary.
1658 const continuationDirty = metadata.wrap && (originalRowDirty?.[rowIndex + 1] ?? false);
1659 if (rowDirty || continuationDirty || wantAll || metadataChanged) {
1660 if (!rowDirty && !wantAll && dirty === "none") dirty = "partial";
1661 dirtyRows.push({
1662 index: rowIndex,
1663 ...metadata,
1664 cells: this.readRowCells(rowIndex, metadata.hyperlink, scratch.ptr, readPaletteColor),
1665 });
1666 }
1667 rowIndex++;
1668 }
1669 if (refreshRawRows) {
1670 // Promote before consuming row bits so any cleanup failure retries a
1671 // complete frame rather than losing rows already marked clean.
1672 mod
1673 .view()
1674 .setUint32(
1675 cleanState.ptr,
1676 GhosttyRenderStateDirty.GHOSTTY_RENDER_STATE_DIRTY_FULL,
1677 true,
1678 );
1679 assertGhosttySuccess(
1680 ex.set(
1681 this.renderState,
1682 GhosttyRenderStateOption.GHOSTTY_RENDER_STATE_OPTION_DIRTY,
1683 cleanState.ptr,
1684 ),
1685 "render_state_set(full)",
1686 );
1687 acquireRows();
1688 let dirtyIndex = 0;
1689 while (ex.rowNext(this.rowIter) !== 0) {
1690 if (originalRowDirty?.[dirtyIndex] === true) {
1691 assertGhosttySuccess(
1692 ex.rowSet(
1693 this.rowIter,
1694 GhosttyRenderStateRowOption.GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY,
1695 clean.ptr,
1696 ),
1697 "render_state_row_set(dirty)",
1698 );
1699 }
1700 dirtyIndex++;
1701 }
1702 this.rowMetadata = nextRowMetadata;
1703 }
1704 } catch (error) {
1705 cleanState.free();
1706 throw error;
1707 } finally {
1708 clean.free();
1709 if (canonicalRenderState !== 0) {
1710 mod.fn("ghostty_render_state_free")(canonicalRenderState);
1711 }
1712 }
1713
1714 mod
1715 .view()
1716 .setUint32(cleanState.ptr, GhosttyRenderStateDirty.GHOSTTY_RENDER_STATE_DIRTY_FALSE, true);
1717 try {
1718 assertGhosttySuccess(
1719 ex.set(
1720 this.renderState,
1721 GhosttyRenderStateOption.GHOSTTY_RENDER_STATE_OPTION_DIRTY,
1722 cleanState.ptr,
1723 ),
1724 "render_state_set(dirty)",
1725 );
1726 } finally {
1727 cleanState.free();
1728 }
1729
1730 return { cols, rows, dirty, dirtyRows, cursor, foreground, background };
1731 } finally {
1732 scratch.free();
1733 }
1734 }
1735
1736 private readRowCells(
1737 rowIndex: number,
1738 rowHasHyperlink: boolean,
1739 scratch: number,
1740 readPaletteColor: (index: number) => number,
1741 ): CellSnapshot[] {
1742 const mod = this.mod;
1743 const cellsGet = mod.fn("ghostty_render_state_row_cells_get");
1744 const cellsGetMulti = mod.fn("ghostty_render_state_row_cells_get_multi");
1745 const cellsNext = mod.fn("ghostty_render_state_row_cells_next");
1746 const cellGetMulti = mod.fn("ghostty_cell_get_multi");
1747 const C = GhosttyRenderStateRowCellsData;
1748 const asciiCell = mod.layout["GhosttyRenderStateAsciiCell"];
1749 if (asciiCell === undefined) throw new Error("missing GhosttyRenderStateAsciiCell layout");
1750 const asciiCodepoint = mod.field("GhosttyRenderStateAsciiCell", "codepoint").offset;
1751 const asciiForeground = mod.field("GhosttyRenderStateAsciiCell", "foreground").offset;
1752
1753 const cellsOut = mod.allocScratch(4);
1754 try {
1755 mod.view().setUint32(cellsOut.ptr, this.rowCells, true);
1756 assertGhosttySuccess(
1757 mod.fn("ghostty_render_state_row_get")(
1758 this.rowIter,
1759 GhosttyRenderStateRowData.GHOSTTY_RENDER_STATE_ROW_DATA_CELLS,
1760 cellsOut.ptr,
1761 ),
1762 "render_state_row_get(cells)",
1763 );
1764 } finally {
1765 cellsOut.free();
1766 }
1767
1768 if (this.asciiRowScratch === null || this.asciiRowScratch.capacity < this.colsNow) {
1769 const capacity = 2 ** Math.ceil(Math.log2(this.colsNow));
1770 const next = mod.allocScratch(capacity * asciiCell.size);
1771 this.asciiRowScratch?.free();
1772 this.asciiRowScratch = { ...next, capacity };
1773 }
1774 const asciiResult = mod.fn("ghostty_render_state_row_cells_ascii")(
1775 this.rowCells,
1776 this.asciiRowScratch.ptr,
1777 this.asciiRowScratch.capacity,
1778 scratch,
1779 );
1780 if (asciiResult === GhosttyResult.GHOSTTY_SUCCESS) {
1781 const length = mod.view().getUint32(scratch, true);
1782 const cells: CellSnapshot[] = [];
1783 for (let index = 0; index < length; index++) {
1784 const ptr = this.asciiRowScratch.ptr + index * asciiCell.size;
1785 const codepoint = mod.view().getUint32(ptr + asciiCodepoint, true);
1786 const foreground = mod.view().getUint32(ptr + asciiForeground, true);
1787 cells.push({
1788 text: codepoint === 0 ? "" : String.fromCharCode(codepoint),
1789 width: 1,
1790 spacer: "none",
1791 protected: false,
1792 semantic: "output",
1793 hasOsc8: false,
1794 osc8Uri: null,
1795 fg: foreground === 0xffffffff ? -1 : foreground,
1796 bg: -1,
1797 underlineColor: -1,
1798 bold: false,
1799 italic: false,
1800 faint: false,
1801 blink: false,
1802 inverse: false,
1803 invisible: false,
1804 strikethrough: false,
1805 overline: false,
1806 underline: 0,
1807 });
1808 }
1809 return cells;
1810 }
1811 if (asciiResult !== GhosttyResult.GHOSTTY_NO_VALUE) {
1812 throw new Error(`render_state_row_cells_ascii failed: ${asciiResult}`);
1813 }
1814
1815 const allocations: Array<{ free(): void }> = [];
1816 const own = <T extends { free(): void }>(allocation: T): T => {
1817 allocations.push(allocation);
1818 return allocation;
1819 };
1820 try {
1821 const style = own(mod.allocStruct("GhosttyStyle"));
1822 const rowKeys = own(mod.allocScratch(16));
1823 const rowValues = own(mod.allocScratch(16));
1824 const cellKeys = own(mod.allocScratch(20));
1825 const cellValues = own(mod.allocScratch(20));
1826 const rowQuery = [
1827 C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW,
1828 C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE,
1829 C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN,
1830 C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING,
1831 ];
1832 const cellQuery = [
1833 GhosttyCellData.GHOSTTY_CELL_DATA_WIDE,
1834 GhosttyCellData.GHOSTTY_CELL_DATA_PROTECTED,
1835 GhosttyCellData.GHOSTTY_CELL_DATA_SEMANTIC_CONTENT,
1836 GhosttyCellData.GHOSTTY_CELL_DATA_HAS_HYPERLINK,
1837 GhosttyCellData.GHOSTTY_CELL_DATA_CONTENT_TAG,
1838 ];
1839 const queryView = mod.view();
1840 rowQuery.forEach((key, index) => queryView.setUint32(rowKeys.ptr + index * 4, key, true));
1841 [scratch, style.ptr, scratch + 8, scratch + 12].forEach((ptr, index) =>
1842 queryView.setUint32(rowValues.ptr + index * 4, ptr, true),
1843 );
1844 cellQuery.forEach((key, index) => queryView.setUint32(cellKeys.ptr + index * 4, key, true));
1845 [scratch, scratch + 8, scratch + 4, scratch + 9, scratch + 12].forEach((ptr, index) =>
1846 queryView.setUint32(cellValues.ptr + index * 4, ptr, true),
1847 );
1848 const styleFields = {
1849 bold: mod.field("GhosttyStyle", "bold").offset,
1850 italic: mod.field("GhosttyStyle", "italic").offset,
1851 faint: mod.field("GhosttyStyle", "faint").offset,
1852 blink: mod.field("GhosttyStyle", "blink").offset,
1853 inverse: mod.field("GhosttyStyle", "inverse").offset,
1854 invisible: mod.field("GhosttyStyle", "invisible").offset,
1855 strikethrough: mod.field("GhosttyStyle", "strikethrough").offset,
1856 overline: mod.field("GhosttyStyle", "overline").offset,
1857 underline: mod.field("GhosttyStyle", "underline").offset,
1858 fgTag: mod.field("GhosttyStyle", "fg_color").offset,
1859 bgTag: mod.field("GhosttyStyle", "bg_color").offset,
1860 underlineColor: mod.field("GhosttyStyle", "underline_color").offset,
1861 colorTag: mod.field("GhosttyStyleColor", "tag").offset,
1862 colorValue: mod.field("GhosttyStyleColor", "value").offset,
1863 };
1864 const colorFields = {
1865 r: mod.field("GhosttyColorRgb", "r").offset,
1866 g: mod.field("GhosttyColorRgb", "g").offset,
1867 b: mod.field("GhosttyColorRgb", "b").offset,
1868 };
1869 let graphemeCapacity = INITIAL_GRAPHEME_CAPACITY;
1870 let graphemeBuf = own(mod.allocScratch(graphemeCapacity * 4));
1871 const readResolvedColor = (what: number) => {
1872 const view = mod.view();
1873 view.setUint8(scratch + colorFields.r, 0);
1874 view.setUint8(scratch + colorFields.g, 0);
1875 view.setUint8(scratch + colorFields.b, 0);
1876 assertGhosttySuccess(cellsGet(this.rowCells, what, scratch), "row_cells_get(color)");
1877 const resolved = mod.view();
1878 return (
1879 (resolved.getUint8(scratch + colorFields.r) << 16) |
1880 (resolved.getUint8(scratch + colorFields.g) << 8) |
1881 resolved.getUint8(scratch + colorFields.b)
1882 );
1883 };
1884
1885 const cells: CellSnapshot[] = [];
1886 const hyperlink = rowHasHyperlink ? this.resolveHyperlinkRow(rowIndex) : null;
1887 let physicalColumn = 0;
1888 while (cellsNext(this.rowCells) !== 0) {
1889 const cellColumn = physicalColumn++;
1890 assertGhosttySuccess(
1891 cellsGetMulti(this.rowCells, rowQuery.length, rowKeys.ptr, rowValues.ptr, 0),
1892 "row_cells_get_multi",
1893 );
1894 const rawCell = mod.view().getBigUint64(scratch, true);
1895 const graphemeLen = mod.view().getUint32(scratch + 8, true);
1896 const hasStyling = mod.view().getUint8(scratch + 12) !== 0;
1897 let width = 1;
1898 let spacer: CellSnapshot["spacer"] = "none";
1899 let protectedCell = false;
1900 let semantic: CellSnapshot["semantic"] = "output";
1901 let hasOsc8 = false;
1902 let osc8Uri: string | null = null;
1903 let contentTag: number = GhosttyCellContentTag.GHOSTTY_CELL_CONTENT_CODEPOINT;
1904 if (rawCell !== 0n) {
1905 // GhosttyCell is a packed 64-bit value, not a pointer: pass it as i64.
1906 assertGhosttySuccess(
1907 cellGetMulti(rawCell, cellQuery.length, cellKeys.ptr, cellValues.ptr, 0),
1908 "cell_get_multi",
1909 );
1910 const wide = mod.view().getUint32(scratch, true);
1911 spacer = wide === GhosttyCellWide.GHOSTTY_CELL_WIDE_SPACER_HEAD ? "head" : "none";
1912 width = wide === GhosttyCellWide.GHOSTTY_CELL_WIDE_WIDE ? 2 : 1;
1913 const semanticRaw = mod.view().getUint32(scratch + 4, true);
1914 protectedCell = mod.view().getUint8(scratch + 8) !== 0;
1915 contentTag = mod.view().getUint32(scratch + 12, true);
1916 semantic =
1917 semanticRaw === GhosttyCellSemanticContent.GHOSTTY_CELL_SEMANTIC_INPUT
1918 ? "input"
1919 : semanticRaw === GhosttyCellSemanticContent.GHOSTTY_CELL_SEMANTIC_PROMPT
1920 ? "prompt"
1921 : "output";
1922
1923 if (hyperlink !== null) {
1924 if (mod.view().getUint8(scratch + 9) !== 0) {
1925 hasOsc8 = true;
1926 osc8Uri = this.readHyperlinkUri(hyperlink, cellColumn);
1927 }
1928 }
1929 if (wide === GhosttyCellWide.GHOSTTY_CELL_WIDE_SPACER_TAIL) continue;
1930 }
1931
1932 let text = "";
1933 if (graphemeLen > 0) {
1934 if (graphemeLen > graphemeCapacity) {
1935 graphemeBuf.free();
1936 graphemeCapacity = 2 ** Math.ceil(Math.log2(graphemeLen));
1937 graphemeBuf = own(mod.allocScratch(graphemeCapacity * 4));
1938 }
1939 assertGhosttySuccess(
1940 cellsGet(
1941 this.rowCells,
1942 C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF,
1943 graphemeBuf.ptr,
1944 ),
1945 "row_cells_get(graphemes_buf)",
1946 );
1947 text = codepointsToString(mod.view(), graphemeBuf.ptr, graphemeLen);
1948 }
1949
1950 let cell: CellSnapshot;
1951 if (hasStyling) {
1952 const readColor = (tagOffset: number, resolved: number) => {
1953 return mod.view().getUint32(style.ptr + tagOffset + styleFields.colorTag, true) === 0
1954 ? -1
1955 : readResolvedColor(resolved);
1956 };
1957 const fg = readColor(styleFields.fgTag, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR);
1958 const bg = readColor(styleFields.bgTag, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR);
1959 const underlineColorPtr = style.ptr + styleFields.underlineColor + styleFields.colorValue;
1960 const underlineColorTag = mod
1961 .view()
1962 .getUint32(style.ptr + styleFields.underlineColor + styleFields.colorTag, true);
1963 let underlineColor = -1;
1964 if (underlineColorTag === GhosttyStyleColorTag.GHOSTTY_STYLE_COLOR_RGB) {
1965 const color = mod.view();
1966 underlineColor =
1967 (color.getUint8(underlineColorPtr + colorFields.r) << 16) |
1968 (color.getUint8(underlineColorPtr + colorFields.g) << 8) |
1969 color.getUint8(underlineColorPtr + colorFields.b);
1970 } else if (underlineColorTag === GhosttyStyleColorTag.GHOSTTY_STYLE_COLOR_PALETTE) {
1971 underlineColor = readPaletteColor(mod.view().getUint8(underlineColorPtr));
1972 }
1973 const v = mod.view();
1974 cell = {
1975 text,
1976 width,
1977 spacer,
1978 protected: protectedCell,
1979 semantic,
1980 hasOsc8,
1981 osc8Uri,
1982 fg,
1983 bg,
1984 underlineColor,
1985 bold: v.getUint8(style.ptr + styleFields.bold) !== 0,
1986 italic: v.getUint8(style.ptr + styleFields.italic) !== 0,
1987 faint: v.getUint8(style.ptr + styleFields.faint) !== 0,
1988 blink: v.getUint8(style.ptr + styleFields.blink) !== 0,
1989 inverse: v.getUint8(style.ptr + styleFields.inverse) !== 0,
1990 invisible: v.getUint8(style.ptr + styleFields.invisible) !== 0,
1991 strikethrough: v.getUint8(style.ptr + styleFields.strikethrough) !== 0,
1992 overline: v.getUint8(style.ptr + styleFields.overline) !== 0,
1993 underline: v.getInt32(style.ptr + styleFields.underline, true),
1994 };
1995 } else {
1996 let bg = -1;
1997 if (rawCell !== 0n && text === "") {
1998 if (
1999 contentTag === GhosttyCellContentTag.GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE ||
2000 contentTag === GhosttyCellContentTag.GHOSTTY_CELL_CONTENT_BG_COLOR_RGB
2001 ) {
2002 bg = readResolvedColor(C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR);
2003 }
2004 }
2005 cell = {
2006 text,
2007 width,
2008 spacer,
2009 protected: protectedCell,
2010 semantic,
2011 hasOsc8,
2012 osc8Uri,
2013 fg: -1,
2014 bg,
2015 underlineColor: -1,
2016 bold: false,
2017 italic: false,
2018 faint: false,
2019 blink: false,
2020 inverse: false,
2021 invisible: false,
2022 strikethrough: false,
2023 overline: false,
2024 underline: 0,
2025 };
2026 }
2027 cells.push(cell);
2028 }
2029
2030 // Trim only cells with no canonical presentation state so runs/DOM stay small.
2031 trimCanonicalDefaultTail(cells);
2032 return cells;
2033 } finally {
2034 for (let index = allocations.length - 1; index >= 0; index--) allocations[index]!.free();
2035 }
2036 }
2037
2038 private resolveHyperlinkRow(rowIndex: number): HyperlinkScratch {
2039 const mod = this.mod;
2040 let hyperlink = this.hyperlinkScratch;
2041 if (hyperlink === null) {
2042 let point: HyperlinkScratch["point"] | null = null;
2043 let ref: HyperlinkScratch["ref"] | null = null;
2044 let outLen: HyperlinkScratch["outLen"] | null = null;
2045 try {
2046 point = mod.allocStruct("GhosttyPoint");
2047 ref = mod.allocStruct("GhosttyGridRef");
2048 outLen = mod.allocScratch(4);
2049 hyperlink = {
2050 point,
2051 ref,
2052 outLen,
2053 uri: mod.allocScratch(INITIAL_HYPERLINK_URI_CAPACITY),
2054 uriCapacity: INITIAL_HYPERLINK_URI_CAPACITY,
2055 };
2056 } catch (error) {
2057 outLen?.free();
2058 ref?.free();
2059 point?.free();
2060 throw error;
2061 }
2062 this.hyperlinkScratch = hyperlink;
2063 }
2064
2065 const pointTag = mod.field("GhosttyPoint", "tag").offset;
2066 const pointValue = mod.field("GhosttyPoint", "value").offset;
2067 const coordinateX = mod.field("GhosttyPointCoordinate", "x").offset;
2068 const coordinateY = mod.field("GhosttyPointCoordinate", "y").offset;
2069 mod
2070 .view()
2071 .setUint32(hyperlink.point.ptr + pointTag, GhosttyPointTag.GHOSTTY_POINT_TAG_VIEWPORT, true);
2072 mod.view().setUint16(hyperlink.point.ptr + pointValue + coordinateX, 0, true);
2073 mod.view().setUint32(hyperlink.point.ptr + pointValue + coordinateY, rowIndex, true);
2074 assertGhosttySuccess(
2075 mod.fn("ghostty_terminal_grid_ref")(this.term, hyperlink.point.ptr, hyperlink.ref.ptr),
2076 "terminal_grid_ref(hyperlink row)",
2077 );
2078 return hyperlink;
2079 }
2080
2081 private readHyperlinkUri(hyperlink: HyperlinkScratch, physicalColumn: number): string | null {
2082 const mod = this.mod;
2083 mod
2084 .view()
2085 .setUint16(hyperlink.ref.ptr + mod.field("GhosttyGridRef", "x").offset, physicalColumn, true);
2086 mod.view().setUint32(hyperlink.outLen.ptr, 0, true);
2087 let result = mod.fn("ghostty_grid_ref_hyperlink_uri")(
2088 hyperlink.ref.ptr,
2089 hyperlink.uri.ptr,
2090 hyperlink.uriCapacity,
2091 hyperlink.outLen.ptr,
2092 );
2093 if (result === GhosttyResult.GHOSTTY_OUT_OF_SPACE) {
2094 const required = mod.view().getUint32(hyperlink.outLen.ptr, true);
2095 if (required <= 0) throw new Error("grid_ref_hyperlink_uri returned an invalid size");
2096 const next = mod.allocScratch(required);
2097 hyperlink.uri.free();
2098 hyperlink.uri = next;
2099 hyperlink.uriCapacity = required;
2100 mod.view().setUint32(hyperlink.outLen.ptr, 0, true);
2101 result = mod.fn("ghostty_grid_ref_hyperlink_uri")(
2102 hyperlink.ref.ptr,
2103 hyperlink.uri.ptr,
2104 hyperlink.uriCapacity,
2105 hyperlink.outLen.ptr,
2106 );
2107 }
2108 assertGhosttySuccess(result, "grid_ref_hyperlink_uri");
2109 const length = mod.view().getUint32(hyperlink.outLen.ptr, true);
2110 if (length > hyperlink.uriCapacity) {
2111 throw new Error(`grid_ref_hyperlink_uri wrote invalid length: ${length}`);
2112 }
2113 const bytes = mod.bytes(hyperlink.uri.ptr, length);
2114 try {
2115 return osc8Decoder.decode(bytes);
2116 } catch {
2117 return null;
2118 }
2119 }
2120
2121 /** Plain-text dump of the active screen (tests, copy-all). */
2122 text(): string {
2123 this.assertLive();
2124 const mod = this.mod;
2125 const opts = mod.allocStruct("GhosttyFormatterTerminalOptions");
2126 let fmt: number;
2127 try {
2128 const trim = mod.field("GhosttyFormatterTerminalOptions", "trim");
2129 mod.view().setUint8(opts.ptr + trim.offset, 1);
2130 const extraOff = mod.field("GhosttyFormatterTerminalOptions", "extra").offset;
2131 const extra = mod.layout["GhosttyFormatterTerminalExtra"]!;
2132 mod.view().setUint32(opts.ptr + extraOff + extra.fields["size"]!.offset, extra.size, true);
2133 const screenOff = extra.fields["screen"]!.offset;
2134 const screen = mod.layout["GhosttyFormatterScreenExtra"]!;
2135 mod
2136 .view()
2137 .setUint32(
2138 opts.ptr + extraOff + screenOff + screen.fields["size"]!.offset,
2139 screen.size,
2140 true,
2141 );
2142 fmt = mod.create("ghostty_formatter_terminal_new", this.term, opts.ptr);
2143 } finally {
2144 opts.free();
2145 }
2146
2147 let outPtrPtr: ReturnType<GhosttyModule["allocScratch"]> | null = null;
2148 let outLenPtr: ReturnType<GhosttyModule["allocScratch"]> | null = null;
2149 try {
2150 outPtrPtr = mod.allocScratch(4);
2151 outLenPtr = mod.allocScratch(4);
2152 mod.view().setUint32(outPtrPtr.ptr, 0, true);
2153 mod.view().setUint32(outLenPtr.ptr, 0, true);
2154 const result = mod.fn("ghostty_formatter_format_alloc")(fmt, 0, outPtrPtr.ptr, outLenPtr.ptr);
2155 const ptr = mod.view().getUint32(outPtrPtr.ptr, true);
2156 const len = mod.view().getUint32(outLenPtr.ptr, true);
2157 if (ptr === 0) {
2158 if (result === GhosttyResult.GHOSTTY_SUCCESS && len !== 0) {
2159 throw new Error("ghostty_formatter_format_alloc returned a null buffer");
2160 }
2161 return "";
2162 }
2163 try {
2164 return result === GhosttyResult.GHOSTTY_SUCCESS ? mod.readCString(ptr, len) : "";
2165 } finally {
2166 mod.fn("ghostty_free")(0, ptr, len);
2167 }
2168 } finally {
2169 outLenPtr?.free();
2170 outPtrPtr?.free();
2171 mod.fn("ghostty_formatter_free")(fmt);
2172 }
2173 }
2174
2175 free(): void {
2176 if (this.disposed || this.disposePending) return;
2177 if (this.writing) {
2178 this.disposePending = true;
2179 return;
2180 }
2181 this.disposed = true;
2182 this.frameReaderOwner = null;
2183 // Anchors first: tracked refs outlive the terminal, so freeing them after
2184 // it would leak the handles rather than crash.
2185 this.shell.dispose();
2186 this.releaseWasmResources();
2187 }
2188
2189 private releaseWasmResources(): void {
2190 const mod = this.mod;
2191 this.asciiRowScratch?.free();
2192 this.asciiRowScratch = null;
2193 this.inputScratch?.free();
2194 this.inputScratch = null;
2195 if (this.hyperlinkScratch !== null) {
2196 this.hyperlinkScratch.uri.free();
2197 this.hyperlinkScratch.outLen.free();
2198 this.hyperlinkScratch.ref.free();
2199 this.hyperlinkScratch.point.free();
2200 this.hyperlinkScratch = null;
2201 }
2202 if (this.mouseEvent !== 0) {
2203 mod.fn("ghostty_mouse_event_free")(this.mouseEvent);
2204 this.mouseEvent = 0;
2205 }
2206 if (this.mouseEncoder !== 0) {
2207 mod.fn("ghostty_mouse_encoder_free")(this.mouseEncoder);
2208 this.mouseEncoder = 0;
2209 }
2210 if (this.keyEvent !== 0) {
2211 mod.fn("ghostty_key_event_free")(this.keyEvent);
2212 this.keyEvent = 0;
2213 }
2214 if (this.keyEncoder !== 0) {
2215 mod.fn("ghostty_key_encoder_free")(this.keyEncoder);
2216 this.keyEncoder = 0;
2217 }
2218 if (this.rowCells !== 0) {
2219 mod.fn("ghostty_render_state_row_cells_free")(this.rowCells);
2220 this.rowCells = 0;
2221 }
2222 if (this.rowIter !== 0) {
2223 mod.fn("ghostty_render_state_row_iterator_free")(this.rowIter);
2224 this.rowIter = 0;
2225 }
2226 if (this.renderState !== 0) {
2227 mod.fn("ghostty_render_state_free")(this.renderState);
2228 this.renderState = 0;
2229 }
2230 if (this.modeValue !== 0) {
2231 mod.fn("ghostty_wasm_free_u8")(this.modeValue);
2232 this.modeValue = 0;
2233 }
2234 if (this.term !== 0) {
2235 mod.fn("ghostty_terminal_free")(this.term);
2236 this.term = 0;
2237 }
2238 for (const trampoline of this.trampolines.splice(0).reverse()) trampoline.free();
2239 }
2240
2241 private assertLive(): void {
2242 if (this.disposed) throw new Error("terminal is disposed");
2243 }
2244}