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