@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 +17 -0
- package/lib/buffer.ts +71 -47
- package/lib/csi-observer.ts +123 -0
- package/lib/ghostty.ts +88 -2
- package/lib/headless.ts +244 -0
- package/lib/index.ts +8 -1
- package/lib/input-handler.ts +3 -0
- package/lib/interfaces.ts +10 -0
- package/lib/types.ts +10 -0
- 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,
|
|
@@ -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
|
|
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
|
|
package/lib/headless.ts
ADDED
|
@@ -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)
|
package/lib/input-handler.ts
CHANGED
|
@@ -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.
|
|
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",
|