@aurigax-ai/ghostty-web 0.3.7 → 0.3.9

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,
@@ -230,8 +232,8 @@ export interface TrackedRow {
230
232
  const POINT_ACTIVE = 'ACTIVE';
231
233
  const POINT_HISTORY = 'HISTORY';
232
234
  const POINT_SCREEN = 'SCREEN';
233
- type PointSpace = typeof POINT_ACTIVE | typeof POINT_HISTORY;
234
- type PointTag = PointSpace | typeof POINT_SCREEN;
235
+ type PointSpace = typeof POINT_ACTIVE | typeof POINT_HISTORY | typeof POINT_SCREEN;
236
+ type PointTag = PointSpace;
235
237
 
236
238
  const GRAPHEME_CAP = 16;
237
239
  const GRAPHEME_CLUSTER_MODE = 2027;
@@ -1035,6 +1037,61 @@ export class GhosttyTerminal {
1035
1037
  return out;
1036
1038
  }
1037
1039
 
1040
+ /**
1041
+ * The rows `from` to `to` (0 is the oldest scrollback row) of the screen that
1042
+ * is showing, as text with its colours and styles as escape sequences. A
1043
+ * wrapped line comes back as one line, lines end in CR LF, and empty rows at
1044
+ * the end are dropped. One call into the engine, whatever the number of rows.
1045
+ */
1046
+ formatRows(from: number, to: number): string {
1047
+ const abi = this.abi;
1048
+ const optionsSize = abi.sizeOf('GhosttyFormatterTerminalOptions');
1049
+ const selectionSize = abi.sizeOf('GhosttySelection');
1050
+ const refSize = abi.sizeOf('GhosttyGridRef');
1051
+ const lastCol = this._cols - 1;
1052
+ return abi.with(optionsSize + selectionSize, (base) => {
1053
+ const options = base;
1054
+ const selection = base + optionsSize;
1055
+ const O = (field: string) => options + abi.offset('GhosttyFormatterTerminalOptions', field);
1056
+ const S = (field: string) => selection + abi.offset('GhosttySelection', field);
1057
+ abi.view().setUint32(selection, selectionSize, true);
1058
+ const place = (field: string, x: number, y: number) =>
1059
+ this.gridRef(POINT_SCREEN, x, y, (ref) => {
1060
+ abi.bytes().copyWithin(S(field), ref, ref + refSize);
1061
+ return true;
1062
+ });
1063
+ if (!place('start', 0, from) || !place('end', lastCol, to)) return '';
1064
+ const view = abi.view();
1065
+ view.setUint32(O('size'), optionsSize, true);
1066
+ view.setInt32(O('emit'), abi.enumValue('GhosttyFormatterFormat', 'VT'), true);
1067
+ view.setUint8(O('unwrap'), 1);
1068
+ view.setUint8(O('trim'), 1);
1069
+ view.setUint32(O('extra'), abi.sizeOf('GhosttyFormatterTerminalExtra'), true);
1070
+ view.setUint32(O('selection'), selection, true);
1071
+ const slot = abi.call('ghostty_wasm_alloc_opaque');
1072
+ try {
1073
+ abi.check('ghostty_formatter_terminal_new', 0, slot, this.handle, options);
1074
+ const formatter = abi.call('ghostty_wasm_take_opaque', slot);
1075
+ try {
1076
+ const written = abi.scratch(4);
1077
+ const probe = abi.call('ghostty_formatter_format_buf', formatter, 0, 0, written);
1078
+ if (probe === GHOSTTY_SUCCESS) return '';
1079
+ if (probe !== GHOSTTY_OUT_OF_SPACE)
1080
+ throw new GhosttyCallError('ghostty_formatter_format_buf', probe);
1081
+ const need = abi.view().getUint32(written, true);
1082
+ return abi.with(need, (buf) => {
1083
+ abi.check('ghostty_formatter_format_buf', formatter, buf, need, written);
1084
+ return abi.string(buf, abi.view().getUint32(written, true));
1085
+ });
1086
+ } finally {
1087
+ abi.call('ghostty_formatter_free', formatter);
1088
+ }
1089
+ } finally {
1090
+ abi.call('ghostty_wasm_free_opaque', slot);
1091
+ }
1092
+ });
1093
+ }
1094
+
1038
1095
  getGrapheme(row: number, col: number): number[] | null {
1039
1096
  return this.gridRef(POINT_ACTIVE, col, row, (ref) => this.graphemesAt(ref)) ?? null;
1040
1097
  }
@@ -1402,9 +1459,27 @@ export class GhosttyTerminal {
1402
1459
  let fg = this.colors.foreground;
1403
1460
  let bg = this.colors.background;
1404
1461
  let flags = 0;
1462
+ let fgKind = ColorKind.DEFAULT;
1463
+ let bgKind = ColorKind.DEFAULT;
1464
+ let fgIndex = 0;
1465
+ let bgIndex = 0;
1405
1466
  if (styleId !== 0) {
1406
1467
  fg = this.styleColor(view, stylePtr + L.fgColor) ?? fg;
1407
1468
  bg = this.styleColor(view, stylePtr + L.bgColor) ?? bg;
1469
+ const fgTag = view.getInt32(stylePtr + L.fgColor + L.colorTag, true);
1470
+ const bgTag = view.getInt32(stylePtr + L.bgColor + L.colorTag, true);
1471
+ if (fgTag === L.colorPalette) {
1472
+ fgKind = ColorKind.PALETTE;
1473
+ fgIndex = view.getUint8(stylePtr + L.fgColor + L.colorValue);
1474
+ } else if (fgTag === L.colorRgb) {
1475
+ fgKind = ColorKind.RGB;
1476
+ }
1477
+ if (bgTag === L.colorPalette) {
1478
+ bgKind = ColorKind.PALETTE;
1479
+ bgIndex = view.getUint8(stylePtr + L.bgColor + L.colorValue);
1480
+ } else if (bgTag === L.colorRgb) {
1481
+ bgKind = ColorKind.RGB;
1482
+ }
1408
1483
  if (view.getUint8(stylePtr + L.bold)) flags |= CellFlags.BOLD;
1409
1484
  if (view.getUint8(stylePtr + L.italic)) flags |= CellFlags.ITALIC;
1410
1485
  if (view.getInt32(stylePtr + L.underline, true) !== 0) flags |= CellFlags.UNDERLINE;
@@ -1416,9 +1491,16 @@ export class GhosttyTerminal {
1416
1491
  }
1417
1492
  if (tag === L.tagBgPalette) {
1418
1493
  bg = this.colors.palette[contentBits & 0xff] ?? bg;
1494
+ bgKind = ColorKind.PALETTE;
1495
+ bgIndex = contentBits & 0xff;
1419
1496
  } else if (tag === L.tagBgRgb) {
1420
1497
  bg = { r: contentBits & 0xff, g: (contentBits >> 8) & 0xff, b: (contentBits >> 16) & 0xff };
1498
+ bgKind = ColorKind.RGB;
1421
1499
  }
1500
+ cell.fg_kind = fgKind;
1501
+ cell.bg_kind = bgKind;
1502
+ cell.fg_index = fgIndex;
1503
+ cell.bg_index = bgIndex;
1422
1504
  cell.fg_r = fg.r;
1423
1505
  cell.fg_g = fg.g;
1424
1506
  cell.fg_b = fg.b;
@@ -1515,6 +1597,10 @@ export class GhosttyTerminal {
1515
1597
  width: 1,
1516
1598
  hyperlink_id: 0,
1517
1599
  grapheme_len: 0,
1600
+ fg_kind: ColorKind.DEFAULT,
1601
+ bg_kind: ColorKind.DEFAULT,
1602
+ fg_index: 0,
1603
+ bg_index: 0,
1518
1604
  };
1519
1605
  }
1520
1606
 
@@ -0,0 +1,244 @@
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
+ /**
154
+ * The logical lines of the rows `from` to `to` (0 is the oldest scrollback
155
+ * row) of the screen that is showing, each with its colours and styles as
156
+ * escape sequences. A wrapped line is one line, so the count is the number
157
+ * of rows that do not continue the row before them, empty rows at the end
158
+ * included. Costs one call into the engine, not one per row or cell.
159
+ */
160
+ serialize(range: { from: number; to: number }): string[] {
161
+ const engine = this.wasmTerm;
162
+ if (!engine || range.to < range.from) return [];
163
+ const text = engine.formatRows(range.from, range.to);
164
+ const printed = text === '' ? [] : text.split('\r\n');
165
+ const scrollback = engine.isAlternateScreen() ? 0 : engine.getScrollbackLength();
166
+ const wrapped = (y: number): boolean =>
167
+ y < scrollback ? engine.isScrollbackRowWrapped(y) : engine.isRowWrapped(y - scrollback);
168
+ const starts = [range.from];
169
+ for (let y = range.from + 1; y <= range.to; y++) if (!wrapped(y)) starts.push(y);
170
+ const lines: string[] = [];
171
+ let at = 0;
172
+ for (const [i, start] of starts.entries()) {
173
+ const rows = (starts[i + 1] ?? range.to + 1) - start;
174
+ let blank = 0;
175
+ while (blank < rows - 1 && engine.formatRows(start + blank, start + blank) === '') blank++;
176
+ at += blank;
177
+ lines.push(' '.repeat(blank * this.cols) + (printed[at++] ?? ''));
178
+ }
179
+ return lines;
180
+ }
181
+
182
+ registerMarker(cursorYOffset = 0): IMarker | undefined {
183
+ const engine = this.wasmTerm;
184
+ if (!engine) return undefined;
185
+ const y = engine.cursorPosition().y + cursorYOffset;
186
+ if (y < 0 || y >= this.rows) return undefined;
187
+ if (engine.rowsDiscarded()) this.refreshMarkers(true);
188
+ const row = engine.trackRow(y);
189
+ if (!row) return undefined;
190
+ const marker = new Marker(row);
191
+ this.markers.add(marker);
192
+ marker.onDispose(() => this.markers.delete(marker));
193
+ return marker;
194
+ }
195
+
196
+ dispose(): void {
197
+ if (this.disposed) return;
198
+ this.disposed = true;
199
+ for (const sub of this.subscriptions) sub.dispose();
200
+ for (const marker of [...this.markers]) marker.dispose();
201
+ this.markers.clear();
202
+ this.oscRoutes.clear();
203
+ this.scrollEmitter.dispose();
204
+ this.writeParsedEmitter.dispose();
205
+ this.titleEmitter.dispose();
206
+ this.wasmTerm?.free();
207
+ this.wasmTerm = undefined;
208
+ }
209
+
210
+ private addOsc(ident: number, handler: OscHandler): IDisposable {
211
+ const set = this.oscRoutes.get(ident) ?? new Set<OscHandler>();
212
+ set.add(handler);
213
+ this.oscRoutes.set(ident, set);
214
+ return { dispose: () => set.delete(handler) };
215
+ }
216
+
217
+ private fireOsc(ident: number, data: string): void {
218
+ for (const handler of [...(this.oscRoutes.get(ident) ?? [])]) void handler(data);
219
+ }
220
+
221
+ private afterChunk(): void {
222
+ const engine = this.wasmTerm;
223
+ if (!engine) return;
224
+ while (engine.hasResponse()) engine.readResponse();
225
+ const alternate = engine.isAlternateScreen();
226
+ const switched = alternate !== this.lastAlternate;
227
+ this.lastAlternate = alternate;
228
+ if (switched) {
229
+ (this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
230
+ }
231
+ const discarded = engine.rowsDiscarded();
232
+ this.refreshMarkers(switched || discarded);
233
+ const base = engine.getScrollbackLength();
234
+ if (!alternate && (base !== this.lastBase || discarded)) {
235
+ this.lastBase = base;
236
+ this.scrollEmitter.fire(base);
237
+ }
238
+ }
239
+
240
+ private refreshMarkers(needed: boolean): void {
241
+ if (!needed) return;
242
+ for (const marker of [...this.markers]) marker.refresh();
243
+ }
244
+ }
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)
@@ -850,6 +850,7 @@ export class InputHandler {
850
850
  private handleMouseDown(event: MouseEvent): void {
851
851
  if (this.isDisposed) return;
852
852
  if (!this.mouseConfig?.hasMouseTracking()) return;
853
+ if (event.shiftKey) return;
853
854
 
854
855
  const cell = this.pixelToCell(event);
855
856
  if (!cell) return;
@@ -875,6 +876,7 @@ export class InputHandler {
875
876
  private handleMouseUp(event: MouseEvent): void {
876
877
  if (this.isDisposed) return;
877
878
  if (!this.mouseConfig?.hasMouseTracking()) return;
879
+ if (event.shiftKey) return;
878
880
 
879
881
  const cell = this.pixelToCell(event);
880
882
  if (!cell) return;
@@ -895,6 +897,7 @@ export class InputHandler {
895
897
  private handleMouseMove(event: MouseEvent): void {
896
898
  if (this.isDisposed) return;
897
899
  if (!this.mouseConfig?.hasMouseTracking()) return;
900
+ if (event.shiftKey) return;
898
901
 
899
902
  // Check if button motion mode or any-event tracking is enabled
900
903
  // Mode 1002 = button motion, Mode 1003 = any motion
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/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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurigax-ai/ghostty-web",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
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",