@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/GHOSTTY_COMMIT +1 -0
- package/LICENSE +22 -0
- package/README.md +90 -2
- package/ghostty-vt.wasm +0 -0
- package/lib/abi.ts +227 -0
- package/lib/addons/fit.ts +242 -0
- package/lib/buffer.ts +402 -0
- package/lib/event-emitter.ts +27 -0
- package/lib/ghostty.ts +1135 -0
- package/lib/index.ts +110 -0
- package/lib/input-handler.ts +1104 -0
- package/lib/interfaces.ts +218 -0
- package/lib/link-detector.ts +215 -0
- package/lib/marker.ts +55 -0
- package/lib/providers/osc8-link-provider.ts +144 -0
- package/lib/providers/url-regex-provider.ts +150 -0
- package/lib/renderer.ts +958 -0
- package/lib/selection-manager.ts +1052 -0
- package/lib/terminal.ts +2026 -0
- package/lib/types.ts +648 -0
- package/package.json +70 -4
package/lib/buffer.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffer API - xterm.js-compatible buffer access
|
|
3
|
+
*
|
|
4
|
+
* Provides read-only access to terminal buffer contents, cursor state,
|
|
5
|
+
* and viewport position. Wraps Ghostty WASM terminal state.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const cell = term.buffer.active.getLine(0)?.getCell(5);
|
|
10
|
+
* console.log(cell?.getChars(), cell?.isBold());
|
|
11
|
+
*
|
|
12
|
+
* if (term.buffer.active.type === 'alternate') {
|
|
13
|
+
* console.log('Full-screen app running');
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { EventEmitter } from './event-emitter';
|
|
19
|
+
import type { GhosttyTerminal } from './ghostty';
|
|
20
|
+
import { CellFlags } from './ghostty';
|
|
21
|
+
import type { IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IEvent } from './interfaces';
|
|
22
|
+
import type { Terminal } from './terminal';
|
|
23
|
+
import type { GhosttyCell } from './types';
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// BufferNamespace - Top-level buffer API
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Top-level buffer API namespace
|
|
31
|
+
* Provides access to active, normal, and alternate screen buffers
|
|
32
|
+
*/
|
|
33
|
+
export class BufferNamespace implements IBufferNamespace {
|
|
34
|
+
private terminal: Terminal;
|
|
35
|
+
private bufferChangeEmitter = new EventEmitter<IBuffer>();
|
|
36
|
+
|
|
37
|
+
// Lazy-initialized buffer wrappers (stateless, so we can cache them)
|
|
38
|
+
private _normalBuffer?: Buffer;
|
|
39
|
+
private _alternateBuffer?: Buffer;
|
|
40
|
+
|
|
41
|
+
constructor(terminal: Terminal) {
|
|
42
|
+
this.terminal = terminal;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get active(): IBuffer {
|
|
46
|
+
// Query WASM to determine which buffer is active
|
|
47
|
+
const wasmTerm = (this.terminal as any).wasmTerm as GhosttyTerminal | undefined;
|
|
48
|
+
if (!wasmTerm) {
|
|
49
|
+
return this.normal; // Default to normal if not initialized
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return wasmTerm.isAlternateScreen() ? this.alternate : this.normal;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get normal(): IBuffer {
|
|
56
|
+
if (!this._normalBuffer) {
|
|
57
|
+
this._normalBuffer = new Buffer(this.terminal, 'normal');
|
|
58
|
+
}
|
|
59
|
+
return this._normalBuffer;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get alternate(): IBuffer {
|
|
63
|
+
if (!this._alternateBuffer) {
|
|
64
|
+
this._alternateBuffer = new Buffer(this.terminal, 'alternate');
|
|
65
|
+
}
|
|
66
|
+
return this._alternateBuffer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get onBufferChange(): IEvent<IBuffer> {
|
|
70
|
+
return this.bufferChangeEmitter.event;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Internal: Fire buffer change event when screen switches
|
|
75
|
+
* Should be called by Terminal when detecting screen change
|
|
76
|
+
*/
|
|
77
|
+
_fireBufferChange(buffer: IBuffer): void {
|
|
78
|
+
this.bufferChangeEmitter.fire(buffer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Buffer - Represents a terminal buffer (normal or alternate)
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A terminal buffer (normal or alternate screen)
|
|
88
|
+
*/
|
|
89
|
+
export class Buffer implements IBuffer {
|
|
90
|
+
private terminal: Terminal;
|
|
91
|
+
private bufferType: 'normal' | 'alternate';
|
|
92
|
+
private nullCell: BufferCell;
|
|
93
|
+
|
|
94
|
+
constructor(terminal: Terminal, type: 'normal' | 'alternate') {
|
|
95
|
+
this.terminal = terminal;
|
|
96
|
+
this.bufferType = type;
|
|
97
|
+
|
|
98
|
+
// 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);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get type(): 'normal' | 'alternate' {
|
|
116
|
+
return this.bufferType;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read from the terminal itself, so it is exact even inside a hook that runs mid-write. */
|
|
120
|
+
get cursorX(): number {
|
|
121
|
+
const wasmTerm = this.getWasmTerm();
|
|
122
|
+
if (!wasmTerm) return 0;
|
|
123
|
+
return wasmTerm.cursorPosition().x;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get cursorY(): number {
|
|
127
|
+
const wasmTerm = this.getWasmTerm();
|
|
128
|
+
if (!wasmTerm) return 0;
|
|
129
|
+
return wasmTerm.cursorPosition().y;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The absolute line at the top of the viewport (baseY when scrolled to the bottom). */
|
|
133
|
+
get viewportY(): number {
|
|
134
|
+
if (this.bufferType === 'alternate') return 0;
|
|
135
|
+
const scrolledBack = Math.round((this.terminal as any).viewportY ?? 0);
|
|
136
|
+
return Math.max(0, this.baseY - scrolledBack);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The absolute line of the active area's top row: the scrollback length for the normal buffer. */
|
|
140
|
+
get baseY(): number {
|
|
141
|
+
if (this.bufferType === 'alternate') return 0;
|
|
142
|
+
const wasmTerm = this.getWasmTerm();
|
|
143
|
+
if (!wasmTerm) return 0;
|
|
144
|
+
return wasmTerm.getScrollbackLength();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get length(): number {
|
|
148
|
+
const wasmTerm = this.getWasmTerm();
|
|
149
|
+
if (!wasmTerm) return 0;
|
|
150
|
+
|
|
151
|
+
if (this.bufferType === 'alternate') {
|
|
152
|
+
// Alternate buffer has no scrollback, just visible rows
|
|
153
|
+
return wasmTerm.rows;
|
|
154
|
+
} else {
|
|
155
|
+
// Normal buffer: scrollback + visible rows
|
|
156
|
+
const scrollback = wasmTerm.getScrollbackLength();
|
|
157
|
+
return scrollback + wasmTerm.rows;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getLine(y: number): IBufferLine | undefined {
|
|
162
|
+
const wasmTerm = this.getWasmTerm();
|
|
163
|
+
if (!wasmTerm) return undefined;
|
|
164
|
+
|
|
165
|
+
// Check bounds
|
|
166
|
+
if (y < 0 || y >= this.length) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Determine if accessing scrollback or visible screen
|
|
171
|
+
const scrollbackLength = wasmTerm.getScrollbackLength();
|
|
172
|
+
let cells: GhosttyCell[] | null;
|
|
173
|
+
let lineNumber: number;
|
|
174
|
+
let isWrapped: boolean;
|
|
175
|
+
|
|
176
|
+
if (this.bufferType === 'normal' && y < scrollbackLength) {
|
|
177
|
+
// Accessing scrollback
|
|
178
|
+
// WASM getScrollbackLine: offset 0 = oldest, offset (length-1) = newest
|
|
179
|
+
// Buffer coords: y=0 = oldest, y=(length-1) = newest
|
|
180
|
+
// So scrollbackOffset = y directly!
|
|
181
|
+
const scrollbackOffset = y;
|
|
182
|
+
cells = wasmTerm.getScrollbackLine(scrollbackOffset);
|
|
183
|
+
// TODO: We'd need WASM API to check if scrollback line is wrapped
|
|
184
|
+
// For now, assume not wrapped
|
|
185
|
+
isWrapped = false;
|
|
186
|
+
} else {
|
|
187
|
+
// Accessing visible screen
|
|
188
|
+
lineNumber = this.bufferType === 'normal' ? y - scrollbackLength : y;
|
|
189
|
+
cells = wasmTerm.getLine(lineNumber);
|
|
190
|
+
isWrapped = wasmTerm.isRowWrapped(lineNumber);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!cells) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return new BufferLine(cells, isWrapped, wasmTerm.cols);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
getNullCell(): IBufferCell {
|
|
201
|
+
return this.nullCell;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private getWasmTerm(): GhosttyTerminal | undefined {
|
|
205
|
+
return (this.terminal as any).wasmTerm as GhosttyTerminal | undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// BufferLine - Represents a single line in the buffer
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A single line in the buffer
|
|
215
|
+
*/
|
|
216
|
+
export class BufferLine implements IBufferLine {
|
|
217
|
+
private cells: GhosttyCell[];
|
|
218
|
+
private _isWrapped: boolean;
|
|
219
|
+
private _length: number;
|
|
220
|
+
|
|
221
|
+
constructor(cells: GhosttyCell[], isWrapped: boolean, length: number) {
|
|
222
|
+
this.cells = cells;
|
|
223
|
+
this._isWrapped = isWrapped;
|
|
224
|
+
this._length = length;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
get length(): number {
|
|
228
|
+
return this._length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
get isWrapped(): boolean {
|
|
232
|
+
return this._isWrapped;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
getCell(x: number): IBufferCell | undefined {
|
|
236
|
+
if (x < 0 || x >= this._length) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (x >= this.cells.length) {
|
|
241
|
+
// Cell beyond what was returned (empty/null cell)
|
|
242
|
+
return new BufferCell(
|
|
243
|
+
{
|
|
244
|
+
codepoint: 0,
|
|
245
|
+
fg_r: 204,
|
|
246
|
+
fg_g: 204,
|
|
247
|
+
fg_b: 204,
|
|
248
|
+
bg_r: 0,
|
|
249
|
+
bg_g: 0,
|
|
250
|
+
bg_b: 0,
|
|
251
|
+
flags: 0,
|
|
252
|
+
width: 1,
|
|
253
|
+
hyperlink_id: 0,
|
|
254
|
+
grapheme_len: 0,
|
|
255
|
+
},
|
|
256
|
+
x
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return new BufferCell(this.cells[x], x);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
translateToString(trimRight = false, startColumn = 0, endColumn = this._length): string {
|
|
264
|
+
// Clamp bounds
|
|
265
|
+
const start = Math.max(0, Math.min(startColumn, this._length));
|
|
266
|
+
const end = Math.max(start, Math.min(endColumn, this._length));
|
|
267
|
+
|
|
268
|
+
let result = '';
|
|
269
|
+
for (let x = start; x < end; x++) {
|
|
270
|
+
const cell = this.getCell(x);
|
|
271
|
+
if (cell) {
|
|
272
|
+
const chars = cell.getChars();
|
|
273
|
+
result += chars;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (trimRight) {
|
|
278
|
+
result = result.trimEnd();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ============================================================================
|
|
286
|
+
// BufferCell - Represents a single cell in the buffer
|
|
287
|
+
// ============================================================================
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A single cell in the buffer
|
|
291
|
+
*/
|
|
292
|
+
export class BufferCell implements IBufferCell {
|
|
293
|
+
private cell: GhosttyCell;
|
|
294
|
+
|
|
295
|
+
constructor(cell: GhosttyCell, _x: number) {
|
|
296
|
+
this.cell = cell;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
getChars(): string {
|
|
300
|
+
const codepoint = this.cell.codepoint;
|
|
301
|
+
|
|
302
|
+
// Return empty string for null character or invalid codepoints
|
|
303
|
+
if (codepoint === 0) {
|
|
304
|
+
return '';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Validate codepoint is within valid Unicode range
|
|
308
|
+
// Valid: 0x0000 to 0x10FFFF, excluding surrogates (0xD800-0xDFFF)
|
|
309
|
+
if (codepoint < 0 || codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)) {
|
|
310
|
+
// Return replacement character for invalid codepoints
|
|
311
|
+
return '\uFFFD';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return String.fromCodePoint(codepoint);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
getCode(): number {
|
|
318
|
+
return this.cell.codepoint;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
getWidth(): number {
|
|
322
|
+
return this.cell.width;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
getFgColorMode(): number {
|
|
326
|
+
// Return -1 for RGB (we always use RGB in our WASM implementation)
|
|
327
|
+
// xterm.js uses different values:
|
|
328
|
+
// 0 = default, 1 = palette 16, 2 = palette 256, 3 = RGB
|
|
329
|
+
// For simplicity, we return -1 for RGB
|
|
330
|
+
return -1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
getBgColorMode(): number {
|
|
334
|
+
return -1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
getFgColor(): number {
|
|
338
|
+
// Pack RGB into a single number: 0xRRGGBB
|
|
339
|
+
return (this.cell.fg_r << 16) | (this.cell.fg_g << 8) | this.cell.fg_b;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
getBgColor(): number {
|
|
343
|
+
// Pack RGB into a single number: 0xRRGGBB
|
|
344
|
+
return (this.cell.bg_r << 16) | (this.cell.bg_g << 8) | this.cell.bg_b;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
isBold(): number {
|
|
348
|
+
return (this.cell.flags & CellFlags.BOLD) !== 0 ? 1 : 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
isItalic(): number {
|
|
352
|
+
return (this.cell.flags & CellFlags.ITALIC) !== 0 ? 1 : 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
isUnderline(): number {
|
|
356
|
+
return (this.cell.flags & CellFlags.UNDERLINE) !== 0 ? 1 : 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
isStrikethrough(): number {
|
|
360
|
+
return (this.cell.flags & CellFlags.STRIKETHROUGH) !== 0 ? 1 : 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
isBlink(): number {
|
|
364
|
+
return (this.cell.flags & CellFlags.BLINK) !== 0 ? 1 : 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
isInverse(): number {
|
|
368
|
+
return (this.cell.flags & CellFlags.INVERSE) !== 0 ? 1 : 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
isInvisible(): number {
|
|
372
|
+
return (this.cell.flags & CellFlags.INVISIBLE) !== 0 ? 1 : 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
isFaint(): number {
|
|
376
|
+
return (this.cell.flags & CellFlags.FAINT) !== 0 ? 1 : 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Get hyperlink ID for this cell (0 = no link)
|
|
381
|
+
* Used by link detection system
|
|
382
|
+
*/
|
|
383
|
+
getHyperlinkId(): number {
|
|
384
|
+
return this.cell.hyperlink_id;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Get the Unicode codepoint for this cell
|
|
389
|
+
* Used by link detection system
|
|
390
|
+
*/
|
|
391
|
+
getCodepoint(): number {
|
|
392
|
+
return this.cell.codepoint;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Check if cell has dim/faint attribute
|
|
397
|
+
* Added for IBufferCell compatibility
|
|
398
|
+
*/
|
|
399
|
+
isDim(): boolean {
|
|
400
|
+
return (this.cell.flags & CellFlags.FAINT) !== 0;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IEvent } from './interfaces';
|
|
2
|
+
|
|
3
|
+
export class EventEmitter<T> {
|
|
4
|
+
private listeners: Array<(arg: T) => void> = [];
|
|
5
|
+
|
|
6
|
+
fire(arg: T): void {
|
|
7
|
+
for (const listener of this.listeners) {
|
|
8
|
+
listener(arg);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
event: IEvent<T> = (listener) => {
|
|
13
|
+
this.listeners.push(listener);
|
|
14
|
+
return {
|
|
15
|
+
dispose: () => {
|
|
16
|
+
const index = this.listeners.indexOf(listener);
|
|
17
|
+
if (index >= 0) {
|
|
18
|
+
this.listeners.splice(index, 1);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
dispose(): void {
|
|
25
|
+
this.listeners = [];
|
|
26
|
+
}
|
|
27
|
+
}
|