@aurigax-ai/ghostty-web 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/ghostty.ts ADDED
@@ -0,0 +1,1135 @@
1
+ /**
2
+ * TypeScript wrapper for the official, unpatched libghostty-vt WASM API.
3
+ *
4
+ * The public classes keep the shape the renderer, selection manager and input
5
+ * handler were written against; underneath they use Ghostty's render state,
6
+ * grid references and key encoder through `Abi`.
7
+ */
8
+
9
+ import { Abi, GHOSTTY_SUCCESS, type VtExports } from './abi';
10
+ import { EventEmitter } from './event-emitter';
11
+ import type { IEvent } from './interfaces';
12
+ import {
13
+ CellFlags,
14
+ type Cursor,
15
+ DirtyState,
16
+ type GhosttyCell,
17
+ type GhosttyTerminalConfig,
18
+ KeyEncoderOption,
19
+ type KeyEvent,
20
+ type KittyKeyFlags,
21
+ type RGB,
22
+ type RenderStateColors,
23
+ type RenderStateCursor,
24
+ } from './types';
25
+
26
+ // Re-export types for convenience
27
+ export {
28
+ CellFlags,
29
+ type Cursor,
30
+ DirtyState,
31
+ type GhosttyCell,
32
+ type GhosttyTerminalConfig,
33
+ KeyEncoderOption,
34
+ type RGB,
35
+ type RenderStateColors,
36
+ type RenderStateCursor,
37
+ };
38
+
39
+ /**
40
+ * Main Ghostty WASM wrapper class
41
+ */
42
+ export class Ghostty {
43
+ readonly abi: Abi;
44
+
45
+ constructor(wasmInstance: WebAssembly.Instance) {
46
+ this.abi = new Abi(wasmInstance.exports as unknown as VtExports);
47
+ }
48
+
49
+ createKeyEncoder(): KeyEncoder {
50
+ return new KeyEncoder(this.abi);
51
+ }
52
+
53
+ createTerminal(
54
+ cols: number = 80,
55
+ rows: number = 24,
56
+ config?: GhosttyTerminalConfig
57
+ ): GhosttyTerminal {
58
+ return new GhosttyTerminal(this.abi, cols, rows, config);
59
+ }
60
+
61
+ static async fromBytes(bytes: BufferSource | WebAssembly.Module): Promise<Ghostty> {
62
+ const module = bytes instanceof WebAssembly.Module ? bytes : await WebAssembly.compile(bytes);
63
+ return new Ghostty(await WebAssembly.instantiate(module, {}));
64
+ }
65
+
66
+ static async load(wasmPath?: string): Promise<Ghostty> {
67
+ if (wasmPath) return Ghostty.loadFromPath(wasmPath);
68
+ const moduleUrl = new URL('../ghostty-vt.wasm', import.meta.url);
69
+ const defaultPaths: string[] = [];
70
+ if (moduleUrl.protocol === 'file:') {
71
+ let filePath = moduleUrl.pathname;
72
+ if (filePath.match(/^\/[A-Za-z]:\//)) filePath = filePath.slice(1);
73
+ defaultPaths.push(filePath);
74
+ }
75
+ defaultPaths.push(moduleUrl.href, './ghostty-vt.wasm', '/ghostty-vt.wasm');
76
+ let lastError: Error | null = null;
77
+ for (const path of defaultPaths) {
78
+ try {
79
+ return await Ghostty.loadFromPath(path);
80
+ } catch (e) {
81
+ lastError = e instanceof Error ? e : new Error(String(e));
82
+ }
83
+ }
84
+ throw lastError || new Error('Failed to load Ghostty WASM');
85
+ }
86
+
87
+ private static async loadFromPath(path: string): Promise<Ghostty> {
88
+ let wasmBytes: ArrayBuffer | undefined;
89
+ if (!path.startsWith('http:') && !path.startsWith('https:')) {
90
+ try {
91
+ const fs = await import('fs/promises');
92
+ const buffer = await fs.readFile(path);
93
+ wasmBytes = buffer.buffer.slice(
94
+ buffer.byteOffset,
95
+ buffer.byteOffset + buffer.byteLength
96
+ ) as ArrayBuffer;
97
+ } catch {
98
+ wasmBytes = undefined;
99
+ }
100
+ }
101
+ if (!wasmBytes) {
102
+ const response = await fetch(path);
103
+ if (!response.ok) {
104
+ throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`);
105
+ }
106
+ wasmBytes = await response.arrayBuffer();
107
+ if (wasmBytes.byteLength === 0) {
108
+ throw new Error(`WASM file is empty (0 bytes). Check path: ${path}`);
109
+ }
110
+ }
111
+ return Ghostty.fromBytes(wasmBytes);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Key Encoder - converts keyboard events into terminal escape sequences
117
+ */
118
+ export class KeyEncoder {
119
+ private encoder: number;
120
+
121
+ constructor(private readonly abi: Abi) {
122
+ this.encoder = abi.newHandle('ghostty_key_encoder_new', (slot) =>
123
+ abi.call('ghostty_key_encoder_new', 0, slot)
124
+ );
125
+ }
126
+
127
+ setOption(option: KeyEncoderOption, value: boolean | number): void {
128
+ this.abi.with(4, (ptr) => {
129
+ this.abi.view().setUint8(ptr, typeof value === 'boolean' ? (value ? 1 : 0) : value);
130
+ this.abi.call('ghostty_key_encoder_setopt', this.encoder, option, ptr);
131
+ });
132
+ }
133
+
134
+ setKittyFlags(flags: KittyKeyFlags): void {
135
+ this.setOption(KeyEncoderOption.KITTY_KEYBOARD_FLAGS, flags);
136
+ }
137
+
138
+ encode(event: KeyEvent): Uint8Array {
139
+ const abi = this.abi;
140
+ const eventPtr = abi.newHandle('ghostty_key_event_new', (slot) =>
141
+ abi.call('ghostty_key_event_new', 0, slot)
142
+ );
143
+ try {
144
+ abi.call('ghostty_key_event_set_action', eventPtr, event.action);
145
+ abi.call('ghostty_key_event_set_key', eventPtr, event.key);
146
+ abi.call('ghostty_key_event_set_mods', eventPtr, event.mods);
147
+ if (event.consumedMods !== undefined) {
148
+ abi.call('ghostty_key_event_set_consumed_mods', eventPtr, event.consumedMods);
149
+ }
150
+ if (event.composing !== undefined) {
151
+ abi.call('ghostty_key_event_set_composing', eventPtr, event.composing ? 1 : 0);
152
+ }
153
+ if (event.unshiftedCodepoint !== undefined) {
154
+ abi.call('ghostty_key_event_set_unshifted_codepoint', eventPtr, event.unshiftedCodepoint);
155
+ }
156
+ const encode = (): Uint8Array => {
157
+ const capacity = 128;
158
+ return abi.with(capacity + 8, (buf) => {
159
+ const written = buf + capacity;
160
+ const result = abi.call(
161
+ 'ghostty_key_encoder_encode',
162
+ this.encoder,
163
+ eventPtr,
164
+ buf,
165
+ capacity,
166
+ written
167
+ );
168
+ if (result !== GHOSTTY_SUCCESS) throw new Error(`Failed to encode key: ${result}`);
169
+ const len = abi.view().getUint32(written, true);
170
+ return abi.bytes().slice(buf, buf + len);
171
+ });
172
+ };
173
+ if (!event.utf8) return encode();
174
+ const utf8 = new TextEncoder().encode(event.utf8);
175
+ return abi.withBytes(utf8, (ptr, len) => {
176
+ abi.call('ghostty_key_event_set_utf8', eventPtr, ptr, len);
177
+ return encode();
178
+ });
179
+ } finally {
180
+ abi.call('ghostty_key_event_free', eventPtr);
181
+ }
182
+ }
183
+
184
+ dispose(): void {
185
+ if (this.encoder) {
186
+ this.abi.call('ghostty_key_encoder_free', this.encoder);
187
+ this.encoder = 0;
188
+ }
189
+ }
190
+ }
191
+
192
+ export type SemanticPromptKind = 'prompt-start' | 'input-start' | 'output-start' | 'command-end';
193
+
194
+ export interface SemanticPromptEvent {
195
+ kind: SemanticPromptKind;
196
+ exitCode?: number;
197
+ command: string;
198
+ }
199
+
200
+ export interface DesktopNotificationEvent {
201
+ title: string;
202
+ body: string;
203
+ }
204
+
205
+ const PROMPT_KINDS: Record<string, SemanticPromptKind> = {
206
+ PROMPT_START: 'prompt-start',
207
+ INPUT_START: 'input-start',
208
+ OUTPUT_START: 'output-start',
209
+ COMMAND_END: 'command-end',
210
+ };
211
+
212
+ const UNKNOWN_SEQUENCE_MAX_BYTES = 4096;
213
+
214
+ export type MouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any';
215
+
216
+ /** A row the terminal keeps track of as output scrolls, reflows or is pruned. */
217
+ export interface TrackedRow {
218
+ /** The row's line in the screen (scrollback first), or null once it no longer exists. */
219
+ line(): number | null;
220
+ free(): void;
221
+ }
222
+
223
+ const POINT_ACTIVE = 'ACTIVE';
224
+ const POINT_HISTORY = 'HISTORY';
225
+ type PointSpace = typeof POINT_ACTIVE | typeof POINT_HISTORY;
226
+
227
+ const GRAPHEME_CAP = 16;
228
+ const GRAPHEME_CLUSTER_MODE = 2027;
229
+ const HYPERLINK_CAPS = [2048, 8192, 32768];
230
+
231
+ function rgbAt(view: DataView, ptr: number): RGB {
232
+ return { r: view.getUint8(ptr), g: view.getUint8(ptr + 1), b: view.getUint8(ptr + 2) };
233
+ }
234
+
235
+ function bitsOf(raw: bigint, field: { lsb: number; width: number }): number {
236
+ return Number((raw >> BigInt(field.lsb)) & ((1n << BigInt(field.width)) - 1n));
237
+ }
238
+
239
+ /**
240
+ * GhosttyTerminal - a terminal backed by libghostty-vt's render state.
241
+ *
242
+ * `update()` syncs the render state and copies the dirty rows of the viewport
243
+ * into a reusable cell pool; the renderer then reads cells from the pool.
244
+ * Scrollback, graphemes, hyperlinks and wrap flags are read through grid
245
+ * references on demand.
246
+ */
247
+ export class GhosttyTerminal {
248
+ private handle: number;
249
+ private renderState: number;
250
+ private rowIterator: number;
251
+ private rowCells: number;
252
+ private _cols: number;
253
+ private _rows: number;
254
+
255
+ private cellPool: GhosttyCell[] = [];
256
+ private rowDirty: boolean[] = [];
257
+ private dirty: DirtyState = DirtyState.FULL;
258
+ private changed = true;
259
+ private colors: { background: RGB; foreground: RGB; cursor: RGB | null; palette: RGB[] } = {
260
+ background: { r: 0, g: 0, b: 0 },
261
+ foreground: { r: 204, g: 204, b: 204 },
262
+ cursor: null,
263
+ palette: [],
264
+ };
265
+ private responses: string[] = [];
266
+ private callbacks: number[] = [];
267
+
268
+ private readonly bellEmitter = new EventEmitter<void>();
269
+ private readonly titleEmitter = new EventEmitter<string>();
270
+ private readonly pwdEmitter = new EventEmitter<string>();
271
+ private readonly promptEmitter = new EventEmitter<SemanticPromptEvent>();
272
+ private readonly notificationEmitter = new EventEmitter<DesktopNotificationEvent>();
273
+ private readonly unknownOscEmitter = new EventEmitter<string>();
274
+
275
+ /** BEL from the program (Ghostty's own bell, not a guess from the byte stream). */
276
+ readonly onBell: IEvent<void> = this.bellEmitter.event;
277
+ /** OSC 0/2 title, read from the terminal after Ghostty applied it. */
278
+ readonly onTitleChange: IEvent<string> = this.titleEmitter.event;
279
+ /** OSC 7 (or OSC 9 / 1337 CurrentDir) as the shell sent it: for OSC 7 the raw file:// URI with its host. */
280
+ readonly onPwdChange: IEvent<string> = this.pwdEmitter.event;
281
+ /** OSC 133 prompt marks, delivered at their exact position in the stream. */
282
+ readonly onSemanticPrompt: IEvent<SemanticPromptEvent> = this.promptEmitter.event;
283
+ /** OSC 9 / OSC 777 desktop notifications. */
284
+ readonly onDesktopNotification: IEvent<DesktopNotificationEvent> = this.notificationEmitter.event;
285
+ /** The content of an OSC libghostty-vt does not implement, e.g. `633;E;...`. */
286
+ readonly onUnknownOsc: IEvent<string> = this.unknownOscEmitter.event;
287
+ /**
288
+ * Decides an OSC 52 clipboard write synchronously: return true to allow it.
289
+ * Without a handler every write is denied. Clipboard reads are never answered.
290
+ */
291
+ clipboardWriteHandler: ((text: string) => boolean) | null = null;
292
+
293
+ constructor(
294
+ private readonly abi: Abi,
295
+ cols: number = 80,
296
+ rows: number = 24,
297
+ config?: GhosttyTerminalConfig
298
+ ) {
299
+ this._cols = cols;
300
+ this._rows = rows;
301
+ this.handle = abi.newHandle('ghostty_terminal_new', (slot) =>
302
+ abi.call('ghostty_terminal_new', 0, slot, cols, rows)
303
+ );
304
+ this.renderState = abi.newHandle('ghostty_render_state_new', (slot) =>
305
+ abi.call('ghostty_render_state_new', 0, slot)
306
+ );
307
+ this.rowIterator = abi.newHandle('ghostty_render_state_row_iterator_new', (slot) =>
308
+ abi.call('ghostty_render_state_row_iterator_new', 0, slot)
309
+ );
310
+ this.rowCells = abi.newHandle('ghostty_render_state_row_cells_new', (slot) =>
311
+ abi.call('ghostty_render_state_row_cells_new', 0, slot)
312
+ );
313
+ const writePty = abi.addCallback(['i32', 'i32', 'i32', 'i32'], null, (_t, _u, ptr, len) => {
314
+ this.responses.push(abi.string(ptr, len));
315
+ });
316
+ this.callbacks.push(writePty);
317
+ this.setOption('WRITE_PTY', writePty);
318
+ this.installHooks();
319
+ this.setModeDefault(GRAPHEME_CLUSTER_MODE, true);
320
+ if (config) this.applyConfig(config);
321
+ this.initCellPool();
322
+ }
323
+
324
+ get cols(): number {
325
+ return this._cols;
326
+ }
327
+ get rows(): number {
328
+ return this._rows;
329
+ }
330
+
331
+ /** The raw libghostty-vt terminal handle, for APIs this class does not wrap yet. */
332
+ get rawHandle(): number {
333
+ return this.handle;
334
+ }
335
+
336
+ /** Sets a terminal option whose value is passed directly (a callback or pointer). */
337
+ setOption(option: string, value: number): void {
338
+ const opt = this.abi.enumValue('GhosttyTerminalOption', option);
339
+ this.abi.check('ghostty_terminal_set', this.handle, opt, value);
340
+ }
341
+
342
+ // ==========================================================================
343
+ // Lifecycle
344
+ // ==========================================================================
345
+
346
+ write(data: string | Uint8Array): void {
347
+ const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
348
+ this.abi.withBytes(bytes, (ptr, len) =>
349
+ this.abi.call('ghostty_terminal_vt_write', this.handle, ptr, len)
350
+ );
351
+ this.changed = true;
352
+ }
353
+
354
+ resize(cols: number, rows: number): void {
355
+ if (cols === this._cols && rows === this._rows) return;
356
+ this._cols = cols;
357
+ this._rows = rows;
358
+ this.abi.check('ghostty_terminal_resize', this.handle, cols, rows, 0, 0);
359
+ this.initCellPool();
360
+ this.changed = true;
361
+ }
362
+
363
+ free(): void {
364
+ const abi = this.abi;
365
+ if (!this.handle) return;
366
+ abi.call('ghostty_render_state_row_cells_free', this.rowCells);
367
+ abi.call('ghostty_render_state_row_iterator_free', this.rowIterator);
368
+ abi.call('ghostty_render_state_free', this.renderState);
369
+ abi.call('ghostty_terminal_free', this.handle);
370
+ for (const index of this.callbacks) abi.removeCallback(index);
371
+ this.callbacks = [];
372
+ for (const emitter of [
373
+ this.bellEmitter,
374
+ this.titleEmitter,
375
+ this.pwdEmitter,
376
+ this.promptEmitter,
377
+ this.notificationEmitter,
378
+ this.unknownOscEmitter,
379
+ ]) {
380
+ emitter.dispose();
381
+ }
382
+ this.handle = 0;
383
+ }
384
+
385
+ // ==========================================================================
386
+ // RenderState API
387
+ // ==========================================================================
388
+
389
+ /**
390
+ * Sync the render state with the terminal and copy dirty rows into the cell
391
+ * pool. Safe to call several times per frame: it only does work after a
392
+ * write or resize, and dirty state persists until markClean().
393
+ */
394
+ update(): DirtyState {
395
+ if (!this.changed) return this.dirty;
396
+ this.changed = false;
397
+ const abi = this.abi;
398
+ abi.check('ghostty_render_state_update', this.renderState, this.handle);
399
+ const dirty = this.readRenderU32('DIRTY') as DirtyState;
400
+ if (dirty > this.dirty || this.dirty === DirtyState.NONE) this.dirty = dirty;
401
+ this.readColors();
402
+ this.extractRows(this.dirty === DirtyState.FULL);
403
+ return this.dirty;
404
+ }
405
+
406
+ getCursor(): RenderStateCursor {
407
+ this.update();
408
+ const abi = this.abi;
409
+ return abi.withSized('GhosttyRenderStateCursor', (ptr) => {
410
+ abi.check(
411
+ 'ghostty_render_state_get',
412
+ this.renderState,
413
+ abi.enumValue('GhosttyRenderStateData', 'CURSOR'),
414
+ ptr
415
+ );
416
+ const view = abi.view();
417
+ const at = (f: string) => ptr + abi.offset('GhosttyRenderStateCursor', f);
418
+ const inViewport = view.getUint8(at('viewport_has_value')) !== 0;
419
+ const x = inViewport ? view.getUint16(at('viewport_x'), true) : -1;
420
+ const y = inViewport ? view.getUint16(at('viewport_y'), true) : -1;
421
+ const style = view.getInt32(at('visual_style'), true);
422
+ const styles = 'GhosttyRenderStateCursorVisualStyle';
423
+ return {
424
+ x,
425
+ y,
426
+ viewportX: x,
427
+ viewportY: y,
428
+ visible: inViewport && view.getUint8(at('visible')) !== 0,
429
+ blinking: view.getUint8(at('blinking')) !== 0,
430
+ style:
431
+ style === abi.enumValue(styles, 'BAR')
432
+ ? 'bar'
433
+ : style === abi.enumValue(styles, 'UNDERLINE')
434
+ ? 'underline'
435
+ : 'block',
436
+ };
437
+ });
438
+ }
439
+
440
+ /**
441
+ * The cursor in the active area, read from the terminal itself rather than
442
+ * the render state, so it is exact inside a callback that runs mid-write.
443
+ */
444
+ cursorPosition(): { x: number; y: number } {
445
+ return { x: this.readTerminalU16('CURSOR_X'), y: this.readTerminalU16('CURSOR_Y') };
446
+ }
447
+
448
+ getColors(): RenderStateColors {
449
+ this.update();
450
+ return {
451
+ background: { ...this.colors.background },
452
+ foreground: { ...this.colors.foreground },
453
+ cursor: this.colors.cursor ? { ...this.colors.cursor } : null,
454
+ };
455
+ }
456
+
457
+ isRowDirty(y: number): boolean {
458
+ this.update();
459
+ return this.dirty === DirtyState.FULL || this.rowDirty[y] === true;
460
+ }
461
+
462
+ markClean(): void {
463
+ this.abi.check('ghostty_render_state_clean', this.renderState);
464
+ this.dirty = DirtyState.NONE;
465
+ this.rowDirty.fill(false);
466
+ }
467
+
468
+ getViewport(): GhosttyCell[] {
469
+ this.update();
470
+ return this.cellPool;
471
+ }
472
+
473
+ getLine(y: number): GhosttyCell[] | null {
474
+ if (y < 0 || y >= this._rows) return null;
475
+ this.update();
476
+ const start = y * this._cols;
477
+ return this.cellPool.slice(start, start + this._cols).map((cell) => ({ ...cell }));
478
+ }
479
+
480
+ isDirty(): boolean {
481
+ return this.update() !== DirtyState.NONE;
482
+ }
483
+
484
+ needsFullRedraw(): boolean {
485
+ return this.update() === DirtyState.FULL;
486
+ }
487
+
488
+ clearDirty(): void {
489
+ this.markClean();
490
+ }
491
+
492
+ // ==========================================================================
493
+ // Terminal modes
494
+ // ==========================================================================
495
+
496
+ isAlternateScreen(): boolean {
497
+ return (
498
+ this.readTerminalU32('ACTIVE_SCREEN') ===
499
+ this.abi.enumValue('GhosttyTerminalScreen', 'ALTERNATE')
500
+ );
501
+ }
502
+
503
+ hasBracketedPaste(): boolean {
504
+ return this.getMode(2004, false);
505
+ }
506
+
507
+ hasFocusEvents(): boolean {
508
+ return this.getMode(1004, false);
509
+ }
510
+
511
+ mouseTrackingMode(): MouseTrackingMode {
512
+ if (this.getMode(1003)) return 'any';
513
+ if (this.getMode(1002)) return 'drag';
514
+ if (this.getMode(1000)) return 'vt200';
515
+ if (this.getMode(9)) return 'x10';
516
+ return 'none';
517
+ }
518
+
519
+ /** Tracks the start of a row of the active area. */
520
+ trackRow(activeY: number): TrackedRow | null {
521
+ const abi = this.abi;
522
+ const ref = abi.with(abi.sizeOf('GhosttyPoint'), (point) => {
523
+ const view = abi.view();
524
+ view.setInt32(point, abi.enumValue('GhosttyPointTag', POINT_ACTIVE), true);
525
+ const coord = point + abi.offset('GhosttyPoint', 'value');
526
+ view.setUint16(coord + abi.offset('GhosttyPointCoordinate', 'x'), 0, true);
527
+ view.setUint32(coord + abi.offset('GhosttyPointCoordinate', 'y'), activeY, true);
528
+ const slot = abi.call('ghostty_wasm_alloc_opaque');
529
+ try {
530
+ const result = abi.call('ghostty_terminal_grid_ref_track', this.handle, point, slot);
531
+ return result === GHOSTTY_SUCCESS ? abi.call('ghostty_wasm_take_opaque', slot) : 0;
532
+ } finally {
533
+ abi.call('ghostty_wasm_free_opaque', slot);
534
+ }
535
+ });
536
+ if (!ref) return null;
537
+ let freed = false;
538
+ return {
539
+ line: () => {
540
+ if (freed) return null;
541
+ return abi.with(abi.sizeOf('GhosttyPointCoordinate'), (out) => {
542
+ const result = abi.call(
543
+ 'ghostty_tracked_grid_ref_point',
544
+ ref,
545
+ abi.enumValue('GhosttyPointTag', 'SCREEN'),
546
+ out
547
+ );
548
+ if (result !== GHOSTTY_SUCCESS) return null;
549
+ return abi.view().getUint32(out + abi.offset('GhosttyPointCoordinate', 'y'), true);
550
+ });
551
+ },
552
+ free: () => {
553
+ if (freed) return;
554
+ freed = true;
555
+ abi.call('ghostty_tracked_grid_ref_free', ref);
556
+ },
557
+ };
558
+ }
559
+
560
+ hasMouseTracking(): boolean {
561
+ return this.readTerminalU32('MOUSE_TRACKING') !== 0;
562
+ }
563
+
564
+ getMode(mode: number, isAnsi: boolean = false): boolean {
565
+ const abi = this.abi;
566
+ return abi.with(abi.sizeOf('GhosttyTerminalModeConfig'), (ptr) => {
567
+ const view = abi.view();
568
+ view.setUint16(
569
+ ptr + abi.offset('GhosttyTerminalModeConfig', 'mode'),
570
+ (mode & 0x7fff) | (isAnsi ? 0x8000 : 0),
571
+ true
572
+ );
573
+ const result = abi.call(
574
+ 'ghostty_terminal_get',
575
+ this.handle,
576
+ abi.enumValue('GhosttyTerminalData', 'MODE'),
577
+ ptr
578
+ );
579
+ if (result !== GHOSTTY_SUCCESS) return false;
580
+ return abi.view().getUint8(ptr + abi.offset('GhosttyTerminalModeConfig', 'value')) !== 0;
581
+ });
582
+ }
583
+
584
+ // ==========================================================================
585
+ // Extended API (scrollback, graphemes, hyperlinks)
586
+ // ==========================================================================
587
+
588
+ getDimensions(): { cols: number; rows: number } {
589
+ return { cols: this._cols, rows: this._rows };
590
+ }
591
+
592
+ /** Number of scrollback lines (history, not including the active screen). */
593
+ getScrollbackLength(): number {
594
+ return this.readTerminalU32('SCROLLBACK_ROWS');
595
+ }
596
+
597
+ /** A line of scrollback; offset 0 is the oldest line. */
598
+ getScrollbackLine(offset: number): GhosttyCell[] | null {
599
+ if (offset < 0 || offset >= this.getScrollbackLength()) return null;
600
+ this.update();
601
+ const cells: GhosttyCell[] = [];
602
+ let hyperlinkRun = 0;
603
+ let previousLinked = false;
604
+ for (let x = 0; x < this._cols; x++) {
605
+ const cell = this.gridRef(POINT_HISTORY, x, offset, (ref) => this.cellAt(ref));
606
+ if (!cell) return cells.length > 0 ? cells : null;
607
+ if (cell.hyperlink_id) {
608
+ if (!previousLinked) hyperlinkRun++;
609
+ cell.hyperlink_id = hyperlinkRun;
610
+ }
611
+ previousLinked = cell.hyperlink_id !== 0;
612
+ cells.push(cell);
613
+ }
614
+ return cells;
615
+ }
616
+
617
+ isRowWrapped(row: number): boolean {
618
+ const abi = this.abi;
619
+ return (
620
+ this.gridRef(POINT_ACTIVE, 0, row, (ref) =>
621
+ abi.with(8, (rowPtr) => {
622
+ if (abi.call('ghostty_grid_ref_row', ref, rowPtr) !== GHOSTTY_SUCCESS) return false;
623
+ const raw = abi.view().getBigUint64(rowPtr, true);
624
+ return abi.with(4, (out) => {
625
+ const result = abi.call(
626
+ 'ghostty_row_get',
627
+ raw,
628
+ abi.enumValue('GhosttyRowData', 'WRAP_CONTINUATION'),
629
+ out
630
+ );
631
+ return result === GHOSTTY_SUCCESS && abi.view().getUint8(out) !== 0;
632
+ });
633
+ })
634
+ ) ?? false
635
+ );
636
+ }
637
+
638
+ getHyperlinkUri(row: number, col: number): string | null {
639
+ return this.gridRef(POINT_ACTIVE, col, row, (ref) => this.hyperlinkAt(ref)) ?? null;
640
+ }
641
+
642
+ getScrollbackHyperlinkUri(offset: number, col: number): string | null {
643
+ return this.gridRef(POINT_HISTORY, col, offset, (ref) => this.hyperlinkAt(ref)) ?? null;
644
+ }
645
+
646
+ hasResponse(): boolean {
647
+ return this.responses.length > 0;
648
+ }
649
+
650
+ readResponse(): string | null {
651
+ if (this.responses.length === 0) return null;
652
+ const out = this.responses.join('');
653
+ this.responses = [];
654
+ return out;
655
+ }
656
+
657
+ getGrapheme(row: number, col: number): number[] | null {
658
+ return this.gridRef(POINT_ACTIVE, col, row, (ref) => this.graphemesAt(ref)) ?? null;
659
+ }
660
+
661
+ getGraphemeString(row: number, col: number): string {
662
+ const codepoints = this.getGrapheme(row, col);
663
+ if (!codepoints || codepoints.length === 0) return ' ';
664
+ return String.fromCodePoint(...codepoints);
665
+ }
666
+
667
+ getScrollbackGrapheme(offset: number, col: number): number[] | null {
668
+ return this.gridRef(POINT_HISTORY, col, offset, (ref) => this.graphemesAt(ref)) ?? null;
669
+ }
670
+
671
+ getScrollbackGraphemeString(offset: number, col: number): string {
672
+ const codepoints = this.getScrollbackGrapheme(offset, col);
673
+ if (!codepoints || codepoints.length === 0) return ' ';
674
+ return String.fromCodePoint(...codepoints);
675
+ }
676
+
677
+ // ==========================================================================
678
+ // Private helpers
679
+ // ==========================================================================
680
+
681
+ private applyConfig(config: GhosttyTerminalConfig): void {
682
+ const abi = this.abi;
683
+ if (config.scrollbackLimit !== undefined) {
684
+ abi.with(4, (ptr) => {
685
+ abi.view().setUint32(ptr, config.scrollbackLimit!, true);
686
+ this.setOption('SCROLLBACK_MAX_LINES', ptr);
687
+ });
688
+ }
689
+ const setRgb = (option: string, color: number | undefined) => {
690
+ if (!color) return;
691
+ abi.with(4, (ptr) => {
692
+ const view = abi.view();
693
+ view.setUint8(ptr, (color >> 16) & 0xff);
694
+ view.setUint8(ptr + 1, (color >> 8) & 0xff);
695
+ view.setUint8(ptr + 2, color & 0xff);
696
+ this.setOption(option, ptr);
697
+ });
698
+ };
699
+ setRgb('COLOR_FOREGROUND', config.fgColor);
700
+ setRgb('COLOR_BACKGROUND', config.bgColor);
701
+ setRgb('COLOR_CURSOR', config.cursorColor);
702
+ if (config.palette?.some((c) => c)) {
703
+ const size = 256 * 3;
704
+ abi.with(size, (ptr) => {
705
+ abi.check(
706
+ 'ghostty_terminal_get',
707
+ this.handle,
708
+ abi.enumValue('GhosttyTerminalData', 'COLOR_PALETTE_DEFAULT'),
709
+ ptr
710
+ );
711
+ const view = abi.view();
712
+ config.palette!.slice(0, 16).forEach((color, i) => {
713
+ if (!color) return;
714
+ view.setUint8(ptr + i * 3, (color >> 16) & 0xff);
715
+ view.setUint8(ptr + i * 3 + 1, (color >> 8) & 0xff);
716
+ view.setUint8(ptr + i * 3 + 2, color & 0xff);
717
+ });
718
+ this.setOption('COLOR_PALETTE', ptr);
719
+ });
720
+ }
721
+ }
722
+
723
+ private installHooks(): void {
724
+ const abi = this.abi;
725
+ const hook = (option: string, params: number, fn: (...args: number[]) => void) => {
726
+ const index = abi.addCallback(Array(params).fill('i32'), null, fn);
727
+ this.callbacks.push(index);
728
+ this.setOption(option, index);
729
+ };
730
+ hook('BELL', 2, () => this.bellEmitter.fire());
731
+ hook('TITLE_CHANGED', 2, () => this.titleEmitter.fire(this.readTerminalString('TITLE')));
732
+ hook('PWD_CHANGED', 2, () => this.pwdEmitter.fire(this.readTerminalString('PWD')));
733
+ hook('SEMANTIC_PROMPT', 3, (_t, _u, event) => {
734
+ const type = 'GhosttyTerminalSemanticPrompt';
735
+ const view = abi.view();
736
+ const kindValue = view.getInt32(event + abi.offset(type, 'kind'), true);
737
+ const kindName = Object.keys(PROMPT_KINDS).find(
738
+ (k) => abi.enumValue('GhosttySemanticPromptKind', k) === kindValue
739
+ );
740
+ if (!kindName) return;
741
+ const hasExit = view.getUint8(event + abi.offset(type, 'has_exit_code')) !== 0;
742
+ const command = event + abi.offset(type, 'command');
743
+ this.promptEmitter.fire({
744
+ kind: PROMPT_KINDS[kindName],
745
+ exitCode: hasExit ? view.getInt32(event + abi.offset(type, 'exit_code'), true) : undefined,
746
+ command: this.stringAt(command),
747
+ });
748
+ });
749
+ hook('DESKTOP_NOTIFICATION', 3, (_t, _u, n) => {
750
+ const type = 'GhosttyTerminalDesktopNotification';
751
+ this.notificationEmitter.fire({
752
+ title: this.stringAt(n + abi.offset(type, 'title')),
753
+ body: this.stringAt(n + abi.offset(type, 'body')),
754
+ });
755
+ });
756
+ hook('UNKNOWN_SEQUENCE', 3, (_t, _u, seq) => {
757
+ const view = abi.view();
758
+ const type = 'GhosttyTerminalUnknownSequence';
759
+ const tag = view.getInt32(seq + abi.offset(type, 'tag'), true);
760
+ if (tag !== abi.enumValue('GhosttyTerminalUnknownSequenceTag', 'OSC')) return;
761
+ const osc = seq + abi.offset(type, 'value');
762
+ const oscType = 'GhosttyTerminalUnknownOscSequence';
763
+ if (view.getUint8(osc + abi.offset(oscType, 'truncated')) !== 0) return;
764
+ this.unknownOscEmitter.fire(this.stringAt(osc + abi.offset(oscType, 'content')));
765
+ });
766
+ abi.with(4, (ptr) => {
767
+ abi.view().setUint32(ptr, UNKNOWN_SEQUENCE_MAX_BYTES, true);
768
+ this.setOption('UNKNOWN_MAX_BYTES', ptr);
769
+ });
770
+ hook('CLIPBOARD_WRITE', 3, (_t, _u, write) => this.answerClipboardWrite(write));
771
+ }
772
+
773
+ private answerClipboardWrite(write: number): void {
774
+ const abi = this.abi;
775
+ const type = 'GhosttyClipboardWrite';
776
+ const view = abi.view();
777
+ const contents = view.getUint32(write + abi.offset(type, 'contents'), true);
778
+ const count = view.getUint32(write + abi.offset(type, 'contents_len'), true);
779
+ const entry = abi.sizeOf('GhosttyClipboardContent');
780
+ let text: string | null = null;
781
+ for (let i = 0; i < count; i++) {
782
+ const at = contents + i * entry;
783
+ const mime = this.stringAt(at + abi.offset('GhosttyClipboardContent', 'mime'));
784
+ if (text === null || mime.startsWith('text/plain')) {
785
+ text = this.stringAt(at + abi.offset('GhosttyClipboardContent', 'data'));
786
+ }
787
+ }
788
+ const allowed = text !== null && this.clipboardWriteHandler?.(text) === true;
789
+ const results = 'GhosttyClipboardWriteResult';
790
+ const reply = abi.exports.__indirect_function_table.get(
791
+ view.getUint32(write + abi.offset(type, 'reply'), true)
792
+ ) as ((write: number, reply: number) => void) | null;
793
+ abi.withSized('GhosttyClipboardWriteReply', (ptr) => {
794
+ abi
795
+ .view()
796
+ .setInt32(
797
+ ptr + abi.offset('GhosttyClipboardWriteReply', 'result'),
798
+ abi.enumValue(results, allowed ? 'SUCCESS' : 'DENIED'),
799
+ true
800
+ );
801
+ reply?.(write, ptr);
802
+ });
803
+ }
804
+
805
+ private stringAt(ptr: number): string {
806
+ const view = this.abi.view();
807
+ const data = view.getUint32(ptr + this.abi.offset('GhosttyString', 'ptr'), true);
808
+ const len = view.getUint32(ptr + this.abi.offset('GhosttyString', 'len'), true);
809
+ return this.abi.string(data, len);
810
+ }
811
+
812
+ private readTerminalString(data: string): string {
813
+ const abi = this.abi;
814
+ return abi.with(abi.sizeOf('GhosttyString'), (out) => {
815
+ const result = abi.call(
816
+ 'ghostty_terminal_get',
817
+ this.handle,
818
+ abi.enumValue('GhosttyTerminalData', data),
819
+ out
820
+ );
821
+ return result === GHOSTTY_SUCCESS ? this.stringAt(out) : '';
822
+ });
823
+ }
824
+
825
+ private setModeDefault(mode: number, value: boolean, isAnsi: boolean = false): void {
826
+ const abi = this.abi;
827
+ abi.with(abi.sizeOf('GhosttyTerminalModeConfig'), (ptr) => {
828
+ const view = abi.view();
829
+ view.setUint16(
830
+ ptr + abi.offset('GhosttyTerminalModeConfig', 'mode'),
831
+ (mode & 0x7fff) | (isAnsi ? 0x8000 : 0),
832
+ true
833
+ );
834
+ view.setUint8(ptr + abi.offset('GhosttyTerminalModeConfig', 'value'), value ? 1 : 0);
835
+ this.setOption('MODE_DEFAULT', ptr);
836
+ });
837
+ }
838
+
839
+ private readTerminalU32(data: string): number {
840
+ const abi = this.abi;
841
+ return abi.with(8, (out) => {
842
+ abi.check(
843
+ 'ghostty_terminal_get',
844
+ this.handle,
845
+ abi.enumValue('GhosttyTerminalData', data),
846
+ out
847
+ );
848
+ return abi.view().getUint32(out, true);
849
+ });
850
+ }
851
+
852
+ private readTerminalU16(data: string): number {
853
+ const abi = this.abi;
854
+ return abi.with(8, (out) => {
855
+ abi.check(
856
+ 'ghostty_terminal_get',
857
+ this.handle,
858
+ abi.enumValue('GhosttyTerminalData', data),
859
+ out
860
+ );
861
+ return abi.view().getUint16(out, true);
862
+ });
863
+ }
864
+
865
+ private readRenderU32(data: string): number {
866
+ const abi = this.abi;
867
+ return abi.with(8, (out) => {
868
+ abi.check(
869
+ 'ghostty_render_state_get',
870
+ this.renderState,
871
+ abi.enumValue('GhosttyRenderStateData', data),
872
+ out
873
+ );
874
+ return abi.view().getUint32(out, true);
875
+ });
876
+ }
877
+
878
+ private readColors(): void {
879
+ const abi = this.abi;
880
+ abi.withSized('GhosttyRenderStateColors', (ptr) => {
881
+ abi.check(
882
+ 'ghostty_render_state_get',
883
+ this.renderState,
884
+ abi.enumValue('GhosttyRenderStateData', 'COLORS'),
885
+ ptr
886
+ );
887
+ const view = abi.view();
888
+ const at = (f: string) => ptr + abi.offset('GhosttyRenderStateColors', f);
889
+ const palette: RGB[] = [];
890
+ for (let i = 0; i < 256; i++) palette.push(rgbAt(view, at('palette') + i * 3));
891
+ this.colors = {
892
+ background: rgbAt(view, at('background')),
893
+ foreground: rgbAt(view, at('foreground')),
894
+ cursor: view.getUint8(at('cursor_has_value')) ? rgbAt(view, at('cursor')) : null,
895
+ palette,
896
+ };
897
+ });
898
+ }
899
+
900
+ private extractRows(all: boolean): void {
901
+ const abi = this.abi;
902
+ const rowData = 'GhosttyRenderStateRowData';
903
+ abi.with(8, (slot) => {
904
+ const view = abi.view();
905
+ view.setUint32(slot, this.rowIterator, true);
906
+ abi.check(
907
+ 'ghostty_render_state_get',
908
+ this.renderState,
909
+ abi.enumValue('GhosttyRenderStateData', 'ROW_ITERATOR'),
910
+ slot
911
+ );
912
+ abi.view().setUint32(slot, this.rowCells, true);
913
+ let y = 0;
914
+ while (
915
+ y < this._rows &&
916
+ abi.call('ghostty_render_state_row_iterator_next', this.rowIterator)
917
+ ) {
918
+ const rowDirty = abi.with(4, (out) => {
919
+ abi.check(
920
+ 'ghostty_render_state_row_get',
921
+ this.rowIterator,
922
+ abi.enumValue(rowData, 'DIRTY'),
923
+ out
924
+ );
925
+ return abi.view().getUint8(out) !== 0;
926
+ });
927
+ if (all || rowDirty) {
928
+ this.rowDirty[y] = true;
929
+ abi.check(
930
+ 'ghostty_render_state_row_get',
931
+ this.rowIterator,
932
+ abi.enumValue(rowData, 'CELLS'),
933
+ slot
934
+ );
935
+ this.extractCells(y);
936
+ }
937
+ y++;
938
+ }
939
+ });
940
+ }
941
+
942
+ private extractCells(y: number): void {
943
+ const abi = this.abi;
944
+ const cellsData = 'GhosttyRenderStateRowCellsData';
945
+ const styleSize = abi.sizeOf('GhosttyStyle');
946
+ const keyRaw = abi.enumValue(cellsData, 'RAW');
947
+ const keyLen = abi.enumValue(cellsData, 'GRAPHEMES_LEN');
948
+ const keyStyle = abi.enumValue(cellsData, 'STYLE');
949
+ const block = 3 * 4 + 3 * 4 + 8 + 8 + styleSize + 8;
950
+ abi.with(block, (base) => {
951
+ const keys = base;
952
+ const values = base + 12;
953
+ const raw = base + 24;
954
+ const len = base + 32;
955
+ const style = base + 40;
956
+ const written = style + styleSize;
957
+ const view = abi.view();
958
+ view.setInt32(keys, keyRaw, true);
959
+ view.setInt32(keys + 4, keyLen, true);
960
+ view.setInt32(keys + 8, keyStyle, true);
961
+ view.setUint32(values, raw, true);
962
+ view.setUint32(values + 4, len, true);
963
+ view.setUint32(values + 8, style, true);
964
+ let x = 0;
965
+ let hyperlinkRun = 0;
966
+ let previousLinked = false;
967
+ while (x < this._cols && abi.call('ghostty_render_state_row_cells_next', this.rowCells)) {
968
+ abi.view().setUint32(style, styleSize, true);
969
+ abi.check(
970
+ 'ghostty_render_state_row_cells_get_multi',
971
+ this.rowCells,
972
+ 3,
973
+ keys,
974
+ values,
975
+ written
976
+ );
977
+ const v = abi.view();
978
+ const cell = this.cellPool[y * this._cols + x];
979
+ const graphemeLen = v.getUint32(len, true);
980
+ this.fillCell(cell, v.getBigUint64(raw, true), style, graphemeLen);
981
+ if (cell.hyperlink_id) {
982
+ if (!previousLinked) hyperlinkRun++;
983
+ cell.hyperlink_id = hyperlinkRun;
984
+ }
985
+ previousLinked = cell.hyperlink_id !== 0;
986
+ x++;
987
+ }
988
+ });
989
+ }
990
+
991
+ private fillCell(cell: GhosttyCell, raw: bigint, stylePtr: number, graphemeLen: number): void {
992
+ const abi = this.abi;
993
+ const view = abi.view();
994
+ const tag = bitsOf(raw, abi.bits('GhosttyCell', 'content_tag'));
995
+ const content = abi.bits('GhosttyCell', 'content');
996
+ const contentBits = bitsOf(raw, content);
997
+ const tags = 'GhosttyCellContentTag';
998
+ const isText =
999
+ tag === abi.enumValue(tags, 'CODEPOINT') || tag === abi.enumValue(tags, 'CODEPOINT_GRAPHEME');
1000
+ cell.codepoint = isText ? contentBits & 0x1fffff : 0;
1001
+ cell.grapheme_len = Math.max(0, graphemeLen - 1);
1002
+
1003
+ const at = (f: string) => stylePtr + abi.offset('GhosttyStyle', f);
1004
+ const fg = this.styleColor(view, at('fg_color')) ?? this.colors.foreground;
1005
+ let bg = this.styleColor(view, at('bg_color')) ?? this.colors.background;
1006
+ if (tag === abi.enumValue(tags, 'BG_COLOR_PALETTE')) {
1007
+ bg = this.colors.palette[contentBits & 0xff] ?? bg;
1008
+ } else if (tag === abi.enumValue(tags, 'BG_COLOR_RGB')) {
1009
+ bg = { r: contentBits & 0xff, g: (contentBits >> 8) & 0xff, b: (contentBits >> 16) & 0xff };
1010
+ }
1011
+ cell.fg_r = fg.r;
1012
+ cell.fg_g = fg.g;
1013
+ cell.fg_b = fg.b;
1014
+ cell.bg_r = bg.r;
1015
+ cell.bg_g = bg.g;
1016
+ cell.bg_b = bg.b;
1017
+
1018
+ let flags = 0;
1019
+ if (view.getUint8(at('bold'))) flags |= CellFlags.BOLD;
1020
+ if (view.getUint8(at('italic'))) flags |= CellFlags.ITALIC;
1021
+ if (view.getInt32(at('underline'), true) !== 0) flags |= CellFlags.UNDERLINE;
1022
+ if (view.getUint8(at('strikethrough'))) flags |= CellFlags.STRIKETHROUGH;
1023
+ if (view.getUint8(at('inverse'))) flags |= CellFlags.INVERSE;
1024
+ if (view.getUint8(at('invisible'))) flags |= CellFlags.INVISIBLE;
1025
+ if (view.getUint8(at('blink'))) flags |= CellFlags.BLINK;
1026
+ if (view.getUint8(at('faint'))) flags |= CellFlags.FAINT;
1027
+ cell.flags = flags;
1028
+
1029
+ const wide = bitsOf(raw, abi.bits('GhosttyCell', 'wide'));
1030
+ const wides = 'GhosttyCellWide';
1031
+ cell.width =
1032
+ wide === abi.enumValue(wides, 'WIDE')
1033
+ ? 2
1034
+ : wide === abi.enumValue(wides, 'SPACER_TAIL')
1035
+ ? 0
1036
+ : 1;
1037
+ cell.hyperlink_id = bitsOf(raw, abi.bits('GhosttyCell', 'hyperlink'));
1038
+ }
1039
+
1040
+ private styleColor(view: DataView, ptr: number): RGB | null {
1041
+ const abi = this.abi;
1042
+ const tag = view.getInt32(ptr + abi.offset('GhosttyStyleColor', 'tag'), true);
1043
+ const value = ptr + abi.offset('GhosttyStyleColor', 'value');
1044
+ if (tag === abi.enumValue('GhosttyStyleColorTag', 'PALETTE')) {
1045
+ return this.colors.palette[view.getUint8(value)] ?? null;
1046
+ }
1047
+ if (tag === abi.enumValue('GhosttyStyleColorTag', 'RGB')) return rgbAt(view, value);
1048
+ return null;
1049
+ }
1050
+
1051
+ private gridRef<T>(space: PointSpace, x: number, y: number, use: (ref: number) => T): T | null {
1052
+ const abi = this.abi;
1053
+ return abi.with(abi.sizeOf('GhosttyPoint'), (point) => {
1054
+ const view = abi.view();
1055
+ view.setInt32(point, abi.enumValue('GhosttyPointTag', space), true);
1056
+ const coord = point + abi.offset('GhosttyPoint', 'value');
1057
+ view.setUint16(coord + abi.offset('GhosttyPointCoordinate', 'x'), x, true);
1058
+ view.setUint32(coord + abi.offset('GhosttyPointCoordinate', 'y'), y, true);
1059
+ return abi.withSized('GhosttyGridRef', (ref) => {
1060
+ const result = abi.call('ghostty_terminal_grid_ref', this.handle, point, ref);
1061
+ return result === GHOSTTY_SUCCESS ? use(ref) : null;
1062
+ });
1063
+ });
1064
+ }
1065
+
1066
+ private cellAt(ref: number): GhosttyCell | null {
1067
+ const abi = this.abi;
1068
+ const styleSize = abi.sizeOf('GhosttyStyle');
1069
+ return abi.with(8 + styleSize, (rawPtr) => {
1070
+ if (abi.call('ghostty_grid_ref_cell', ref, rawPtr) !== GHOSTTY_SUCCESS) return null;
1071
+ const stylePtr = rawPtr + 8;
1072
+ abi.view().setUint32(stylePtr, styleSize, true);
1073
+ if (abi.call('ghostty_grid_ref_style', ref, stylePtr) !== GHOSTTY_SUCCESS) return null;
1074
+ const graphemes = this.graphemesAt(ref);
1075
+ const cell = GhosttyTerminal.emptyCell();
1076
+ this.fillCell(cell, abi.view().getBigUint64(rawPtr, true), stylePtr, graphemes?.length ?? 0);
1077
+ return cell;
1078
+ });
1079
+ }
1080
+
1081
+ private graphemesAt(ref: number): number[] | null {
1082
+ const abi = this.abi;
1083
+ return abi.with(GRAPHEME_CAP * 4 + 8, (buf) => {
1084
+ const outLen = buf + GRAPHEME_CAP * 4;
1085
+ const result = abi.call('ghostty_grid_ref_graphemes', ref, buf, GRAPHEME_CAP, outLen);
1086
+ if (result !== GHOSTTY_SUCCESS) return null;
1087
+ const n = Math.min(abi.view().getUint32(outLen, true), GRAPHEME_CAP);
1088
+ const view = abi.view();
1089
+ const out: number[] = [];
1090
+ for (let i = 0; i < n; i++) out.push(view.getUint32(buf + i * 4, true));
1091
+ return out;
1092
+ });
1093
+ }
1094
+
1095
+ private hyperlinkAt(ref: number): string | null {
1096
+ const abi = this.abi;
1097
+ for (const cap of HYPERLINK_CAPS) {
1098
+ const uri = abi.with(cap + 8, (buf) => {
1099
+ const outLen = buf + cap;
1100
+ const result = abi.call('ghostty_grid_ref_hyperlink_uri', ref, buf, cap, outLen);
1101
+ if (result === GHOSTTY_SUCCESS) {
1102
+ const len = abi.view().getUint32(outLen, true);
1103
+ return len === 0 ? null : abi.string(buf, len);
1104
+ }
1105
+ return result;
1106
+ });
1107
+ if (typeof uri === 'string' || uri === null) return uri;
1108
+ }
1109
+ return null;
1110
+ }
1111
+
1112
+ private static emptyCell(): GhosttyCell {
1113
+ return {
1114
+ codepoint: 0,
1115
+ fg_r: 204,
1116
+ fg_g: 204,
1117
+ fg_b: 204,
1118
+ bg_r: 0,
1119
+ bg_g: 0,
1120
+ bg_b: 0,
1121
+ flags: 0,
1122
+ width: 1,
1123
+ hyperlink_id: 0,
1124
+ grapheme_len: 0,
1125
+ };
1126
+ }
1127
+
1128
+ private initCellPool(): void {
1129
+ const total = this._cols * this._rows;
1130
+ while (this.cellPool.length < total) this.cellPool.push(GhosttyTerminal.emptyCell());
1131
+ this.cellPool.length = total;
1132
+ this.rowDirty = new Array(this._rows).fill(true);
1133
+ this.dirty = DirtyState.FULL;
1134
+ }
1135
+ }