@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 +17 -0
- package/lib/buffer.ts +71 -47
- package/lib/csi-observer.ts +123 -0
- package/lib/ghostty.ts +31 -0
- package/lib/headless.ts +215 -0
- package/lib/index.ts +8 -1
- package/lib/interfaces.ts +10 -0
- package/lib/renderer.ts +3 -30
- package/lib/terminal.ts +1 -49
- package/lib/types.ts +10 -0
- package/lib/webgl-renderer.ts +1 -1
- package/package.json +1 -1
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
115
|
+
private terminal: BufferHost;
|
|
91
116
|
private bufferType: 'normal' | 'alternate';
|
|
92
117
|
private nullCell: BufferCell;
|
|
93
118
|
|
|
94
|
-
constructor(terminal:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
package/lib/headless.ts
ADDED
|
@@ -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
|
|
515
|
-
protected linkHovered(
|
|
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 =
|
|
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
|
-
|
|
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
|
/**
|
package/lib/webgl-renderer.ts
CHANGED
|
@@ -610,7 +610,7 @@ export class WebglRenderer extends TerminalRenderer {
|
|
|
610
610
|
}
|
|
611
611
|
}
|
|
612
612
|
|
|
613
|
-
const link =
|
|
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.
|
|
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",
|