@aurigax-ai/ghostty-web 0.3.6 → 0.3.8

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/README.md CHANGED
@@ -70,6 +70,23 @@ blink) and sleeps otherwise. `setPaused(true)` stops drawing a terminal that is
70
70
  keeps parsing output; `setPaused(false)` redraws it whole. `setPaused(true, { releaseRenderer: true })`
71
71
  also gives up its WebGL context until then, since browsers keep only a few.
72
72
 
73
+ ## Headless terminal
74
+
75
+ `HeadlessTerminal` parses bytes with no DOM, canvas or renderer, for a server or worker that keeps a screen and scrollback.
76
+
77
+ ```ts
78
+ import { Ghostty, HeadlessTerminal } from '@aurigax-ai/ghostty-web';
79
+
80
+ const ghostty = await Ghostty.fromBytes(wasmBytes);
81
+ const term = new HeadlessTerminal({ ghostty, cols: 120, rows: 40, scrollback: 2000 });
82
+ term.parser.registerOscHandler(133, (data) => false);
83
+ term.parser.registerCsiHandler({ final: 'J' }, (params) => {});
84
+ term.write('hello\r\n', () => {});
85
+ term.buffer.normal.getLine(0)?.translateToString(true);
86
+ ```
87
+
88
+ It has `buffer`, `registerMarker`, `onScroll`, `onWriteParsed`, `modes.bracketedPasteMode`, `resize` and `dispose`, with the xterm.js names. OSC handlers get the payload after the number (7, 133 and any other number). CSI handlers only observe a sequence, just before the terminal applies it. `scrollback` frees whole pages, so a screen can hold fewer lines than the limit (about half at 80 columns) and a narrow one more.
89
+
73
90
  ## Use
74
91
 
75
92
  ```ts
package/lib/buffer.ts CHANGED
@@ -17,11 +17,36 @@
17
17
 
18
18
  import { EventEmitter } from './event-emitter';
19
19
  import type { GhosttyTerminal } from './ghostty';
20
- import { CellFlags } from './ghostty';
20
+ import { CellFlags, ColorKind } from './ghostty';
21
21
  import type { IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IEvent } from './interfaces';
22
- import type { Terminal } from './terminal';
23
22
  import type { GhosttyCell } from './types';
24
23
 
24
+ function nullCellData(): GhosttyCell {
25
+ return {
26
+ codepoint: 0,
27
+ fg_r: 204,
28
+ fg_g: 204,
29
+ fg_b: 204,
30
+ bg_r: 0,
31
+ bg_g: 0,
32
+ bg_b: 0,
33
+ flags: 0,
34
+ width: 1,
35
+ hyperlink_id: 0,
36
+ grapheme_len: 0,
37
+ fg_kind: ColorKind.DEFAULT,
38
+ bg_kind: ColorKind.DEFAULT,
39
+ fg_index: 0,
40
+ bg_index: 0,
41
+ };
42
+ }
43
+
44
+ /** What a buffer reads from its terminal: the wasm engine and how far the viewport is scrolled back. */
45
+ export interface BufferHost {
46
+ readonly wasmTerm?: GhosttyTerminal;
47
+ readonly viewportY: number;
48
+ }
49
+
25
50
  // ============================================================================
26
51
  // BufferNamespace - Top-level buffer API
27
52
  // ============================================================================
@@ -31,20 +56,20 @@ import type { GhosttyCell } from './types';
31
56
  * Provides access to active, normal, and alternate screen buffers
32
57
  */
33
58
  export class BufferNamespace implements IBufferNamespace {
34
- private terminal: Terminal;
59
+ private terminal: BufferHost;
35
60
  private bufferChangeEmitter = new EventEmitter<IBuffer>();
36
61
 
37
62
  // Lazy-initialized buffer wrappers (stateless, so we can cache them)
38
63
  private _normalBuffer?: Buffer;
39
64
  private _alternateBuffer?: Buffer;
40
65
 
41
- constructor(terminal: Terminal) {
66
+ constructor(terminal: BufferHost) {
42
67
  this.terminal = terminal;
43
68
  }
44
69
 
45
70
  get active(): IBuffer {
46
71
  // Query WASM to determine which buffer is active
47
- const wasmTerm = (this.terminal as any).wasmTerm as GhosttyTerminal | undefined;
72
+ const wasmTerm = this.terminal.wasmTerm;
48
73
  if (!wasmTerm) {
49
74
  return this.normal; // Default to normal if not initialized
50
75
  }
@@ -87,29 +112,16 @@ export class BufferNamespace implements IBufferNamespace {
87
112
  * A terminal buffer (normal or alternate screen)
88
113
  */
89
114
  export class Buffer implements IBuffer {
90
- private terminal: Terminal;
115
+ private terminal: BufferHost;
91
116
  private bufferType: 'normal' | 'alternate';
92
117
  private nullCell: BufferCell;
93
118
 
94
- constructor(terminal: Terminal, type: 'normal' | 'alternate') {
119
+ constructor(terminal: BufferHost, type: 'normal' | 'alternate') {
95
120
  this.terminal = terminal;
96
121
  this.bufferType = type;
97
122
 
98
123
  // Create a null cell (codepoint=0, default colors, no flags)
99
- const nullCellData: GhosttyCell = {
100
- codepoint: 0,
101
- fg_r: 204,
102
- fg_g: 204,
103
- fg_b: 204,
104
- bg_r: 0,
105
- bg_g: 0,
106
- bg_b: 0,
107
- flags: 0,
108
- width: 1,
109
- hyperlink_id: 0,
110
- grapheme_len: 0,
111
- };
112
- this.nullCell = new BufferCell(nullCellData, 0);
124
+ this.nullCell = new BufferCell(nullCellData(), 0);
113
125
  }
114
126
 
115
127
  get type(): 'normal' | 'alternate' {
@@ -132,7 +144,7 @@ export class Buffer implements IBuffer {
132
144
  /** The absolute line at the top of the viewport (baseY when scrolled to the bottom). */
133
145
  get viewportY(): number {
134
146
  if (this.bufferType === 'alternate') return 0;
135
- const scrolledBack = Math.round((this.terminal as any).viewportY ?? 0);
147
+ const scrolledBack = Math.round(this.terminal.viewportY);
136
148
  return Math.max(0, this.baseY - scrolledBack);
137
149
  }
138
150
 
@@ -172,6 +184,7 @@ export class Buffer implements IBuffer {
172
184
  let cells: GhosttyCell[] | null;
173
185
  let lineNumber: number;
174
186
  let isWrapped: boolean;
187
+ let graphemes: (x: number) => string;
175
188
 
176
189
  if (this.bufferType === 'normal' && y < scrollbackLength) {
177
190
  // Accessing scrollback
@@ -181,18 +194,20 @@ export class Buffer implements IBuffer {
181
194
  const scrollbackOffset = y;
182
195
  cells = wasmTerm.getScrollbackLine(scrollbackOffset);
183
196
  isWrapped = wasmTerm.isScrollbackRowWrapped(scrollbackOffset);
197
+ graphemes = (x) => wasmTerm.getScrollbackGraphemeString(scrollbackOffset, x);
184
198
  } else {
185
199
  // Accessing visible screen
186
200
  lineNumber = this.bufferType === 'normal' ? y - scrollbackLength : y;
187
201
  cells = wasmTerm.getLine(lineNumber);
188
202
  isWrapped = wasmTerm.isRowWrapped(lineNumber);
203
+ graphemes = (x) => wasmTerm.getGraphemeString(lineNumber, x);
189
204
  }
190
205
 
191
206
  if (!cells) {
192
207
  return undefined;
193
208
  }
194
209
 
195
- return new BufferLine(cells, isWrapped, wasmTerm.cols);
210
+ return new BufferLine(cells, isWrapped, wasmTerm.cols, graphemes);
196
211
  }
197
212
 
198
213
  getNullCell(): IBufferCell {
@@ -200,7 +215,7 @@ export class Buffer implements IBuffer {
200
215
  }
201
216
 
202
217
  private getWasmTerm(): GhosttyTerminal | undefined {
203
- return (this.terminal as any).wasmTerm as GhosttyTerminal | undefined;
218
+ return this.terminal.wasmTerm;
204
219
  }
205
220
  }
206
221
 
@@ -216,7 +231,12 @@ export class BufferLine implements IBufferLine {
216
231
  private _isWrapped: boolean;
217
232
  private _length: number;
218
233
 
219
- constructor(cells: GhosttyCell[], isWrapped: boolean, length: number) {
234
+ constructor(
235
+ cells: GhosttyCell[],
236
+ isWrapped: boolean,
237
+ length: number,
238
+ private readonly graphemes?: (x: number) => string
239
+ ) {
220
240
  this.cells = cells;
221
241
  this._isWrapped = isWrapped;
222
242
  this._length = length;
@@ -237,25 +257,11 @@ export class BufferLine implements IBufferLine {
237
257
 
238
258
  if (x >= this.cells.length) {
239
259
  // Cell beyond what was returned (empty/null cell)
240
- return new BufferCell(
241
- {
242
- codepoint: 0,
243
- fg_r: 204,
244
- fg_g: 204,
245
- fg_b: 204,
246
- bg_r: 0,
247
- bg_g: 0,
248
- bg_b: 0,
249
- flags: 0,
250
- width: 1,
251
- hyperlink_id: 0,
252
- grapheme_len: 0,
253
- },
254
- x
255
- );
260
+ return new BufferCell(nullCellData(), x);
256
261
  }
257
262
 
258
- return new BufferCell(this.cells[x], x);
263
+ const cell = this.cells[x];
264
+ return new BufferCell(cell, x, cell.grapheme_len > 0 ? this.graphemes?.(x) : undefined);
259
265
  }
260
266
 
261
267
  translateToString(trimRight = false, startColumn = 0, endColumn = this._length): string {
@@ -266,10 +272,7 @@ export class BufferLine implements IBufferLine {
266
272
  let result = '';
267
273
  for (let x = start; x < end; x++) {
268
274
  const cell = this.getCell(x);
269
- if (cell) {
270
- const chars = cell.getChars();
271
- result += chars;
272
- }
275
+ if (cell && cell.getWidth() !== 0) result += cell.getChars() || ' ';
273
276
  }
274
277
 
275
278
  if (trimRight) {
@@ -290,11 +293,16 @@ export class BufferLine implements IBufferLine {
290
293
  export class BufferCell implements IBufferCell {
291
294
  private cell: GhosttyCell;
292
295
 
293
- constructor(cell: GhosttyCell, _x: number) {
296
+ constructor(
297
+ cell: GhosttyCell,
298
+ _x: number,
299
+ private readonly grapheme?: string
300
+ ) {
294
301
  this.cell = cell;
295
302
  }
296
303
 
297
304
  getChars(): string {
305
+ if (this.grapheme) return this.grapheme;
298
306
  const codepoint = this.cell.codepoint;
299
307
 
300
308
  // Return empty string for null character or invalid codepoints
@@ -320,6 +328,22 @@ export class BufferCell implements IBufferCell {
320
328
  return this.cell.width;
321
329
  }
322
330
 
331
+ getFgKind(): ColorKind {
332
+ return this.cell.fg_kind;
333
+ }
334
+
335
+ getBgKind(): ColorKind {
336
+ return this.cell.bg_kind;
337
+ }
338
+
339
+ getFgPaletteIndex(): number {
340
+ return this.cell.fg_index;
341
+ }
342
+
343
+ getBgPaletteIndex(): number {
344
+ return this.cell.bg_index;
345
+ }
346
+
323
347
  getFgColorMode(): number {
324
348
  // Return -1 for RGB (we always use RGB in our WASM implementation)
325
349
  // xterm.js uses different values:
@@ -0,0 +1,123 @@
1
+ export type CsiParams = (number | number[])[];
2
+
3
+ export interface CsiIdentifier {
4
+ prefix?: string;
5
+ intermediates?: string;
6
+ final: string;
7
+ }
8
+
9
+ export type CsiHandler = (params: CsiParams) => void;
10
+
11
+ type State = 'ground' | 'escape' | 'csi' | 'string' | 'string-escape';
12
+
13
+ const ESC = 0x1b;
14
+ const BEL = 0x07;
15
+ const CSI_INTRODUCER = 0x5b;
16
+ const STRING_INTRODUCERS = new Set([0x5d, 0x50, 0x5f, 0x5e, 0x58]);
17
+ const MAX_SEQUENCE = 256;
18
+
19
+ /**
20
+ * Watches a byte stream for CSI sequences without parsing the terminal state.
21
+ * It follows the stream across chunks and skips OSC, DCS, APC, PM and SOS
22
+ * strings, so text inside them never counts as a sequence.
23
+ */
24
+ export class CsiObserver {
25
+ private state: State = 'ground';
26
+ private body = '';
27
+ private readonly handlers = new Map<string, Set<CsiHandler>>();
28
+
29
+ get active(): boolean {
30
+ return this.handlers.size > 0;
31
+ }
32
+
33
+ register(id: CsiIdentifier, handler: CsiHandler): { dispose(): void } {
34
+ const key = keyOf(id.prefix ?? '', id.intermediates ?? '', id.final);
35
+ const set = this.handlers.get(key) ?? new Set<CsiHandler>();
36
+ set.add(handler);
37
+ this.handlers.set(key, set);
38
+ return {
39
+ dispose: () => {
40
+ set.delete(handler);
41
+ if (set.size === 0) this.handlers.delete(key);
42
+ },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Advances over the byte at `index`. When it ends a sequence with handlers,
48
+ * `before(index)` runs first, so the caller can hand the terminal everything
49
+ * before that byte, and the handlers then see the state the sequence found.
50
+ */
51
+ step(byte: number, index: number, before: (index: number) => void): void {
52
+ switch (this.state) {
53
+ case 'ground':
54
+ if (byte === ESC) this.state = 'escape';
55
+ return;
56
+ case 'escape':
57
+ if (byte === CSI_INTRODUCER) {
58
+ this.state = 'csi';
59
+ this.body = '';
60
+ } else if (STRING_INTRODUCERS.has(byte)) {
61
+ this.state = 'string';
62
+ } else {
63
+ this.state = byte === ESC ? 'escape' : 'ground';
64
+ }
65
+ return;
66
+ case 'string':
67
+ if (byte === BEL) this.state = 'ground';
68
+ else if (byte === ESC) this.state = 'string-escape';
69
+ return;
70
+ case 'string-escape':
71
+ this.state = byte === 0x5c ? 'ground' : byte === ESC ? 'string-escape' : 'string';
72
+ return;
73
+ case 'csi':
74
+ this.csiByte(byte, index, before);
75
+ }
76
+ }
77
+
78
+ private csiByte(byte: number, index: number, before: (index: number) => void): void {
79
+ if (byte >= 0x40 && byte <= 0x7e) {
80
+ this.state = 'ground';
81
+ this.dispatch(this.body, String.fromCharCode(byte), index, before);
82
+ return;
83
+ }
84
+ if (byte === ESC) {
85
+ this.state = 'escape';
86
+ return;
87
+ }
88
+ if (byte < 0x20 || byte > 0x3f) {
89
+ if (byte >= 0x20 && byte <= 0x2f) this.body += String.fromCharCode(byte);
90
+ else if (byte === 0x18 || byte === 0x1a) this.state = 'ground';
91
+ return;
92
+ }
93
+ this.body += String.fromCharCode(byte);
94
+ if (this.body.length > MAX_SEQUENCE) this.state = 'ground';
95
+ }
96
+
97
+ private dispatch(
98
+ body: string,
99
+ final: string,
100
+ index: number,
101
+ before: (index: number) => void
102
+ ): void {
103
+ const match = /^([<=>?]?)([0-9;:]*)([ -/]*)$/.exec(body);
104
+ if (!match) return;
105
+ const handlers = this.handlers.get(keyOf(match[1], match[3], final));
106
+ if (!handlers) return;
107
+ before(index);
108
+ const params = parseParams(match[2]);
109
+ for (const handler of [...handlers]) handler(params);
110
+ }
111
+ }
112
+
113
+ function keyOf(prefix: string, intermediates: string, final: string): string {
114
+ return `${prefix}|${intermediates}|${final}`;
115
+ }
116
+
117
+ function parseParams(text: string): CsiParams {
118
+ if (text === '') return [];
119
+ return text.split(';').map((part) => {
120
+ if (!part.includes(':')) return part === '' ? 0 : Number(part);
121
+ return part.split(':').map((sub) => (sub === '' ? 0 : Number(sub)));
122
+ });
123
+ }
package/lib/ghostty.ts CHANGED
@@ -18,6 +18,7 @@ import { EventEmitter } from './event-emitter';
18
18
  import type { IEvent } from './interfaces';
19
19
  import {
20
20
  CellFlags,
21
+ ColorKind,
21
22
  type Cursor,
22
23
  DirtyState,
23
24
  type GhosttyCell,
@@ -33,6 +34,7 @@ import {
33
34
  // Re-export types for convenience
34
35
  export {
35
36
  CellFlags,
37
+ ColorKind,
36
38
  type Cursor,
37
39
  DirtyState,
38
40
  type GhosttyCell,
@@ -1402,9 +1404,27 @@ export class GhosttyTerminal {
1402
1404
  let fg = this.colors.foreground;
1403
1405
  let bg = this.colors.background;
1404
1406
  let flags = 0;
1407
+ let fgKind = ColorKind.DEFAULT;
1408
+ let bgKind = ColorKind.DEFAULT;
1409
+ let fgIndex = 0;
1410
+ let bgIndex = 0;
1405
1411
  if (styleId !== 0) {
1406
1412
  fg = this.styleColor(view, stylePtr + L.fgColor) ?? fg;
1407
1413
  bg = this.styleColor(view, stylePtr + L.bgColor) ?? bg;
1414
+ const fgTag = view.getInt32(stylePtr + L.fgColor + L.colorTag, true);
1415
+ const bgTag = view.getInt32(stylePtr + L.bgColor + L.colorTag, true);
1416
+ if (fgTag === L.colorPalette) {
1417
+ fgKind = ColorKind.PALETTE;
1418
+ fgIndex = view.getUint8(stylePtr + L.fgColor + L.colorValue);
1419
+ } else if (fgTag === L.colorRgb) {
1420
+ fgKind = ColorKind.RGB;
1421
+ }
1422
+ if (bgTag === L.colorPalette) {
1423
+ bgKind = ColorKind.PALETTE;
1424
+ bgIndex = view.getUint8(stylePtr + L.bgColor + L.colorValue);
1425
+ } else if (bgTag === L.colorRgb) {
1426
+ bgKind = ColorKind.RGB;
1427
+ }
1408
1428
  if (view.getUint8(stylePtr + L.bold)) flags |= CellFlags.BOLD;
1409
1429
  if (view.getUint8(stylePtr + L.italic)) flags |= CellFlags.ITALIC;
1410
1430
  if (view.getInt32(stylePtr + L.underline, true) !== 0) flags |= CellFlags.UNDERLINE;
@@ -1416,9 +1436,16 @@ export class GhosttyTerminal {
1416
1436
  }
1417
1437
  if (tag === L.tagBgPalette) {
1418
1438
  bg = this.colors.palette[contentBits & 0xff] ?? bg;
1439
+ bgKind = ColorKind.PALETTE;
1440
+ bgIndex = contentBits & 0xff;
1419
1441
  } else if (tag === L.tagBgRgb) {
1420
1442
  bg = { r: contentBits & 0xff, g: (contentBits >> 8) & 0xff, b: (contentBits >> 16) & 0xff };
1443
+ bgKind = ColorKind.RGB;
1421
1444
  }
1445
+ cell.fg_kind = fgKind;
1446
+ cell.bg_kind = bgKind;
1447
+ cell.fg_index = fgIndex;
1448
+ cell.bg_index = bgIndex;
1422
1449
  cell.fg_r = fg.r;
1423
1450
  cell.fg_g = fg.g;
1424
1451
  cell.fg_b = fg.b;
@@ -1515,6 +1542,10 @@ export class GhosttyTerminal {
1515
1542
  width: 1,
1516
1543
  hyperlink_id: 0,
1517
1544
  grapheme_len: 0,
1545
+ fg_kind: ColorKind.DEFAULT,
1546
+ bg_kind: ColorKind.DEFAULT,
1547
+ fg_index: 0,
1548
+ bg_index: 0,
1518
1549
  };
1519
1550
  }
1520
1551
 
@@ -0,0 +1,215 @@
1
+ import { BufferNamespace } from './buffer';
2
+ import { type CsiHandler, type CsiIdentifier, CsiObserver } from './csi-observer';
3
+ import { EventEmitter } from './event-emitter';
4
+ import type { Ghostty, GhosttyTerminal, MouseTrackingMode, SemanticPromptEvent } from './ghostty';
5
+ import type { IBufferNamespace, IDisposable, IEvent } from './interfaces';
6
+ import { type IMarker, Marker } from './marker';
7
+
8
+ export interface HeadlessTerminalOptions {
9
+ ghostty: Ghostty;
10
+ cols?: number;
11
+ rows?: number;
12
+ /**
13
+ * Lines kept above the screen. Ghostty frees whole pages, never single rows,
14
+ * so at 80 columns it can hold about half of this, and a
15
+ * narrow screen, whose page holds more rows, can hold more than this.
16
+ */
17
+ scrollback?: number;
18
+ }
19
+
20
+ export type OscHandler = (data: string) => boolean | Promise<boolean>;
21
+
22
+ export interface HeadlessParser {
23
+ /** Runs `handler` with the OSC payload after its number; its result is ignored. */
24
+ registerOscHandler(ident: number, handler: OscHandler): IDisposable;
25
+ /** Observes a CSI sequence just before the terminal applies it; its result is ignored. */
26
+ registerCsiHandler(id: CsiIdentifier, handler: CsiHandler): IDisposable;
27
+ }
28
+
29
+ const PROMPT_MARKS: Record<SemanticPromptEvent['kind'], string> = {
30
+ 'prompt-start': 'A',
31
+ 'input-start': 'B',
32
+ 'output-start': 'C',
33
+ 'command-end': 'D',
34
+ };
35
+
36
+ const LINE_FEED = 0x0a;
37
+ const LINES_PER_SLICE = 128;
38
+
39
+ function unknownOsc(content: string): { ident: number; data: string } | null {
40
+ const sep = content.indexOf(';');
41
+ const ident = Number(sep === -1 ? content : content.slice(0, sep));
42
+ if (!Number.isInteger(ident) || ident < 0) return null;
43
+ return { ident, data: sep === -1 ? '' : content.slice(sep + 1) };
44
+ }
45
+
46
+ /**
47
+ * A terminal with no DOM, canvas or renderer: it parses bytes and keeps the
48
+ * screen, scrollback, markers and OSC/CSI hooks, with the xterm.js names that
49
+ * `Terminal` uses. Writes are synchronous; the write callback runs in a
50
+ * microtask. Replies the terminal would send to the program are dropped.
51
+ */
52
+ export class HeadlessTerminal implements IDisposable {
53
+ readonly buffer: IBufferNamespace;
54
+ readonly parser: HeadlessParser;
55
+ readonly onScroll: IEvent<number>;
56
+ readonly onWriteParsed: IEvent<void>;
57
+ readonly onTitleChange: IEvent<string>;
58
+
59
+ cols: number;
60
+ rows: number;
61
+ wasmTerm: GhosttyTerminal | undefined;
62
+ readonly viewportY = 0;
63
+
64
+ private readonly scrollEmitter = new EventEmitter<number>();
65
+ private readonly writeParsedEmitter = new EventEmitter<void>();
66
+ private readonly titleEmitter = new EventEmitter<string>();
67
+ private readonly oscRoutes = new Map<number, Set<OscHandler>>();
68
+ private readonly csi = new CsiObserver();
69
+ private readonly markers = new Set<Marker>();
70
+ private readonly subscriptions: IDisposable[];
71
+ private readonly encoder = new TextEncoder();
72
+ private lastAlternate = false;
73
+ private lastBase = 0;
74
+ private disposed = false;
75
+
76
+ constructor(options: HeadlessTerminalOptions) {
77
+ this.cols = options.cols ?? 80;
78
+ this.rows = options.rows ?? 24;
79
+ const engine = options.ghostty.createTerminal(this.cols, this.rows, {
80
+ scrollbackLimit: options.scrollback ?? 10000,
81
+ });
82
+ this.wasmTerm = engine;
83
+ this.buffer = new BufferNamespace(this);
84
+ this.onScroll = this.scrollEmitter.event;
85
+ this.onWriteParsed = this.writeParsedEmitter.event;
86
+ this.onTitleChange = this.titleEmitter.event;
87
+ this.parser = {
88
+ registerOscHandler: (ident, handler) => this.addOsc(ident, handler),
89
+ registerCsiHandler: (id, handler) => this.csi.register(id, handler),
90
+ };
91
+ this.subscriptions = [
92
+ engine.onSemanticPrompt((event) => {
93
+ const mark = PROMPT_MARKS[event.kind];
94
+ const data =
95
+ event.kind === 'command-end' && event.exitCode !== undefined
96
+ ? `${mark};${event.exitCode}`
97
+ : mark;
98
+ this.fireOsc(133, data);
99
+ }),
100
+ engine.onPwdChange((pwd) => this.fireOsc(7, pwd)),
101
+ engine.onTitleChange((title) => this.titleEmitter.fire(title)),
102
+ engine.onUnknownOsc((content) => {
103
+ const parsed = unknownOsc(content);
104
+ if (parsed) this.fireOsc(parsed.ident, parsed.data);
105
+ }),
106
+ ];
107
+ this.lastAlternate = engine.isAlternateScreen();
108
+ }
109
+
110
+ get modes(): { bracketedPasteMode: boolean; mouseTrackingMode: MouseTrackingMode } {
111
+ return {
112
+ bracketedPasteMode: this.wasmTerm?.hasBracketedPaste() ?? false,
113
+ mouseTrackingMode: this.wasmTerm?.mouseTrackingMode() ?? 'none',
114
+ };
115
+ }
116
+
117
+ write(data: string | Uint8Array, callback?: () => void): void {
118
+ const engine = this.wasmTerm;
119
+ if (!engine || this.disposed) {
120
+ if (callback) queueMicrotask(callback);
121
+ return;
122
+ }
123
+ const bytes = typeof data === 'string' ? this.encoder.encode(data) : data;
124
+ let written = 0;
125
+ let lines = 0;
126
+ const flushTo = (end: number): void => {
127
+ if (end <= written) return;
128
+ engine.write(bytes.subarray(written, end));
129
+ written = end;
130
+ this.afterChunk();
131
+ };
132
+ const watching = this.csi.active;
133
+ for (let i = 0; i < bytes.length; i++) {
134
+ const byte = bytes[i];
135
+ if (watching) this.csi.step(byte, i, flushTo);
136
+ if (byte === LINE_FEED && ++lines % LINES_PER_SLICE === 0) flushTo(i + 1);
137
+ if (this.disposed) return;
138
+ }
139
+ flushTo(bytes.length);
140
+ this.writeParsedEmitter.fire();
141
+ if (callback) queueMicrotask(callback);
142
+ }
143
+
144
+ resize(cols: number, rows: number): void {
145
+ const engine = this.wasmTerm;
146
+ if (!engine || (cols === this.cols && rows === this.rows)) return;
147
+ this.cols = cols;
148
+ this.rows = rows;
149
+ engine.resize(cols, rows);
150
+ this.refreshMarkers(engine.rowsDiscarded());
151
+ }
152
+
153
+ registerMarker(cursorYOffset = 0): IMarker | undefined {
154
+ const engine = this.wasmTerm;
155
+ if (!engine) return undefined;
156
+ const y = engine.cursorPosition().y + cursorYOffset;
157
+ if (y < 0 || y >= this.rows) return undefined;
158
+ if (engine.rowsDiscarded()) this.refreshMarkers(true);
159
+ const row = engine.trackRow(y);
160
+ if (!row) return undefined;
161
+ const marker = new Marker(row);
162
+ this.markers.add(marker);
163
+ marker.onDispose(() => this.markers.delete(marker));
164
+ return marker;
165
+ }
166
+
167
+ dispose(): void {
168
+ if (this.disposed) return;
169
+ this.disposed = true;
170
+ for (const sub of this.subscriptions) sub.dispose();
171
+ for (const marker of [...this.markers]) marker.dispose();
172
+ this.markers.clear();
173
+ this.oscRoutes.clear();
174
+ this.scrollEmitter.dispose();
175
+ this.writeParsedEmitter.dispose();
176
+ this.titleEmitter.dispose();
177
+ this.wasmTerm?.free();
178
+ this.wasmTerm = undefined;
179
+ }
180
+
181
+ private addOsc(ident: number, handler: OscHandler): IDisposable {
182
+ const set = this.oscRoutes.get(ident) ?? new Set<OscHandler>();
183
+ set.add(handler);
184
+ this.oscRoutes.set(ident, set);
185
+ return { dispose: () => set.delete(handler) };
186
+ }
187
+
188
+ private fireOsc(ident: number, data: string): void {
189
+ for (const handler of [...(this.oscRoutes.get(ident) ?? [])]) void handler(data);
190
+ }
191
+
192
+ private afterChunk(): void {
193
+ const engine = this.wasmTerm;
194
+ if (!engine) return;
195
+ while (engine.hasResponse()) engine.readResponse();
196
+ const alternate = engine.isAlternateScreen();
197
+ const switched = alternate !== this.lastAlternate;
198
+ this.lastAlternate = alternate;
199
+ if (switched) {
200
+ (this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
201
+ }
202
+ const discarded = engine.rowsDiscarded();
203
+ this.refreshMarkers(switched || discarded);
204
+ const base = engine.getScrollbackLength();
205
+ if (!alternate && (base !== this.lastBase || discarded)) {
206
+ this.lastBase = base;
207
+ this.scrollEmitter.fire(base);
208
+ }
209
+ }
210
+
211
+ private refreshMarkers(needed: boolean): void {
212
+ if (!needed) return;
213
+ for (const marker of [...this.markers]) marker.refresh();
214
+ }
215
+ }
package/lib/index.ts CHANGED
@@ -56,6 +56,9 @@ export function getGhostty(): Ghostty {
56
56
 
57
57
  // Main Terminal class
58
58
  export { Terminal } from './terminal';
59
+ export { HeadlessTerminal } from './headless';
60
+ export type { HeadlessParser, HeadlessTerminalOptions, OscHandler } from './headless';
61
+ export type { CsiHandler, CsiIdentifier, CsiParams } from './csi-observer';
59
62
 
60
63
  // xterm.js-compatible interfaces
61
64
  export type {
@@ -65,6 +68,10 @@ export type {
65
68
  ITerminalCore,
66
69
  IDisposable,
67
70
  IEvent,
71
+ IBuffer,
72
+ IBufferCell,
73
+ IBufferLine,
74
+ IBufferNamespace,
68
75
  IBufferRange,
69
76
  IKeyEvent,
70
77
  IUnicodeVersionProvider,
@@ -88,7 +95,7 @@ export type {
88
95
  } from './ghostty';
89
96
  export type { IMarker } from './marker';
90
97
  export { Abi, GhosttyCallError } from './abi';
91
- export { Key, KeyAction, Mods } from './types';
98
+ export { ColorKind, Key, KeyAction, Mods } from './types';
92
99
  export type { KeyEvent, GhosttyCell, RGB, Cursor, TerminalHandle } from './types';
93
100
 
94
101
  // Low-level components (for custom integrations)
package/lib/interfaces.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { Ghostty } from './ghostty';
6
+ import type { ColorKind } from './types';
6
7
 
7
8
  export interface ITerminalOptions {
8
9
  cols?: number; // Default: 80
@@ -222,6 +223,15 @@ export interface IBufferCell {
222
223
  /** Background RGB color (or 0 for default) */
223
224
  getBgColor(): number;
224
225
 
226
+ /** How the foreground was set: default, a palette entry or RGB */
227
+ getFgKind(): ColorKind;
228
+ /** How the background was set: default, a palette entry or RGB */
229
+ getBgKind(): ColorKind;
230
+ /** The foreground palette entry, when getFgKind() is PALETTE */
231
+ getFgPaletteIndex(): number;
232
+ /** The background palette entry, when getBgKind() is PALETTE */
233
+ getBgPaletteIndex(): number;
234
+
225
235
  /** Whether cell has bold style */
226
236
  isBold(): number;
227
237
  /** Whether cell has italic style */
package/lib/renderer.ts CHANGED
@@ -268,8 +268,6 @@ export abstract class TerminalRenderer {
268
268
  private readonly highlightRows = new Set<number>();
269
269
  protected highlightColors: SearchHighlightColors = { match: '#5c6266', active: '#538bb5' };
270
270
 
271
- protected hoveredHyperlinkId = 0;
272
- private previousHoveredHyperlinkId = 0;
273
271
  protected hoveredLinkRange: LinkRange | null = null;
274
272
  private previousHoveredLinkRange: LinkRange | null = null;
275
273
 
@@ -385,22 +383,6 @@ export abstract class TerminalRenderer {
385
383
  };
386
384
 
387
385
  const hyperlinkRows = new Set<number>();
388
- if (this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId) {
389
- for (let y = 0; y < dims.rows; y++) {
390
- const line = lineAt(y);
391
- if (!line) continue;
392
- for (const cell of line) {
393
- if (
394
- cell.hyperlink_id === this.hoveredHyperlinkId ||
395
- cell.hyperlink_id === this.previousHoveredHyperlinkId
396
- ) {
397
- hyperlinkRows.add(y);
398
- break;
399
- }
400
- }
401
- }
402
- this.previousHoveredHyperlinkId = this.hoveredHyperlinkId;
403
- }
404
386
  if (!sameRange(this.hoveredLinkRange, this.previousHoveredLinkRange)) {
405
387
  for (const range of [this.previousHoveredLinkRange, this.hoveredLinkRange]) {
406
388
  if (!range) continue;
@@ -511,9 +493,8 @@ export abstract class TerminalRenderer {
511
493
  /** Called when the search highlight colors change. */
512
494
  protected searchColorsChanged(): void {}
513
495
 
514
- /** Whether the cell has a hovered link under it (OSC 8 id or a detected URL range). */
515
- protected linkHovered(cell: GhosttyCell, x: number, y: number): boolean {
516
- if (cell.hyperlink_id > 0 && cell.hyperlink_id === this.hoveredHyperlinkId) return true;
496
+ /** Whether the cell has a hovered link under it (OSC 8 or detected URL range). */
497
+ protected linkHovered(x: number, y: number): boolean {
517
498
  const range = this.hoveredLinkRange;
518
499
  if (!range) return false;
519
500
  return (
@@ -676,14 +657,6 @@ export abstract class TerminalRenderer {
676
657
  this.selectionManager = manager;
677
658
  }
678
659
 
679
- public setHoveredHyperlinkId(hyperlinkId: number): void {
680
- this.hoveredHyperlinkId = hyperlinkId;
681
- }
682
-
683
- public getHoveredHyperlinkId(): number {
684
- return this.hoveredHyperlinkId;
685
- }
686
-
687
660
  /** Set the hovered range of a detected URL, or null to clear it. */
688
661
  public setHoveredLinkRange(range: LinkRange | null): void {
689
662
  this.hoveredLinkRange = range;
@@ -1006,7 +979,7 @@ export class CanvasRenderer extends TerminalRenderer {
1006
979
  private renderDecorations(cell: GhosttyCell, x: number, y: number, color: string): void {
1007
980
  const underline = cell.flags & CellFlags.UNDERLINE;
1008
981
  const strike = cell.flags & CellFlags.STRIKETHROUGH;
1009
- const link = (cell.hyperlink_id > 0 || this.hoveredLinkRange) && this.linkHovered(cell, x, y);
982
+ const link = this.hoveredLinkRange && this.linkHovered(x, y);
1010
983
  if (!underline && !strike && !link) return;
1011
984
  const cellX = x * this.metrics.width;
1012
985
  const cellY = y * this.metrics.height;
package/lib/terminal.ts CHANGED
@@ -660,10 +660,8 @@ export class Terminal implements ITerminalCore {
660
660
  /** Disposes the current renderer and draws with `renderer` from now on, in its state. */
661
661
  private installRenderer(renderer: TerminalRenderer): void {
662
662
  const old = this.renderer!;
663
- const hoveredLink = old.getHoveredHyperlinkId();
664
663
  old.dispose();
665
664
  renderer.onNeedsFrame = () => this.requestFrame();
666
- renderer.setHoveredHyperlinkId(hoveredLink);
667
665
  renderer.setFocused(this.focused);
668
666
  renderer.setSearchHighlights(this.searchHighlights.list, this.searchHighlights.colors);
669
667
  if (this.selectionManager) {
@@ -1545,47 +1543,7 @@ export class Terminal implements ITerminalCore {
1545
1543
  const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth);
1546
1544
  const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight);
1547
1545
 
1548
- // Get hyperlink_id directly from the cell at this position
1549
- // Must account for viewportY (scrollback position)
1550
- const viewportRow = y; // Row in the viewport (0 to rows-1)
1551
- let hyperlinkId = 0;
1552
-
1553
- // When scrolled, fetch from scrollback or screen based on position
1554
- // NOTE: viewportY may be fractional during smooth scrolling. The renderer
1555
- // uses Math.floor(viewportY) when mapping viewport rows to scrollback vs
1556
- // screen; we mirror that logic here so link hit-testing matches what the
1557
- // user sees on screen.
1558
- let line: GhosttyCell[] | null = null;
1559
- const rawViewportY = this.getViewportY();
1560
- const viewportY = Math.max(0, Math.floor(rawViewportY));
1561
- if (viewportY > 0) {
1562
- const scrollbackLength = this.wasmTerm.getScrollbackLength();
1563
- if (viewportRow < viewportY) {
1564
- // Mouse is over scrollback content
1565
- const scrollbackOffset = scrollbackLength - viewportY + viewportRow;
1566
- line = this.wasmTerm.getScrollbackLine(scrollbackOffset);
1567
- } else {
1568
- // Mouse is over screen content (bottom part of viewport)
1569
- const screenRow = viewportRow - viewportY;
1570
- line = this.wasmTerm.getLine(screenRow);
1571
- }
1572
- } else {
1573
- // At bottom - just use screen buffer
1574
- line = this.wasmTerm.getLine(viewportRow);
1575
- }
1576
-
1577
- if (line && x >= 0 && x < line.length) {
1578
- hyperlinkId = line[x].hyperlink_id;
1579
- }
1580
-
1581
- // Update renderer for underline rendering
1582
- const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0;
1583
- if (hyperlinkId !== previousHyperlinkId) {
1584
- this.renderer.setHoveredHyperlinkId(hyperlinkId);
1585
-
1586
- // The 60fps render loop will pick up the change automatically
1587
- // No need to force a render - this keeps performance smooth
1588
- }
1546
+ const viewportRow = y;
1589
1547
 
1590
1548
  // Check if there's a link at this position (for click handling and cursor)
1591
1549
  // Buffer API expects absolute buffer coordinates (including scrollback)
@@ -1678,12 +1636,6 @@ export class Terminal implements ITerminalCore {
1678
1636
  this.wake();
1679
1637
  // Clear hyperlink underline
1680
1638
  if (this.renderer && this.wasmTerm) {
1681
- const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0;
1682
- if (previousHyperlinkId > 0) {
1683
- this.renderer.setHoveredHyperlinkId(0);
1684
-
1685
- // The 60fps render loop will pick up the change automatically
1686
- }
1687
1639
  // Clear regex link underline
1688
1640
  this.renderer.setHoveredLinkRange(null);
1689
1641
  }
package/lib/types.ts CHANGED
@@ -418,6 +418,16 @@ export interface GhosttyCell {
418
418
  width: number; // u8 (character width: 1=normal, 2=wide, etc.)
419
419
  hyperlink_id: number; // u16 (0 = no link, >0 = hyperlink ID in set)
420
420
  grapheme_len: number; // u8 (number of extra codepoints beyond first)
421
+ fg_kind: ColorKind; // how the foreground was set: default, a palette entry or RGB
422
+ bg_kind: ColorKind;
423
+ fg_index: number; // palette entry when fg_kind is PALETTE
424
+ bg_index: number;
425
+ }
426
+
427
+ export enum ColorKind {
428
+ DEFAULT = 0,
429
+ PALETTE = 1,
430
+ RGB = 2,
421
431
  }
422
432
 
423
433
  /**
@@ -610,7 +610,7 @@ export class WebglRenderer extends TerminalRenderer {
610
610
  }
611
611
  }
612
612
 
613
- const link = (cell.hyperlink_id > 0 || this.hoveredLinkRange) && this.linkHovered(cell, x, y);
613
+ const link = this.hoveredLinkRange && this.linkHovered(x, y);
614
614
  if (link || cell.flags & CellFlags.UNDERLINE) {
615
615
  rects[deco1] = left;
616
616
  rects[deco1 + 1] = underlineY;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurigax-ai/ghostty-web",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Ghostty's terminal engine (libghostty-vt) in the browser: WebAssembly build, an xterm.js-shaped API and WebGL and canvas renderers. A hard fork of coder/ghostty-web.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.ts",