@aurigax-ai/ghostty-web 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GHOSTTY_COMMIT +1 -0
- package/LICENSE +22 -0
- package/README.md +94 -2
- package/ghostty-vt.wasm +0 -0
- package/lib/abi.ts +227 -0
- package/lib/addons/fit.ts +242 -0
- package/lib/box-glyphs.ts +278 -0
- package/lib/buffer.ts +402 -0
- package/lib/event-emitter.ts +27 -0
- package/lib/ghostty.ts +1253 -0
- package/lib/index.ts +111 -0
- package/lib/input-handler.ts +1116 -0
- package/lib/interfaces.ts +224 -0
- package/lib/link-detector.ts +215 -0
- package/lib/marker.ts +55 -0
- package/lib/providers/osc8-link-provider.ts +144 -0
- package/lib/providers/url-regex-provider.ts +150 -0
- package/lib/renderer.ts +820 -0
- package/lib/selection-manager.ts +1057 -0
- package/lib/terminal.ts +2054 -0
- package/lib/types.ts +648 -0
- package/lib/webgl-renderer.ts +870 -0
- package/package.json +70 -4
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xterm.js-compatible interfaces
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Ghostty } from './ghostty';
|
|
6
|
+
|
|
7
|
+
export interface ITerminalOptions {
|
|
8
|
+
cols?: number; // Default: 80
|
|
9
|
+
rows?: number; // Default: 24
|
|
10
|
+
cursorBlink?: boolean; // Default: false
|
|
11
|
+
cursorStyle?: 'block' | 'underline' | 'bar';
|
|
12
|
+
theme?: ITheme;
|
|
13
|
+
scrollback?: number; // Default: 10000
|
|
14
|
+
fontSize?: number; // Default: 15
|
|
15
|
+
fontFamily?: string; // Default: 'monospace'
|
|
16
|
+
allowTransparency?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* How the terminal draws, read when it opens. 'webgl' draws on the GPU and
|
|
19
|
+
* falls back to 'canvas' when WebGL 2 is unavailable or its context is lost.
|
|
20
|
+
* Default: 'webgl'.
|
|
21
|
+
*/
|
|
22
|
+
renderer?: 'webgl' | 'canvas';
|
|
23
|
+
|
|
24
|
+
// Phase 1 additions
|
|
25
|
+
convertEol?: boolean; // Convert \n to \r\n (default: false)
|
|
26
|
+
disableStdin?: boolean; // Disable keyboard input (default: false)
|
|
27
|
+
|
|
28
|
+
// Scrolling options
|
|
29
|
+
smoothScrollDuration?: number; // Duration in ms for smooth scroll animation (default: 100, 0 = instant)
|
|
30
|
+
|
|
31
|
+
// Internal: Ghostty WASM instance (optional, for test isolation)
|
|
32
|
+
// If not provided, uses the module-level instance from init()
|
|
33
|
+
ghostty?: Ghostty;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ITheme {
|
|
37
|
+
foreground?: string;
|
|
38
|
+
background?: string;
|
|
39
|
+
cursor?: string;
|
|
40
|
+
cursorAccent?: string;
|
|
41
|
+
selectionBackground?: string;
|
|
42
|
+
selectionForeground?: string;
|
|
43
|
+
|
|
44
|
+
// ANSI colors (0-15)
|
|
45
|
+
black?: string;
|
|
46
|
+
red?: string;
|
|
47
|
+
green?: string;
|
|
48
|
+
yellow?: string;
|
|
49
|
+
blue?: string;
|
|
50
|
+
magenta?: string;
|
|
51
|
+
cyan?: string;
|
|
52
|
+
white?: string;
|
|
53
|
+
brightBlack?: string;
|
|
54
|
+
brightRed?: string;
|
|
55
|
+
brightGreen?: string;
|
|
56
|
+
brightYellow?: string;
|
|
57
|
+
brightBlue?: string;
|
|
58
|
+
brightMagenta?: string;
|
|
59
|
+
brightCyan?: string;
|
|
60
|
+
brightWhite?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface IDisposable {
|
|
64
|
+
dispose(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type IEvent<T> = (listener: (arg: T) => void) => IDisposable;
|
|
68
|
+
|
|
69
|
+
export interface ITerminalAddon {
|
|
70
|
+
activate(terminal: ITerminalCore): void;
|
|
71
|
+
dispose(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ITerminalCore {
|
|
75
|
+
cols: number;
|
|
76
|
+
rows: number;
|
|
77
|
+
element?: HTMLElement;
|
|
78
|
+
textarea?: HTMLTextAreaElement;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Buffer range for selection coordinates
|
|
83
|
+
*/
|
|
84
|
+
export interface IBufferRange {
|
|
85
|
+
start: { x: number; y: number };
|
|
86
|
+
end: { x: number; y: number };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Keyboard event with key and DOM event
|
|
91
|
+
*/
|
|
92
|
+
export interface IKeyEvent {
|
|
93
|
+
key: string;
|
|
94
|
+
domEvent: KeyboardEvent;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Unicode version provider (xterm.js compatibility)
|
|
99
|
+
*/
|
|
100
|
+
export interface IUnicodeVersionProvider {
|
|
101
|
+
readonly activeVersion: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Buffer API Interfaces (xterm.js compatibility)
|
|
106
|
+
// ============================================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Top-level buffer API namespace
|
|
110
|
+
* Provides access to active, normal, and alternate screen buffers
|
|
111
|
+
*/
|
|
112
|
+
export interface IBufferNamespace {
|
|
113
|
+
/** The currently active buffer (normal or alternate) */
|
|
114
|
+
readonly active: IBuffer;
|
|
115
|
+
/** The normal buffer (primary screen) */
|
|
116
|
+
readonly normal: IBuffer;
|
|
117
|
+
/** The alternate buffer (used by full-screen apps like vim) */
|
|
118
|
+
readonly alternate: IBuffer;
|
|
119
|
+
|
|
120
|
+
/** Event fired when buffer changes (normal ↔ alternate) */
|
|
121
|
+
readonly onBufferChange: IEvent<IBuffer>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A terminal buffer (normal or alternate screen)
|
|
126
|
+
*/
|
|
127
|
+
export interface IBuffer {
|
|
128
|
+
/** Buffer type: 'normal' or 'alternate' */
|
|
129
|
+
readonly type: 'normal' | 'alternate';
|
|
130
|
+
/** Cursor X position (0-indexed) */
|
|
131
|
+
readonly cursorX: number;
|
|
132
|
+
/** Cursor Y position (0-indexed, relative to viewport) */
|
|
133
|
+
readonly cursorY: number;
|
|
134
|
+
/** Viewport Y position (scroll offset, 0 = bottom of scrollback) */
|
|
135
|
+
readonly viewportY: number;
|
|
136
|
+
/** Base Y position (always 0 for normal buffer, may vary for alternate) */
|
|
137
|
+
readonly baseY: number;
|
|
138
|
+
/** Total buffer length (rows + scrollback for normal, just rows for alternate) */
|
|
139
|
+
readonly length: number;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get a line from the buffer
|
|
143
|
+
* @param y Line index (0 = top of scrollback for normal buffer)
|
|
144
|
+
* @returns Line object or undefined if out of bounds
|
|
145
|
+
*/
|
|
146
|
+
getLine(y: number): IBufferLine | undefined;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get the null cell (used for empty/uninitialized cells)
|
|
150
|
+
*/
|
|
151
|
+
getNullCell(): IBufferCell;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A single line in the buffer
|
|
156
|
+
*/
|
|
157
|
+
export interface IBufferLine {
|
|
158
|
+
/** Length of the line (in columns) */
|
|
159
|
+
readonly length: number;
|
|
160
|
+
/** Whether this line wraps to the next line */
|
|
161
|
+
readonly isWrapped: boolean;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get a cell from this line
|
|
165
|
+
* @param x Column index (0-indexed)
|
|
166
|
+
* @returns Cell object or undefined if out of bounds
|
|
167
|
+
*/
|
|
168
|
+
getCell(x: number): IBufferCell | undefined;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Translate the line to a string
|
|
172
|
+
* @param trimRight Whether to trim trailing whitespace (default: false)
|
|
173
|
+
* @param startColumn Start column (default: 0)
|
|
174
|
+
* @param endColumn End column (default: length)
|
|
175
|
+
* @returns String representation of the line
|
|
176
|
+
*/
|
|
177
|
+
translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A single cell in the buffer
|
|
182
|
+
*/
|
|
183
|
+
export interface IBufferCell {
|
|
184
|
+
/** Character(s) in this cell (may be empty, single char, or emoji) */
|
|
185
|
+
getChars(): string;
|
|
186
|
+
/** Unicode codepoint (0 for null cell) */
|
|
187
|
+
getCode(): number;
|
|
188
|
+
/** Character width (1 = normal, 2 = wide/emoji, 0 = combining) */
|
|
189
|
+
getWidth(): number;
|
|
190
|
+
|
|
191
|
+
/** Foreground color index (for palette colors) or -1 for RGB */
|
|
192
|
+
getFgColorMode(): number;
|
|
193
|
+
/** Background color index (for palette colors) or -1 for RGB */
|
|
194
|
+
getBgColorMode(): number;
|
|
195
|
+
/** Foreground RGB color (or 0 for default) */
|
|
196
|
+
getFgColor(): number;
|
|
197
|
+
/** Background RGB color (or 0 for default) */
|
|
198
|
+
getBgColor(): number;
|
|
199
|
+
|
|
200
|
+
/** Whether cell has bold style */
|
|
201
|
+
isBold(): number;
|
|
202
|
+
/** Whether cell has italic style */
|
|
203
|
+
isItalic(): number;
|
|
204
|
+
/** Whether cell has underline style */
|
|
205
|
+
isUnderline(): number;
|
|
206
|
+
/** Whether cell has strikethrough style */
|
|
207
|
+
isStrikethrough(): number;
|
|
208
|
+
/** Whether cell has blink style */
|
|
209
|
+
isBlink(): number;
|
|
210
|
+
/** Whether cell has inverse video style */
|
|
211
|
+
isInverse(): number;
|
|
212
|
+
/** Whether cell has invisible style */
|
|
213
|
+
isInvisible(): number;
|
|
214
|
+
/** Whether cell has faint/dim style */
|
|
215
|
+
isFaint(): number;
|
|
216
|
+
|
|
217
|
+
// Link detection support
|
|
218
|
+
/** Get hyperlink ID for this cell (0 = no link) */
|
|
219
|
+
getHyperlinkId(): number;
|
|
220
|
+
/** Get the Unicode codepoint for this cell */
|
|
221
|
+
getCodepoint(): number;
|
|
222
|
+
/** Whether cell has dim/faint attribute (boolean version) */
|
|
223
|
+
isDim(): boolean;
|
|
224
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link detection and caching system
|
|
3
|
+
*
|
|
4
|
+
* The LinkDetector coordinates between multiple link providers and caches
|
|
5
|
+
* results for performance. Links are cached by position range, and earlier
|
|
6
|
+
* providers (e.g. OSC8) take precedence over later ones (e.g. regex).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ILink, ILinkProvider } from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Manages link detection across multiple providers with intelligent caching
|
|
13
|
+
*/
|
|
14
|
+
export class LinkDetector {
|
|
15
|
+
private providers: ILinkProvider[] = [];
|
|
16
|
+
|
|
17
|
+
// Cache links by position range: `r${row}:${startX}-${endX}`
|
|
18
|
+
private linkCache = new Map<string, ILink>();
|
|
19
|
+
|
|
20
|
+
// Track which rows have been scanned to avoid redundant provider calls
|
|
21
|
+
private scannedRows = new Set<number>();
|
|
22
|
+
|
|
23
|
+
// Terminal instance for buffer access
|
|
24
|
+
constructor(private terminal: ITerminalForLinkDetector) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register a link provider
|
|
28
|
+
*/
|
|
29
|
+
registerProvider(provider: ILinkProvider): void {
|
|
30
|
+
this.providers.push(provider);
|
|
31
|
+
this.invalidateCache(); // New provider may detect different links
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get link at the specified buffer position
|
|
36
|
+
* @param col Column (0-based)
|
|
37
|
+
* @param row Absolute row in buffer (0-based)
|
|
38
|
+
* @returns Link at position, or undefined if none
|
|
39
|
+
*/
|
|
40
|
+
async getLinkAt(col: number, row: number): Promise<ILink | undefined> {
|
|
41
|
+
const line = this.terminal.buffer.active.getLine(row);
|
|
42
|
+
if (!line || col < 0 || col >= line.length) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cell = line.getCell(col);
|
|
47
|
+
if (!cell) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check if any cached link contains this position (fast path)
|
|
52
|
+
for (const link of this.linkCache.values()) {
|
|
53
|
+
if (this.isPositionInLink(col, row, link)) {
|
|
54
|
+
return link;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Slow path: scan this row if not already scanned
|
|
59
|
+
if (!this.scannedRows.has(row)) {
|
|
60
|
+
await this.scanRow(row);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check cache again after scanning
|
|
64
|
+
for (const link of this.linkCache.values()) {
|
|
65
|
+
if (this.isPositionInLink(col, row, link)) {
|
|
66
|
+
return link;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Scan a row for links using all registered providers
|
|
75
|
+
*/
|
|
76
|
+
private async scanRow(row: number): Promise<void> {
|
|
77
|
+
this.scannedRows.add(row);
|
|
78
|
+
|
|
79
|
+
const allLinks: ILink[] = [];
|
|
80
|
+
|
|
81
|
+
// Query all providers
|
|
82
|
+
for (const provider of this.providers) {
|
|
83
|
+
const links = await new Promise<ILink[] | undefined>((resolve) => {
|
|
84
|
+
provider.provideLinks(row, resolve);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (links) {
|
|
88
|
+
allLinks.push(...links);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Cache all discovered links
|
|
93
|
+
for (const link of allLinks) {
|
|
94
|
+
this.cacheLink(link);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Cache a link for fast lookup
|
|
100
|
+
*
|
|
101
|
+
* Note: We cache by position range, not hyperlink_id, because the WASM
|
|
102
|
+
* returns hyperlink_id as a boolean (0 or 1), not a unique identifier.
|
|
103
|
+
* The actual unique identifier is the URI which is retrieved separately.
|
|
104
|
+
*/
|
|
105
|
+
private cacheLink(link: ILink): void {
|
|
106
|
+
// Cache by position range - this uniquely identifies links even when
|
|
107
|
+
// multiple OSC 8 links exist on the same line
|
|
108
|
+
const { start: s, end: e } = link.range;
|
|
109
|
+
const cacheKey = `r${s.y}:${s.x}-${e.x}`;
|
|
110
|
+
// Don't overwrite existing entries - earlier providers (OSC8) take
|
|
111
|
+
// precedence over later ones (regex) for the same range
|
|
112
|
+
if (!this.linkCache.has(cacheKey)) {
|
|
113
|
+
this.linkCache.set(cacheKey, link);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a position is within a link's range
|
|
119
|
+
*/
|
|
120
|
+
private isPositionInLink(col: number, row: number, link: ILink): boolean {
|
|
121
|
+
const { start, end } = link.range;
|
|
122
|
+
|
|
123
|
+
// Check if row is in range
|
|
124
|
+
if (row < start.y || row > end.y) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Single-line link
|
|
129
|
+
if (start.y === end.y) {
|
|
130
|
+
return col >= start.x && col <= end.x;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Multi-line link
|
|
134
|
+
if (row === start.y) {
|
|
135
|
+
return col >= start.x; // First line: from start.x to end of line
|
|
136
|
+
} else if (row === end.y) {
|
|
137
|
+
return col <= end.x; // Last line: from start of line to end.x
|
|
138
|
+
} else {
|
|
139
|
+
return true; // Middle line: entire line is part of link
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Invalidate cache when terminal content changes
|
|
145
|
+
* Should be called on terminal write, resize, or clear
|
|
146
|
+
*/
|
|
147
|
+
invalidateCache(): void {
|
|
148
|
+
this.linkCache.clear();
|
|
149
|
+
this.scannedRows.clear();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Invalidate cache for specific rows
|
|
154
|
+
* Used when only part of the terminal changed
|
|
155
|
+
*/
|
|
156
|
+
invalidateRows(startRow: number, endRow: number): void {
|
|
157
|
+
// Remove scanned markers
|
|
158
|
+
for (let row = startRow; row <= endRow; row++) {
|
|
159
|
+
this.scannedRows.delete(row);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Remove cached links in this range
|
|
163
|
+
// This is conservative - we remove any link that touches these rows
|
|
164
|
+
const toDelete: string[] = [];
|
|
165
|
+
for (const [key, link] of this.linkCache.entries()) {
|
|
166
|
+
const { start, end } = link.range;
|
|
167
|
+
if (
|
|
168
|
+
(start.y >= startRow && start.y <= endRow) ||
|
|
169
|
+
(end.y >= startRow && end.y <= endRow) ||
|
|
170
|
+
(start.y < startRow && end.y > endRow)
|
|
171
|
+
) {
|
|
172
|
+
toDelete.push(key);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const key of toDelete) {
|
|
177
|
+
this.linkCache.delete(key);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Dispose and cleanup
|
|
183
|
+
*/
|
|
184
|
+
dispose(): void {
|
|
185
|
+
this.linkCache.clear();
|
|
186
|
+
this.scannedRows.clear();
|
|
187
|
+
|
|
188
|
+
// Dispose all providers
|
|
189
|
+
for (const provider of this.providers) {
|
|
190
|
+
provider.dispose?.();
|
|
191
|
+
}
|
|
192
|
+
this.providers = [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Minimal terminal interface required by LinkDetector
|
|
198
|
+
* Keeps coupling low and testing easy
|
|
199
|
+
*/
|
|
200
|
+
export interface ITerminalForLinkDetector {
|
|
201
|
+
buffer: {
|
|
202
|
+
active: {
|
|
203
|
+
getLine(y: number):
|
|
204
|
+
| {
|
|
205
|
+
length: number;
|
|
206
|
+
getCell(x: number):
|
|
207
|
+
| {
|
|
208
|
+
getHyperlinkId(): number;
|
|
209
|
+
}
|
|
210
|
+
| undefined;
|
|
211
|
+
}
|
|
212
|
+
| undefined;
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
}
|
package/lib/marker.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xterm.js-compatible marker on a buffer line, backed by a libghostty-vt
|
|
3
|
+
* tracked grid reference so it follows its line through scrolling, reflow and
|
|
4
|
+
* scrollback pruning.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { EventEmitter } from './event-emitter';
|
|
8
|
+
import type { TrackedRow } from './ghostty';
|
|
9
|
+
import type { IDisposable, IEvent } from './interfaces';
|
|
10
|
+
|
|
11
|
+
export interface IMarker extends IDisposable {
|
|
12
|
+
readonly id: number;
|
|
13
|
+
readonly isDisposed: boolean;
|
|
14
|
+
/** The marker's absolute buffer line (scrollback first), or -1 once disposed. */
|
|
15
|
+
readonly line: number;
|
|
16
|
+
readonly onDispose: IEvent<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let nextMarkerId = 1;
|
|
20
|
+
|
|
21
|
+
export class Marker implements IMarker {
|
|
22
|
+
readonly id = nextMarkerId++;
|
|
23
|
+
private disposed = false;
|
|
24
|
+
private readonly disposeEmitter = new EventEmitter<void>();
|
|
25
|
+
readonly onDispose: IEvent<void> = this.disposeEmitter.event;
|
|
26
|
+
|
|
27
|
+
constructor(private readonly row: TrackedRow) {}
|
|
28
|
+
|
|
29
|
+
get isDisposed(): boolean {
|
|
30
|
+
return this.disposed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get line(): number {
|
|
34
|
+
if (this.disposed) return -1;
|
|
35
|
+
const line = this.row.line();
|
|
36
|
+
if (line === null) {
|
|
37
|
+
this.dispose();
|
|
38
|
+
return -1;
|
|
39
|
+
}
|
|
40
|
+
return line;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Disposes the marker if its line no longer exists. */
|
|
44
|
+
refresh(): void {
|
|
45
|
+
if (!this.disposed && this.row.line() === null) this.dispose();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
dispose(): void {
|
|
49
|
+
if (this.disposed) return;
|
|
50
|
+
this.disposed = true;
|
|
51
|
+
this.row.free();
|
|
52
|
+
this.disposeEmitter.fire();
|
|
53
|
+
this.disposeEmitter.dispose();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSC 8 Hyperlink Provider
|
|
3
|
+
*
|
|
4
|
+
* Detects hyperlinks created with OSC 8 escape sequences.
|
|
5
|
+
* Supports multi-line links that wrap across lines.
|
|
6
|
+
*
|
|
7
|
+
* OSC 8 format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07
|
|
8
|
+
*
|
|
9
|
+
* The Ghostty WASM automatically assigns hyperlink_id to cells,
|
|
10
|
+
* so we just need to scan for contiguous regions with the same ID.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { IBufferRange, ILink, ILinkProvider } from '../types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* OSC 8 Hyperlink Provider
|
|
17
|
+
*
|
|
18
|
+
* Detects OSC 8 hyperlinks by scanning for hyperlink_id in cells.
|
|
19
|
+
* Automatically handles multi-line links since Ghostty WASM preserves
|
|
20
|
+
* hyperlink_id across wrapped lines.
|
|
21
|
+
*/
|
|
22
|
+
export class OSC8LinkProvider implements ILinkProvider {
|
|
23
|
+
constructor(private terminal: ITerminalForOSC8Provider) {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Provide all OSC 8 links on the given row
|
|
27
|
+
* Note: This may return links that span multiple rows
|
|
28
|
+
*/
|
|
29
|
+
provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void {
|
|
30
|
+
const links: ILink[] = [];
|
|
31
|
+
const visitedPositions = new Set<number>(); // Track which columns we've already processed
|
|
32
|
+
|
|
33
|
+
const line = this.terminal.buffer.active.getLine(y);
|
|
34
|
+
if (!line) {
|
|
35
|
+
callback(undefined);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Scan through this line looking for hyperlink_id
|
|
40
|
+
for (let x = 0; x < line.length; x++) {
|
|
41
|
+
// Skip already processed positions
|
|
42
|
+
if (visitedPositions.has(x)) continue;
|
|
43
|
+
|
|
44
|
+
const cell = line.getCell(x);
|
|
45
|
+
if (!cell) continue;
|
|
46
|
+
|
|
47
|
+
const hyperlinkId = cell.getHyperlinkId();
|
|
48
|
+
|
|
49
|
+
// Skip cells without links
|
|
50
|
+
if (hyperlinkId === 0) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Get the URI from WASM
|
|
55
|
+
// The y parameter is a buffer row - we need to determine if it's in
|
|
56
|
+
// scrollback or the active viewport and use the appropriate API
|
|
57
|
+
if (!this.terminal.wasmTerm) continue;
|
|
58
|
+
const scrollbackLength = this.terminal.wasmTerm.getScrollbackLength();
|
|
59
|
+
const viewportRow = y - scrollbackLength;
|
|
60
|
+
|
|
61
|
+
let uri: string | null;
|
|
62
|
+
if (viewportRow < 0) {
|
|
63
|
+
// Row is in scrollback - use scrollback API
|
|
64
|
+
// y is the buffer row (0 = oldest), scrollback offset is also 0 = oldest
|
|
65
|
+
uri = this.terminal.wasmTerm.getScrollbackHyperlinkUri(y, x);
|
|
66
|
+
} else {
|
|
67
|
+
// Row is in active viewport
|
|
68
|
+
uri = this.terminal.wasmTerm.getHyperlinkUri(viewportRow, x);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (uri) {
|
|
72
|
+
// Find the end of this link by scanning forward until we hit a cell
|
|
73
|
+
// without a hyperlink or with a different URI
|
|
74
|
+
let endX = x;
|
|
75
|
+
for (let col = x + 1; col < line.length; col++) {
|
|
76
|
+
const nextCell = line.getCell(col);
|
|
77
|
+
if (!nextCell || nextCell.getHyperlinkId() === 0) break;
|
|
78
|
+
|
|
79
|
+
// Check if this cell has the same URI (use appropriate API for scrollback vs viewport)
|
|
80
|
+
const nextUri =
|
|
81
|
+
viewportRow < 0
|
|
82
|
+
? this.terminal.wasmTerm!.getScrollbackHyperlinkUri(y, col)
|
|
83
|
+
: this.terminal.wasmTerm!.getHyperlinkUri(viewportRow, col);
|
|
84
|
+
if (nextUri !== uri) break;
|
|
85
|
+
|
|
86
|
+
endX = col;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Mark all columns in this link as visited
|
|
90
|
+
for (let col = x; col <= endX; col++) {
|
|
91
|
+
visitedPositions.add(col);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const range: IBufferRange = {
|
|
95
|
+
start: { x, y },
|
|
96
|
+
end: { x: endX, y },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
links.push({
|
|
100
|
+
text: uri,
|
|
101
|
+
range,
|
|
102
|
+
activate: (event) => {
|
|
103
|
+
// Open link if Ctrl/Cmd is pressed
|
|
104
|
+
if (event.ctrlKey || event.metaKey) {
|
|
105
|
+
window.open(uri, '_blank', 'noopener,noreferrer');
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
callback(links.length > 0 ? links : undefined);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
dispose(): void {
|
|
116
|
+
// No resources to clean up
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Minimal terminal interface required by OSC8LinkProvider
|
|
122
|
+
*/
|
|
123
|
+
export interface ITerminalForOSC8Provider {
|
|
124
|
+
buffer: {
|
|
125
|
+
active: {
|
|
126
|
+
length: number;
|
|
127
|
+
getLine(y: number):
|
|
128
|
+
| {
|
|
129
|
+
length: number;
|
|
130
|
+
getCell(x: number):
|
|
131
|
+
| {
|
|
132
|
+
getHyperlinkId(): number;
|
|
133
|
+
}
|
|
134
|
+
| undefined;
|
|
135
|
+
}
|
|
136
|
+
| undefined;
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
wasmTerm?: {
|
|
140
|
+
getHyperlinkUri(row: number, col: number): string | null;
|
|
141
|
+
getScrollbackHyperlinkUri(offset: number, col: number): string | null;
|
|
142
|
+
getScrollbackLength(): number;
|
|
143
|
+
};
|
|
144
|
+
}
|