@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/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
+ }