@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,2054 @@
1
+ /**
2
+ * Terminal - Main terminal emulator class
3
+ *
4
+ * Provides an xterm.js-compatible API wrapping Ghostty's WASM terminal emulator.
5
+ *
6
+ * Usage:
7
+ * ```typescript
8
+ * import { init, Terminal } from 'ghostty-web';
9
+ *
10
+ * await init();
11
+ * const term = new Terminal();
12
+ * term.open(document.getElementById('container'));
13
+ * term.write('Hello, World!\n');
14
+ * term.onData(data => console.log('User typed:', data));
15
+ * ```
16
+ */
17
+
18
+ import { BufferNamespace } from './buffer';
19
+ import { EventEmitter } from './event-emitter';
20
+ import {
21
+ type DesktopNotificationEvent,
22
+ DirtyState,
23
+ type Ghostty,
24
+ type GhosttyCell,
25
+ type GhosttyTerminal,
26
+ type GhosttyTerminalConfig,
27
+ type MouseTrackingMode,
28
+ type SemanticPromptEvent,
29
+ } from './ghostty';
30
+ import { getGhostty } from './index';
31
+ import { InputHandler, type MouseTrackingConfig } from './input-handler';
32
+ import type {
33
+ IBufferNamespace,
34
+ IBufferRange,
35
+ IDisposable,
36
+ IEvent,
37
+ IKeyEvent,
38
+ ITerminalAddon,
39
+ ITerminalCore,
40
+ ITerminalOptions,
41
+ IUnicodeVersionProvider,
42
+ } from './interfaces';
43
+ import { LinkDetector } from './link-detector';
44
+ import { type IMarker, Marker } from './marker';
45
+ import { OSC8LinkProvider } from './providers/osc8-link-provider';
46
+ import { UrlRegexProvider } from './providers/url-regex-provider';
47
+ import { CanvasRenderer, type RendererOptions, type TerminalRenderer } from './renderer';
48
+ import { SelectionManager } from './selection-manager';
49
+ import type { ILink, ILinkProvider } from './types';
50
+ import { WebglRenderer } from './webgl-renderer';
51
+
52
+ // ============================================================================
53
+ // Terminal Class
54
+ // ============================================================================
55
+
56
+ export class Terminal implements ITerminalCore {
57
+ // Public properties (xterm.js compatibility)
58
+ public cols: number;
59
+ public rows: number;
60
+ public element?: HTMLElement;
61
+ public textarea?: HTMLTextAreaElement;
62
+
63
+ // Buffer API (xterm.js compatibility)
64
+ public readonly buffer: IBufferNamespace;
65
+
66
+ // Unicode API (xterm.js compatibility)
67
+ public readonly unicode: IUnicodeVersionProvider = {
68
+ get activeVersion(): string {
69
+ return '15.1'; // Ghostty supports Unicode 15.1
70
+ },
71
+ };
72
+
73
+ // Options (public for xterm.js compatibility)
74
+ public readonly options!: Required<ITerminalOptions>;
75
+
76
+ // Components (created on open())
77
+ private ghostty?: Ghostty;
78
+ public wasmTerm?: GhosttyTerminal; // Made public for link providers
79
+ public renderer?: TerminalRenderer; // Made public for FitAddon
80
+ private inputHandler?: InputHandler;
81
+ private selectionManager?: SelectionManager;
82
+ private canvas?: HTMLCanvasElement;
83
+
84
+ // Link detection system
85
+ private linkDetector?: LinkDetector;
86
+ private currentHoveredLink?: ILink;
87
+ private mouseMoveThrottleTimeout?: number;
88
+ private pendingMouseMove?: MouseEvent;
89
+
90
+ // Event emitters
91
+ private dataEmitter = new EventEmitter<string>();
92
+ private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>();
93
+ private bellEmitter = new EventEmitter<void>();
94
+ private selectionChangeEmitter = new EventEmitter<void>();
95
+ private keyEmitter = new EventEmitter<IKeyEvent>();
96
+ private titleChangeEmitter = new EventEmitter<string>();
97
+ private scrollEmitter = new EventEmitter<number>();
98
+ private renderEmitter = new EventEmitter<{ start: number; end: number }>();
99
+ private cursorMoveEmitter = new EventEmitter<void>();
100
+ // Public event accessors (xterm.js compatibility)
101
+ public readonly onData: IEvent<string> = this.dataEmitter.event;
102
+ public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event;
103
+ public readonly onBell: IEvent<void> = this.bellEmitter.event;
104
+ public readonly onSelectionChange: IEvent<void> = this.selectionChangeEmitter.event;
105
+ public readonly onKey: IEvent<IKeyEvent> = this.keyEmitter.event;
106
+ public readonly onTitleChange: IEvent<string> = this.titleChangeEmitter.event;
107
+ public readonly onScroll: IEvent<number> = this.scrollEmitter.event;
108
+ public readonly onRender: IEvent<{ start: number; end: number }> = this.renderEmitter.event;
109
+ public readonly onCursorMove: IEvent<void> = this.cursorMoveEmitter.event;
110
+
111
+ private pwdEmitter = new EventEmitter<string>();
112
+ private semanticPromptEmitter = new EventEmitter<SemanticPromptEvent>();
113
+ private desktopNotificationEmitter = new EventEmitter<DesktopNotificationEvent>();
114
+ private unknownOscEmitter = new EventEmitter<string>();
115
+
116
+ /** OSC 7 working directory as the shell sent it (raw file:// URI with its host). */
117
+ public readonly onPwdChange: IEvent<string> = this.pwdEmitter.event;
118
+ /** OSC 133 prompt marks; buffer.active cursor reads inside the handler are exact. */
119
+ public readonly onSemanticPrompt: IEvent<SemanticPromptEvent> = this.semanticPromptEmitter.event;
120
+ /** OSC 9 / OSC 777 desktop notifications. */
121
+ public readonly onDesktopNotification: IEvent<DesktopNotificationEvent> =
122
+ this.desktopNotificationEmitter.event;
123
+ /** OSCs libghostty-vt does not implement, as their content (e.g. `633;E;ls`). */
124
+ public readonly onUnknownOsc: IEvent<string> = this.unknownOscEmitter.event;
125
+
126
+ /**
127
+ * Whether replies to the program's queries (DSR, DA, mode and color reports)
128
+ * are sent back as data. A host that runs the shell behind a multiplexer which
129
+ * answers them itself turns this off.
130
+ */
131
+ public answerQueries = true;
132
+
133
+ private engineSubscriptions: IDisposable[] = [];
134
+ private clipboardHandler: ((text: string) => boolean) | null = null;
135
+ private markers = new Set<Marker>();
136
+ private lastAlternate = false;
137
+ private idleFrames = 0;
138
+ private static readonly IDLE_FRAMES_BEFORE_SLEEP = 30;
139
+
140
+ // Lifecycle state
141
+ private isOpen = false;
142
+ private isDisposed = false;
143
+ private animationFrameId?: number;
144
+ private writeQueue: Uint8Array[] = [];
145
+ private awaitingEcho = false;
146
+
147
+ // Addons
148
+ private addons: ITerminalAddon[] = [];
149
+
150
+ // Phase 1: Custom event handlers
151
+ private customKeyEventHandler?: (event: KeyboardEvent) => boolean;
152
+
153
+ // Phase 1: Title tracking
154
+ private currentTitle: string = '';
155
+
156
+ // Phase 2: Viewport and scrolling state
157
+ public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom, can be fractional during smooth scroll)
158
+ private targetViewportY: number = 0; // Target viewport position for smooth scrolling
159
+ private scrollAnimationStartTime?: number;
160
+ private scrollAnimationFrame?: number;
161
+ private customWheelEventHandler?: (event: WheelEvent) => boolean;
162
+ private lastCursorY: number = 0; // Track cursor position for onCursorMove
163
+
164
+ // Scrollbar interaction state
165
+ private isDraggingScrollbar: boolean = false;
166
+ private scrollbarDragStart: number | null = null;
167
+ private scrollbarDragStartViewportY: number = 0;
168
+
169
+ // Scrollbar visibility/auto-hide state
170
+ private scrollbarVisible: boolean = false;
171
+ private scrollbarOpacity: number = 0;
172
+ private scrollbarHideTimeout?: number;
173
+ private readonly SCROLLBAR_HIDE_DELAY_MS = 1500; // Hide after 1.5 seconds
174
+ private readonly SCROLLBAR_FADE_DURATION_MS = 200; // 200ms fade animation
175
+
176
+ constructor(options: ITerminalOptions = {}) {
177
+ // Use provided Ghostty instance (for test isolation) or get module-level instance
178
+ this.ghostty = options.ghostty ?? getGhostty();
179
+
180
+ // Create base options object with all defaults (excluding ghostty)
181
+ const baseOptions = {
182
+ cols: options.cols ?? 80,
183
+ rows: options.rows ?? 24,
184
+ cursorBlink: options.cursorBlink ?? false,
185
+ cursorStyle: options.cursorStyle ?? 'block',
186
+ theme: options.theme ?? {},
187
+ scrollback: options.scrollback ?? 10000,
188
+ fontSize: options.fontSize ?? 15,
189
+ fontFamily: options.fontFamily ?? 'monospace',
190
+ allowTransparency: options.allowTransparency ?? false,
191
+ renderer: options.renderer ?? 'webgl',
192
+ convertEol: options.convertEol ?? false,
193
+ disableStdin: options.disableStdin ?? false,
194
+ smoothScrollDuration: options.smoothScrollDuration ?? 100, // Default: 100ms smooth scroll
195
+ };
196
+
197
+ // Wrap in Proxy to intercept runtime changes (xterm.js compatibility)
198
+ (this.options as any) = new Proxy(baseOptions, {
199
+ set: (target: any, prop: string, value: any) => {
200
+ const oldValue = target[prop];
201
+ target[prop] = value;
202
+
203
+ // Apply runtime changes if terminal is open
204
+ if (this.isOpen) {
205
+ this.handleOptionChange(prop, value, oldValue);
206
+ }
207
+
208
+ return true;
209
+ },
210
+ });
211
+
212
+ this.cols = this.options.cols;
213
+ this.rows = this.options.rows;
214
+
215
+ // Initialize buffer API
216
+ this.buffer = new BufferNamespace(this);
217
+ }
218
+
219
+ // ==========================================================================
220
+ // Option Change Handling (for mutable options)
221
+ // ==========================================================================
222
+
223
+ /**
224
+ * Handle runtime option changes (called when options are modified after terminal is open)
225
+ * This enables xterm.js compatibility where options can be changed at runtime
226
+ */
227
+ private handleOptionChange(key: string, newValue: any, oldValue: any): void {
228
+ this.wake();
229
+ if (newValue === oldValue) return;
230
+
231
+ switch (key) {
232
+ case 'disableStdin':
233
+ // Input handler already checks this.options.disableStdin dynamically
234
+ // No action needed
235
+ break;
236
+
237
+ case 'cursorBlink':
238
+ case 'cursorStyle':
239
+ if (this.renderer) {
240
+ this.renderer.setCursorStyle(this.options.cursorStyle);
241
+ this.renderer.setCursorBlink(this.options.cursorBlink);
242
+ }
243
+ break;
244
+
245
+ case 'theme':
246
+ if (this.renderer) {
247
+ console.warn('ghostty-web: theme changes after open() are not yet fully supported');
248
+ }
249
+ break;
250
+
251
+ case 'fontSize':
252
+ if (this.renderer) {
253
+ this.renderer.setFontSize(this.options.fontSize);
254
+ this.handleFontChange();
255
+ }
256
+ break;
257
+
258
+ case 'fontFamily':
259
+ if (this.renderer) {
260
+ this.renderer.setFontFamily(this.options.fontFamily);
261
+ this.handleFontChange();
262
+ }
263
+ break;
264
+
265
+ case 'cols':
266
+ case 'rows':
267
+ // Redirect to resize method
268
+ this.resize(this.options.cols, this.options.rows);
269
+ break;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Handle font changes (fontSize or fontFamily)
275
+ * Updates canvas size to match new font metrics and forces a full re-render
276
+ */
277
+ private handleFontChange(): void {
278
+ if (!this.renderer || !this.wasmTerm || !this.canvas) return;
279
+
280
+ // Clear any active selection since pixel positions have changed
281
+ if (this.selectionManager) {
282
+ this.selectionManager.clearSelection();
283
+ }
284
+
285
+ // Resize canvas to match new font metrics
286
+ this.renderer.resize(this.cols, this.rows);
287
+
288
+ // Force full re-render with new font
289
+ this.renderer.render(this.wasmTerm, true, this.viewportY, this);
290
+ }
291
+
292
+ /**
293
+ * Parse a CSS color string to 0xRRGGBB format.
294
+ * Returns 0 if the color is undefined or invalid.
295
+ */
296
+ private parseColorToHex(color?: string): number {
297
+ if (!color) return 0;
298
+
299
+ // Handle hex colors (#RGB, #RRGGBB)
300
+ if (color.startsWith('#')) {
301
+ let hex = color.slice(1);
302
+ if (hex.length === 3) {
303
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
304
+ }
305
+ const value = Number.parseInt(hex, 16);
306
+ return Number.isNaN(value) ? 0 : value;
307
+ }
308
+
309
+ // Handle rgb(r, g, b) format
310
+ const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
311
+ if (match) {
312
+ const r = Number.parseInt(match[1], 10);
313
+ const g = Number.parseInt(match[2], 10);
314
+ const b = Number.parseInt(match[3], 10);
315
+ return (r << 16) | (g << 8) | b;
316
+ }
317
+
318
+ return 0;
319
+ }
320
+
321
+ /**
322
+ * Convert terminal options to WASM terminal config.
323
+ */
324
+ private buildWasmConfig(): GhosttyTerminalConfig | undefined {
325
+ const theme = this.options.theme;
326
+ const scrollback = this.options.scrollback;
327
+
328
+ // If no theme and default scrollback, use defaults
329
+ if (!theme && scrollback === 10000) {
330
+ return undefined;
331
+ }
332
+
333
+ // Build palette array from theme colors
334
+ // Order: black, red, green, yellow, blue, magenta, cyan, white,
335
+ // brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite
336
+ const palette: number[] = [
337
+ this.parseColorToHex(theme?.black),
338
+ this.parseColorToHex(theme?.red),
339
+ this.parseColorToHex(theme?.green),
340
+ this.parseColorToHex(theme?.yellow),
341
+ this.parseColorToHex(theme?.blue),
342
+ this.parseColorToHex(theme?.magenta),
343
+ this.parseColorToHex(theme?.cyan),
344
+ this.parseColorToHex(theme?.white),
345
+ this.parseColorToHex(theme?.brightBlack),
346
+ this.parseColorToHex(theme?.brightRed),
347
+ this.parseColorToHex(theme?.brightGreen),
348
+ this.parseColorToHex(theme?.brightYellow),
349
+ this.parseColorToHex(theme?.brightBlue),
350
+ this.parseColorToHex(theme?.brightMagenta),
351
+ this.parseColorToHex(theme?.brightCyan),
352
+ this.parseColorToHex(theme?.brightWhite),
353
+ ];
354
+
355
+ return {
356
+ scrollbackLimit: scrollback,
357
+ fgColor: this.parseColorToHex(theme?.foreground),
358
+ bgColor: this.parseColorToHex(theme?.background),
359
+ cursorColor: this.parseColorToHex(theme?.cursor),
360
+ palette,
361
+ };
362
+ }
363
+
364
+ // ==========================================================================
365
+ // Lifecycle Methods
366
+ // ==========================================================================
367
+
368
+ /**
369
+ * Open terminal in a parent element
370
+ *
371
+ * Initializes all components and starts rendering.
372
+ * Requires a pre-loaded Ghostty instance passed to the constructor.
373
+ */
374
+ open(parent: HTMLElement): void {
375
+ if (this.isOpen) {
376
+ throw new Error('Terminal is already open');
377
+ }
378
+ if (this.isDisposed) {
379
+ throw new Error('Terminal has been disposed');
380
+ }
381
+
382
+ // Store parent element
383
+ this.element = parent;
384
+ this.isOpen = true;
385
+
386
+ try {
387
+ // Make parent focusable if it isn't already
388
+ if (!parent.hasAttribute('tabindex')) {
389
+ parent.setAttribute('tabindex', '0');
390
+ }
391
+
392
+ // Mark as contenteditable so browser extensions (Vimium, etc.) recognize
393
+ // this as an input element and don't intercept keyboard events.
394
+ parent.setAttribute('contenteditable', 'true');
395
+ // Prevent actual content editing - we handle input ourselves
396
+ parent.addEventListener('beforeinput', (e) => {
397
+ if (e.target === parent) {
398
+ e.preventDefault();
399
+ }
400
+ });
401
+
402
+ // Add accessibility attributes for screen readers and extensions
403
+ parent.setAttribute('role', 'textbox');
404
+ parent.setAttribute('aria-label', 'Terminal input');
405
+ parent.setAttribute('aria-multiline', 'true');
406
+
407
+ // Create WASM terminal with current dimensions and config
408
+ const config = this.buildWasmConfig();
409
+ this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config);
410
+ this.wireEngine();
411
+
412
+ // Create canvas element
413
+ this.canvas = document.createElement('canvas');
414
+ this.canvas.style.display = 'block';
415
+ this.canvas.style.cursor = 'text';
416
+
417
+ parent.appendChild(this.canvas);
418
+
419
+ // Create hidden textarea for keyboard input (must be inside parent for event bubbling)
420
+ this.textarea = document.createElement('textarea');
421
+ this.textarea.setAttribute('autocorrect', 'off');
422
+ this.textarea.setAttribute('autocapitalize', 'off');
423
+ this.textarea.setAttribute('spellcheck', 'false');
424
+ this.textarea.setAttribute('tabindex', '0'); // Allow focus for mobile keyboard
425
+ this.textarea.setAttribute('aria-label', 'Terminal input');
426
+ // Use clip-path to completely hide the textarea and its caret
427
+ this.textarea.style.position = 'absolute';
428
+ this.textarea.style.left = '0';
429
+ this.textarea.style.top = '0';
430
+ this.textarea.style.width = '1px';
431
+ this.textarea.style.height = '1px';
432
+ this.textarea.style.padding = '0';
433
+ this.textarea.style.border = 'none';
434
+ this.textarea.style.margin = '0';
435
+ this.textarea.style.opacity = '0';
436
+ this.textarea.style.clipPath = 'inset(50%)'; // Clip everything including caret
437
+ this.textarea.style.overflow = 'hidden';
438
+ this.textarea.style.whiteSpace = 'nowrap';
439
+ this.textarea.style.resize = 'none';
440
+ parent.appendChild(this.textarea);
441
+
442
+ // Focus textarea on interaction - preventDefault before focus
443
+ const textarea = this.textarea;
444
+ // Desktop: mousedown
445
+ this.canvas.addEventListener('mousedown', (ev) => {
446
+ ev.preventDefault();
447
+ textarea.focus();
448
+ });
449
+ // Mobile: touchend with preventDefault to suppress iOS caret
450
+ this.canvas.addEventListener('touchend', (ev) => {
451
+ ev.preventDefault();
452
+ textarea.focus();
453
+ });
454
+
455
+ this.renderer = this.createRenderer();
456
+ this.renderer.resize(this.cols, this.rows);
457
+
458
+ // Create mouse tracking configuration
459
+ const canvas = this.canvas;
460
+ const wasmTerm = this.wasmTerm;
461
+ const mouseConfig: MouseTrackingConfig = {
462
+ hasMouseTracking: () => wasmTerm?.hasMouseTracking() ?? false,
463
+ hasSgrMouseMode: () => wasmTerm?.getMode(1006, false) ?? true, // SGR extended mode
464
+ getCellDimensions: () => ({
465
+ width: this.renderer!.charWidth,
466
+ height: this.renderer!.charHeight,
467
+ }),
468
+ getCanvasOffset: () => {
469
+ const rect = canvas.getBoundingClientRect();
470
+ return { left: rect.left, top: rect.top };
471
+ },
472
+ };
473
+
474
+ // Create input handler
475
+ this.inputHandler = new InputHandler(
476
+ this.ghostty!,
477
+ parent,
478
+ (data: string) => {
479
+ // Check if stdin is disabled
480
+ if (this.options.disableStdin) {
481
+ return;
482
+ }
483
+ // Clear selection when user types
484
+ this.selectionManager?.clearSelection();
485
+ this.awaitingEcho = true;
486
+ // Input handler fires data events
487
+ this.dataEmitter.fire(data);
488
+ },
489
+ () => {
490
+ // Input handler can also fire bell
491
+ this.bellEmitter.fire();
492
+ },
493
+ (keyEvent: IKeyEvent) => {
494
+ // Forward key events
495
+ this.keyEmitter.fire(keyEvent);
496
+ },
497
+ this.customKeyEventHandler,
498
+ (mode: number) => {
499
+ // Query terminal mode state (e.g., mode 1 for application cursor mode)
500
+ return this.wasmTerm?.getMode(mode, false) ?? false;
501
+ },
502
+ () => {
503
+ // Handle Cmd+C copy - returns true if there was a selection to copy
504
+ return this.copySelection();
505
+ },
506
+ this.textarea,
507
+ mouseConfig
508
+ );
509
+
510
+ // Create selection manager (pass textarea for context menu positioning)
511
+ this.selectionManager = new SelectionManager(
512
+ this,
513
+ this.renderer,
514
+ this.wasmTerm,
515
+ this.textarea
516
+ );
517
+
518
+ // Connect selection manager to renderer
519
+ this.renderer.setSelectionManager(this.selectionManager);
520
+
521
+ // Forward selection change events
522
+ this.selectionManager.onSelectionChange(() => {
523
+ this.selectionChangeEmitter.fire();
524
+ });
525
+
526
+ // Initialize link detection system
527
+ this.linkDetector = new LinkDetector(this);
528
+
529
+ // Register link providers
530
+ // OSC8 first (explicit hyperlinks take precedence)
531
+ this.linkDetector.registerProvider(new OSC8LinkProvider(this));
532
+ // URL regex second (fallback for plain text URLs)
533
+ this.linkDetector.registerProvider(new UrlRegexProvider(this));
534
+
535
+ // Setup mouse event handling for links and scrollbar
536
+ // Use capture phase to intercept scrollbar clicks before SelectionManager
537
+ parent.addEventListener('mousedown', this.handleMouseDown, { capture: true });
538
+ parent.addEventListener('mousemove', this.handleMouseMove);
539
+ parent.addEventListener('mouseleave', this.handleMouseLeave);
540
+ parent.addEventListener('click', this.handleClick);
541
+
542
+ // Setup document-level mouseup for scrollbar drag (so drag works even outside canvas)
543
+ document.addEventListener('mouseup', this.handleMouseUp);
544
+
545
+ // Setup wheel event handling for scrolling (Phase 2)
546
+ // Use capture phase to ensure we get the event before browser scrolling
547
+ parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true });
548
+
549
+ // Render initial blank screen (force full redraw)
550
+ this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
551
+
552
+ // Start render loop
553
+ this.startRenderLoop();
554
+
555
+ // Focus input (auto-focus so user can start typing immediately)
556
+ this.focus();
557
+ } catch (error) {
558
+ // Clean up on error
559
+ this.isOpen = false;
560
+ this.cleanupComponents();
561
+ throw new Error(`Failed to open terminal: ${error}`);
562
+ }
563
+ }
564
+
565
+ /** Which renderer draws the terminal now: 'webgl', or 'canvas' when asked for or fallen back to. */
566
+ get rendererType(): 'webgl' | 'canvas' {
567
+ return this.renderer instanceof WebglRenderer ? 'webgl' : 'canvas';
568
+ }
569
+
570
+ private rendererOptions(): RendererOptions {
571
+ return {
572
+ fontSize: this.options.fontSize,
573
+ fontFamily: this.options.fontFamily,
574
+ cursorStyle: this.options.cursorStyle,
575
+ cursorBlink: this.options.cursorBlink,
576
+ theme: this.options.theme,
577
+ };
578
+ }
579
+
580
+ private createRenderer(): TerminalRenderer {
581
+ let renderer: TerminalRenderer | null = null;
582
+ if (this.options.renderer === 'webgl') {
583
+ try {
584
+ const webgl = new WebglRenderer(this.canvas!, this.rendererOptions());
585
+ webgl.onContextLost = () => this.fallBackToCanvas();
586
+ renderer = webgl;
587
+ } catch {
588
+ renderer = null;
589
+ }
590
+ }
591
+ renderer ??= new CanvasRenderer(this.canvas!, this.rendererOptions());
592
+ renderer.onNeedsFrame = () => this.wake();
593
+ return renderer;
594
+ }
595
+
596
+ /** Replaces a WebGL renderer whose context was lost with a canvas renderer on the same canvas. */
597
+ private fallBackToCanvas(): void {
598
+ if (!this.renderer || !this.canvas || !(this.renderer instanceof WebglRenderer)) return;
599
+ const hoveredLink = this.renderer.getHoveredHyperlinkId();
600
+ this.renderer.dispose();
601
+ const renderer = new CanvasRenderer(this.canvas, this.rendererOptions());
602
+ renderer.onNeedsFrame = () => this.wake();
603
+ renderer.setHoveredHyperlinkId(hoveredLink);
604
+ if (this.selectionManager) {
605
+ renderer.setSelectionManager(this.selectionManager);
606
+ this.selectionManager.setRenderer(renderer);
607
+ }
608
+ this.renderer = renderer;
609
+ renderer.resize(this.cols, this.rows);
610
+ if (this.wasmTerm) {
611
+ renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
612
+ }
613
+ this.wake();
614
+ }
615
+
616
+ /**
617
+ * Write data to terminal
618
+ */
619
+ write(data: string | Uint8Array, callback?: () => void): void {
620
+ this.assertOpen();
621
+
622
+ // Handle convertEol option
623
+ if (this.options.convertEol && typeof data === 'string') {
624
+ data = data.replace(/\n/g, '\r\n');
625
+ }
626
+
627
+ this.writeInternal(data, callback);
628
+ }
629
+
630
+ /**
631
+ * Internal write implementation (extracted from write())
632
+ */
633
+ private writeInternal(data: string | Uint8Array, callback?: () => void): void {
634
+ // Note: We intentionally do NOT clear selection on write - most modern terminals
635
+ // preserve selection when new data arrives. Selection is cleared by user actions
636
+ // like clicking or typing, not by incoming data.
637
+
638
+ // Write directly to WASM terminal (handles VT parsing internally)
639
+ this.wasmTerm!.write(data);
640
+
641
+ // Process any responses generated by the terminal (e.g., DSR cursor position)
642
+ // These need to be sent back to the PTY via onData
643
+ this.processTerminalResponses();
644
+
645
+ // Invalidate link cache (content changed)
646
+ this.linkDetector?.invalidateCache();
647
+
648
+ // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior)
649
+ if (this.viewportY !== 0) {
650
+ this.scrollToBottom();
651
+ }
652
+
653
+ this.afterWrite();
654
+
655
+ // Call callback if provided
656
+ if (callback) {
657
+ // Queue callback after next render
658
+ requestAnimationFrame(callback);
659
+ }
660
+
661
+ if (this.awaitingEcho) {
662
+ this.awaitingEcho = false;
663
+ if (this.renderer && this.wasmTerm) {
664
+ this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
665
+ }
666
+ }
667
+
668
+ // Render will happen on next animation frame
669
+ }
670
+
671
+ /**
672
+ * Write data with newline
673
+ */
674
+ writeln(data: string | Uint8Array, callback?: () => void): void {
675
+ if (typeof data === 'string') {
676
+ this.write(data + '\r\n', callback);
677
+ } else {
678
+ // Append \r\n to Uint8Array
679
+ const newData = new Uint8Array(data.length + 2);
680
+ newData.set(data);
681
+ newData[data.length] = 0x0d; // \r
682
+ newData[data.length + 1] = 0x0a; // \n
683
+ this.write(newData, callback);
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Paste text into terminal (triggers bracketed paste if supported)
689
+ */
690
+ paste(data: string): void {
691
+ this.assertOpen();
692
+
693
+ // Don't paste if stdin is disabled
694
+ if (this.options.disableStdin) {
695
+ return;
696
+ }
697
+
698
+ this.awaitingEcho = true;
699
+
700
+ // Check if terminal has bracketed paste mode enabled
701
+ if (this.wasmTerm!.hasBracketedPaste()) {
702
+ // Wrap with bracketed paste sequences (DEC mode 2004)
703
+ this.dataEmitter.fire('\x1b[200~' + data + '\x1b[201~');
704
+ } else {
705
+ // Send data directly
706
+ this.dataEmitter.fire(data);
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Input data into terminal (as if typed by user)
712
+ *
713
+ * @param data - Data to input
714
+ * @param wasUserInput - If true, triggers onData event (default: false for compat with some apps)
715
+ */
716
+ input(data: string, wasUserInput: boolean = false): void {
717
+ this.assertOpen();
718
+
719
+ // Don't input if stdin is disabled
720
+ if (this.options.disableStdin) {
721
+ return;
722
+ }
723
+
724
+ if (wasUserInput) {
725
+ this.awaitingEcho = true;
726
+ // Trigger onData event as if user typed it
727
+ this.dataEmitter.fire(data);
728
+ } else {
729
+ // Just write to terminal without triggering onData
730
+ this.write(data);
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Resize terminal
736
+ */
737
+ resize(cols: number, rows: number): void {
738
+ this.assertOpen();
739
+
740
+ if (cols === this.cols && rows === this.rows) {
741
+ return; // No change
742
+ }
743
+
744
+ // Cancel render loop before resize to prevent accessing detached TypedArray
745
+ // views while WASM reallocates buffers. We restart it after resize completes.
746
+ // This avoids the background-tab regression of using an isResizing flag
747
+ // cleared via requestAnimationFrame (rAF is throttled/paused in background tabs).
748
+ this.cancelRenderLoop();
749
+
750
+ try {
751
+ // Update dimensions
752
+ this.cols = cols;
753
+ this.rows = rows;
754
+
755
+ // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views)
756
+ this.wasmTerm!.resize(cols, rows);
757
+
758
+ // Resize renderer
759
+ this.renderer!.resize(cols, rows);
760
+
761
+ // Fire resize event
762
+ this.resizeEmitter.fire({ cols, rows });
763
+
764
+ // Force full render
765
+ this.renderer!.render(this.wasmTerm!, true, this.viewportY, this);
766
+ } catch (e) {
767
+ console.error('Terminal resize failed:', e);
768
+ }
769
+
770
+ // Flush any writes that were queued during resize, then restart render loop
771
+ this.flushWriteQueue();
772
+ this.wake();
773
+ }
774
+
775
+ /**
776
+ * Clear terminal screen
777
+ */
778
+ clear(): void {
779
+ this.assertOpen();
780
+ // Send ANSI clear screen and cursor home sequences
781
+ this.wasmTerm!.write('\x1b[2J\x1b[H');
782
+ }
783
+
784
+ /**
785
+ * Reset terminal state
786
+ */
787
+ reset(): void {
788
+ this.assertOpen();
789
+
790
+ // Free old WASM terminal and create new one
791
+ if (this.wasmTerm) {
792
+ this.wasmTerm.free();
793
+ }
794
+ const config = this.buildWasmConfig();
795
+ this.disposeMarkers();
796
+ this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config);
797
+ this.wireEngine();
798
+ this.wake();
799
+
800
+ // Clear renderer
801
+ this.renderer!.clear();
802
+
803
+ // Reset title
804
+ this.currentTitle = '';
805
+ }
806
+
807
+ /**
808
+ * Focus terminal input
809
+ */
810
+ focus(): void {
811
+ this.wake();
812
+ if (this.isOpen && this.element) {
813
+ // Focus immediately for immediate keyboard/wheel event handling
814
+ this.element.focus();
815
+
816
+ // Also schedule a delayed focus as backup to ensure it sticks
817
+ // (some browsers may need this if DOM isn't fully settled)
818
+ setTimeout(() => {
819
+ this.element?.focus();
820
+ }, 0);
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Blur terminal (remove focus)
826
+ */
827
+ blur(): void {
828
+ this.wake();
829
+ if (this.isOpen && this.element) {
830
+ this.element.blur();
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Load an addon
836
+ */
837
+ loadAddon(addon: ITerminalAddon): void {
838
+ addon.activate(this);
839
+ this.addons.push(addon);
840
+ }
841
+
842
+ // ==========================================================================
843
+ // Selection API (xterm.js compatible)
844
+ // ==========================================================================
845
+
846
+ /**
847
+ * Get the selected text as a string
848
+ */
849
+ public getSelection(): string {
850
+ return this.selectionManager?.getSelection() || '';
851
+ }
852
+
853
+ /**
854
+ * Check if there's an active selection
855
+ */
856
+ public hasSelection(): boolean {
857
+ return this.selectionManager?.hasSelection() || false;
858
+ }
859
+
860
+ /**
861
+ * Clear the current selection
862
+ */
863
+ public clearSelection(): void {
864
+ this.selectionManager?.clearSelection();
865
+ }
866
+
867
+ /**
868
+ * Copy the current selection to clipboard
869
+ * @returns true if there was text to copy, false otherwise
870
+ */
871
+ public copySelection(): boolean {
872
+ return this.selectionManager?.copySelection() || false;
873
+ }
874
+
875
+ /**
876
+ * Select all text in the terminal
877
+ */
878
+ public selectAll(): void {
879
+ this.selectionManager?.selectAll();
880
+ }
881
+
882
+ /**
883
+ * Select text at specific column and row with length
884
+ */
885
+ public select(column: number, row: number, length: number): void {
886
+ this.selectionManager?.select(column, row, length);
887
+ }
888
+
889
+ /**
890
+ * Select entire lines from start to end
891
+ */
892
+ public selectLines(start: number, end: number): void {
893
+ this.selectionManager?.selectLines(start, end);
894
+ }
895
+
896
+ /**
897
+ * Get selection position as buffer range
898
+ */
899
+ /**
900
+ * Get the current viewport Y position.
901
+ *
902
+ * This is the number of lines scrolled back from the bottom of the
903
+ * scrollback buffer. It may be fractional during smooth scrolling.
904
+ */
905
+ public getViewportY(): number {
906
+ return this.viewportY;
907
+ }
908
+
909
+ public getSelectionPosition(): IBufferRange | undefined {
910
+ return this.selectionManager?.getSelectionPosition();
911
+ }
912
+
913
+ // ==========================================================================
914
+ // Phase 1: Custom Event Handlers
915
+ // ==========================================================================
916
+
917
+ /**
918
+ * Attach a custom keyboard event handler
919
+ * Returns true to prevent default handling
920
+ */
921
+ public attachCustomKeyEventHandler(
922
+ customKeyEventHandler: (event: KeyboardEvent) => boolean
923
+ ): void {
924
+ this.customKeyEventHandler = customKeyEventHandler;
925
+ // Update input handler if already created
926
+ if (this.inputHandler) {
927
+ this.inputHandler.setCustomKeyEventHandler(customKeyEventHandler);
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Attach a custom wheel event handler (Phase 2)
933
+ * Returns true to prevent default handling
934
+ */
935
+ public attachCustomWheelEventHandler(
936
+ customWheelEventHandler?: (event: WheelEvent) => boolean
937
+ ): void {
938
+ this.customWheelEventHandler = customWheelEventHandler;
939
+ }
940
+
941
+ // ==========================================================================
942
+ // Link Detection Methods
943
+ // ==========================================================================
944
+
945
+ /**
946
+ * Register a custom link provider
947
+ * Multiple providers can be registered to detect different types of links
948
+ *
949
+ * @example
950
+ * ```typescript
951
+ * term.registerLinkProvider({
952
+ * provideLinks(y, callback) {
953
+ * // Detect URLs, file paths, etc.
954
+ * callback(detectedLinks);
955
+ * }
956
+ * });
957
+ * ```
958
+ */
959
+ public registerLinkProvider(provider: ILinkProvider): void {
960
+ if (!this.linkDetector) {
961
+ throw new Error('Terminal must be opened before registering link providers');
962
+ }
963
+ this.linkDetector.registerProvider(provider);
964
+ }
965
+
966
+ // ==========================================================================
967
+ // Phase 2: Scrolling Methods
968
+ // ==========================================================================
969
+
970
+ /**
971
+ * Scroll viewport by a number of lines
972
+ * @param amount Number of lines to scroll (positive = down, negative = up)
973
+ */
974
+ public scrollLines(amount: number): void {
975
+ this.wake();
976
+ if (!this.wasmTerm) {
977
+ throw new Error('Terminal not open');
978
+ }
979
+
980
+ const scrollbackLength = this.getScrollbackLength();
981
+ const maxScroll = scrollbackLength;
982
+
983
+ // Calculate new viewport position
984
+ // viewportY = 0 means at bottom (no scroll)
985
+ // viewportY > 0 means scrolled up into history
986
+ // amount < 0 (scroll up) should INCREASE viewportY
987
+ // amount > 0 (scroll down) should DECREASE viewportY
988
+ // So we SUBTRACT amount (negative amount becomes positive change)
989
+ const newViewportY = Math.max(0, Math.min(maxScroll, this.viewportY - amount));
990
+
991
+ if (newViewportY !== this.viewportY) {
992
+ this.viewportY = newViewportY;
993
+ this.fireScroll();
994
+
995
+ // Show scrollbar when scrolling (with auto-hide)
996
+ if (scrollbackLength > 0) {
997
+ this.showScrollbar();
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * Scroll viewport by a number of pages
1004
+ * @param amount Number of pages to scroll (positive = down, negative = up)
1005
+ */
1006
+ public scrollPages(amount: number): void {
1007
+ this.scrollLines(amount * this.rows);
1008
+ }
1009
+
1010
+ /**
1011
+ * Scroll viewport to the top of the scrollback buffer
1012
+ */
1013
+ public scrollToTop(): void {
1014
+ this.wake();
1015
+ const scrollbackLength = this.getScrollbackLength();
1016
+ if (scrollbackLength > 0 && this.viewportY !== scrollbackLength) {
1017
+ this.viewportY = scrollbackLength;
1018
+ this.fireScroll();
1019
+ this.showScrollbar();
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Scroll viewport to the bottom (current output)
1025
+ */
1026
+ public scrollToBottom(): void {
1027
+ this.wake();
1028
+ if (this.viewportY !== 0) {
1029
+ this.viewportY = 0;
1030
+ this.fireScroll();
1031
+ // Show scrollbar briefly when scrolling to bottom
1032
+ if (this.getScrollbackLength() > 0) {
1033
+ this.showScrollbar();
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Scroll viewport to a specific line in the buffer
1040
+ * @param line Line number (0 = top of scrollback, scrollbackLength = bottom)
1041
+ */
1042
+ /** Scrolls so that absolute buffer line `line` (0 = oldest scrollback line) is at the top, like xterm.js. */
1043
+ public scrollToLine(line: number): void {
1044
+ this.wake();
1045
+ const scrollbackLength = this.getScrollbackLength();
1046
+ const top = Math.max(0, Math.min(scrollbackLength, Math.round(line)));
1047
+ const newViewportY = scrollbackLength - top;
1048
+
1049
+ if (newViewportY !== this.viewportY) {
1050
+ this.viewportY = newViewportY;
1051
+ this.fireScroll();
1052
+
1053
+ // Show scrollbar when scrolling to specific line
1054
+ if (scrollbackLength > 0) {
1055
+ this.showScrollbar();
1056
+ }
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Smoothly scroll to a target viewport position
1062
+ * @param targetY Target viewport Y position (in lines, can be fractional)
1063
+ */
1064
+ private smoothScrollTo(targetY: number): void {
1065
+ if (!this.wasmTerm) return;
1066
+
1067
+ const scrollbackLength = this.getScrollbackLength();
1068
+ const maxScroll = scrollbackLength;
1069
+
1070
+ // Clamp target to valid range
1071
+ const newTarget = Math.max(0, Math.min(maxScroll, targetY));
1072
+
1073
+ // If smooth scrolling is disabled (duration = 0), jump immediately
1074
+ const duration = this.options.smoothScrollDuration ?? 100;
1075
+ if (duration === 0) {
1076
+ this.viewportY = newTarget;
1077
+ this.targetViewportY = newTarget;
1078
+ this.fireScroll();
1079
+
1080
+ if (scrollbackLength > 0) {
1081
+ this.showScrollbar();
1082
+ }
1083
+ return;
1084
+ }
1085
+
1086
+ // Update target (accumulate if animation running)
1087
+ this.targetViewportY = newTarget;
1088
+
1089
+ // If animation is already running, don't restart it
1090
+ // Just let it continue toward the updated target
1091
+ // This prevents choppy restarts during continuous scrolling
1092
+ if (this.scrollAnimationFrame) {
1093
+ return;
1094
+ }
1095
+
1096
+ // Start new animation
1097
+ this.scrollAnimationStartTime = Date.now();
1098
+ this.animateScroll();
1099
+ }
1100
+
1101
+ /**
1102
+ * Animation loop for smooth scrolling
1103
+ * Uses asymptotic approach - moves a fraction of remaining distance each frame
1104
+ */
1105
+ private animateScroll = (): void => {
1106
+ this.wake();
1107
+ if (!this.wasmTerm || this.scrollAnimationStartTime === undefined) {
1108
+ return;
1109
+ }
1110
+
1111
+ const duration = this.options.smoothScrollDuration ?? 100;
1112
+
1113
+ // Calculate distance to target
1114
+ const distance = this.targetViewportY - this.viewportY;
1115
+ const absDistance = Math.abs(distance);
1116
+
1117
+ // If very close, snap to target
1118
+ if (absDistance < 0.01) {
1119
+ this.viewportY = this.targetViewportY;
1120
+ this.fireScroll();
1121
+
1122
+ const scrollbackLength = this.getScrollbackLength();
1123
+ if (scrollbackLength > 0) {
1124
+ this.showScrollbar();
1125
+ }
1126
+
1127
+ // Animation complete
1128
+ this.scrollAnimationFrame = undefined;
1129
+ this.scrollAnimationStartTime = undefined;
1130
+ return;
1131
+ }
1132
+
1133
+ // Move a fraction of the remaining distance
1134
+ // At 60fps, move ~1/6 of distance per frame for ~100ms total duration
1135
+ // This creates smooth deceleration toward target
1136
+ const framesForDuration = (duration / 1000) * 60; // Convert ms to frame count
1137
+ const moveRatio = 1 - (1 / framesForDuration) ** 2; // Ease-out
1138
+ this.viewportY += distance * moveRatio;
1139
+
1140
+ // Fire scroll event
1141
+ this.fireScroll();
1142
+
1143
+ // Show scrollbar during animation
1144
+ const scrollbackLength = this.getScrollbackLength();
1145
+ if (scrollbackLength > 0) {
1146
+ this.showScrollbar();
1147
+ }
1148
+
1149
+ // Continue animation
1150
+ this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll);
1151
+ };
1152
+
1153
+ // ==========================================================================
1154
+ // Lifecycle
1155
+ // ==========================================================================
1156
+
1157
+ /**
1158
+ * Dispose terminal and clean up resources
1159
+ */
1160
+ dispose(): void {
1161
+ if (this.isDisposed) {
1162
+ return;
1163
+ }
1164
+
1165
+ this.isDisposed = true;
1166
+ this.isOpen = false;
1167
+
1168
+ // Stop render loop and clear write queue
1169
+ this.cancelRenderLoop();
1170
+ this.writeQueue.length = 0;
1171
+
1172
+ // Stop smooth scroll animation
1173
+ if (this.scrollAnimationFrame) {
1174
+ cancelAnimationFrame(this.scrollAnimationFrame);
1175
+ this.scrollAnimationFrame = undefined;
1176
+ }
1177
+
1178
+ // Clear mouse move throttle timeout
1179
+ if (this.mouseMoveThrottleTimeout) {
1180
+ clearTimeout(this.mouseMoveThrottleTimeout);
1181
+ this.mouseMoveThrottleTimeout = undefined;
1182
+ }
1183
+ this.pendingMouseMove = undefined;
1184
+
1185
+ // Dispose addons
1186
+ for (const addon of this.addons) {
1187
+ addon.dispose();
1188
+ }
1189
+ this.addons = [];
1190
+
1191
+ // Clean up components
1192
+ this.cleanupComponents();
1193
+
1194
+ // Dispose event emitters
1195
+ this.dataEmitter.dispose();
1196
+ this.resizeEmitter.dispose();
1197
+ this.bellEmitter.dispose();
1198
+ this.selectionChangeEmitter.dispose();
1199
+ this.keyEmitter.dispose();
1200
+ this.titleChangeEmitter.dispose();
1201
+ this.scrollEmitter.dispose();
1202
+ this.renderEmitter.dispose();
1203
+ this.cursorMoveEmitter.dispose();
1204
+ }
1205
+
1206
+ // ==========================================================================
1207
+ // Private Methods
1208
+ // ==========================================================================
1209
+
1210
+ /**
1211
+ * Cancel the render loop
1212
+ */
1213
+ private cancelRenderLoop(): void {
1214
+ if (this.animationFrameId) {
1215
+ cancelAnimationFrame(this.animationFrameId);
1216
+ this.animationFrameId = undefined;
1217
+ }
1218
+ }
1219
+
1220
+ /**
1221
+ * Flush any writes that were queued during resize
1222
+ */
1223
+ private flushWriteQueue(): void {
1224
+ while (this.writeQueue.length > 0) {
1225
+ const data = this.writeQueue.shift()!;
1226
+ this.wasmTerm!.write(data);
1227
+ }
1228
+ }
1229
+
1230
+ /**
1231
+ * Start the render loop
1232
+ */
1233
+ private startRenderLoop(): void {
1234
+ if (this.animationFrameId) return;
1235
+ const loop = () => {
1236
+ this.animationFrameId = undefined;
1237
+ if (this.isDisposed || !this.isOpen) return;
1238
+ const drew = this.wasmTerm!.update() !== DirtyState.NONE;
1239
+ this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity);
1240
+ if (drew) this.renderEmitter.fire({ start: 0, end: this.rows - 1 });
1241
+ const cursor = this.wasmTerm!.getCursor();
1242
+ if (cursor.y !== this.lastCursorY) {
1243
+ this.lastCursorY = cursor.y;
1244
+ this.cursorMoveEmitter.fire();
1245
+ }
1246
+ if (this.idleFrames++ < Terminal.IDLE_FRAMES_BEFORE_SLEEP) {
1247
+ this.animationFrameId = requestAnimationFrame(loop);
1248
+ }
1249
+ };
1250
+ this.animationFrameId = requestAnimationFrame(loop);
1251
+ }
1252
+
1253
+ /**
1254
+ * Get a line from native WASM scrollback buffer
1255
+ * Implements IScrollbackProvider
1256
+ */
1257
+ public getScrollbackLine(offset: number): GhosttyCell[] | null {
1258
+ if (!this.wasmTerm) return null;
1259
+ return this.wasmTerm.getScrollbackLine(offset);
1260
+ }
1261
+
1262
+ /**
1263
+ * Get scrollback length from native WASM
1264
+ * Implements IScrollbackProvider
1265
+ */
1266
+ public getScrollbackLength(): number {
1267
+ if (!this.wasmTerm) return 0;
1268
+ return this.wasmTerm.getScrollbackLength();
1269
+ }
1270
+
1271
+ /**
1272
+ * Clean up components (called on dispose or error)
1273
+ */
1274
+ private cleanupComponents(): void {
1275
+ // Dispose selection manager
1276
+ if (this.selectionManager) {
1277
+ this.selectionManager.dispose();
1278
+ this.selectionManager = undefined;
1279
+ }
1280
+
1281
+ // Dispose input handler
1282
+ if (this.inputHandler) {
1283
+ this.inputHandler.dispose();
1284
+ this.inputHandler = undefined;
1285
+ }
1286
+
1287
+ // Dispose renderer
1288
+ if (this.renderer) {
1289
+ this.renderer.dispose();
1290
+ this.renderer = undefined;
1291
+ }
1292
+
1293
+ // Remove canvas from DOM
1294
+ if (this.canvas && this.canvas.parentNode) {
1295
+ this.canvas.parentNode.removeChild(this.canvas);
1296
+ this.canvas = undefined;
1297
+ }
1298
+
1299
+ // Remove textarea from DOM
1300
+ if (this.textarea && this.textarea.parentNode) {
1301
+ this.textarea.parentNode.removeChild(this.textarea);
1302
+ this.textarea = undefined;
1303
+ }
1304
+
1305
+ // Remove event listeners
1306
+ if (this.element) {
1307
+ this.element.removeEventListener('wheel', this.handleWheel);
1308
+ this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true });
1309
+ this.element.removeEventListener('mousemove', this.handleMouseMove);
1310
+ this.element.removeEventListener('mouseleave', this.handleMouseLeave);
1311
+ this.element.removeEventListener('click', this.handleClick);
1312
+
1313
+ // Remove contenteditable and accessibility attributes added in open()
1314
+ this.element.removeAttribute('contenteditable');
1315
+ this.element.removeAttribute('role');
1316
+ this.element.removeAttribute('aria-label');
1317
+ this.element.removeAttribute('aria-multiline');
1318
+ }
1319
+
1320
+ // Remove document-level listeners (only if opened)
1321
+ if (this.isOpen && typeof document !== 'undefined') {
1322
+ document.removeEventListener('mouseup', this.handleMouseUp);
1323
+ }
1324
+
1325
+ // Clean up scrollbar timers
1326
+ if (this.scrollbarHideTimeout) {
1327
+ window.clearTimeout(this.scrollbarHideTimeout);
1328
+ this.scrollbarHideTimeout = undefined;
1329
+ }
1330
+
1331
+ // Dispose link detector
1332
+ if (this.linkDetector) {
1333
+ this.linkDetector.dispose();
1334
+ this.linkDetector = undefined;
1335
+ }
1336
+
1337
+ // Free WASM terminal
1338
+ if (this.wasmTerm) {
1339
+ this.wasmTerm.free();
1340
+ this.wasmTerm = undefined;
1341
+ }
1342
+
1343
+ // Clear references
1344
+ this.ghostty = undefined;
1345
+ this.element = undefined;
1346
+ this.textarea = undefined;
1347
+ }
1348
+
1349
+ /**
1350
+ * Assert terminal is open (throw if not)
1351
+ */
1352
+ private assertOpen(): void {
1353
+ if (this.isDisposed) {
1354
+ throw new Error('Terminal has been disposed');
1355
+ }
1356
+ if (!this.isOpen) {
1357
+ throw new Error('Terminal must be opened before use. Call terminal.open(parent) first.');
1358
+ }
1359
+ }
1360
+
1361
+ /**
1362
+ * Handle mouse move for link hover detection and scrollbar dragging
1363
+ * Throttled to avoid blocking scroll events (except when dragging scrollbar)
1364
+ */
1365
+ private handleMouseMove = (e: MouseEvent): void => {
1366
+ this.wake();
1367
+ if (!this.canvas || !this.renderer || !this.wasmTerm) return;
1368
+
1369
+ // If dragging scrollbar, handle immediately without throttling
1370
+ if (this.isDraggingScrollbar) {
1371
+ this.processScrollbarDrag(e);
1372
+ return;
1373
+ }
1374
+
1375
+ if (!this.linkDetector) return;
1376
+
1377
+ // Throttle to ~60fps (16ms) to avoid blocking scroll/other events
1378
+ if (this.mouseMoveThrottleTimeout) {
1379
+ this.pendingMouseMove = e;
1380
+ return;
1381
+ }
1382
+
1383
+ this.processMouseMove(e);
1384
+
1385
+ this.mouseMoveThrottleTimeout = window.setTimeout(() => {
1386
+ this.mouseMoveThrottleTimeout = undefined;
1387
+ if (this.pendingMouseMove) {
1388
+ const pending = this.pendingMouseMove;
1389
+ this.pendingMouseMove = undefined;
1390
+ this.processMouseMove(pending);
1391
+ }
1392
+ }, 16);
1393
+ };
1394
+
1395
+ /**
1396
+ * Process mouse move for link detection (internal, called by throttled handler)
1397
+ */
1398
+ private processMouseMove(e: MouseEvent): void {
1399
+ if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return;
1400
+
1401
+ // Convert mouse coordinates to terminal cell position
1402
+ const rect = this.canvas.getBoundingClientRect();
1403
+ const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth);
1404
+ const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight);
1405
+
1406
+ // Get hyperlink_id directly from the cell at this position
1407
+ // Must account for viewportY (scrollback position)
1408
+ const viewportRow = y; // Row in the viewport (0 to rows-1)
1409
+ let hyperlinkId = 0;
1410
+
1411
+ // When scrolled, fetch from scrollback or screen based on position
1412
+ // NOTE: viewportY may be fractional during smooth scrolling. The renderer
1413
+ // uses Math.floor(viewportY) when mapping viewport rows to scrollback vs
1414
+ // screen; we mirror that logic here so link hit-testing matches what the
1415
+ // user sees on screen.
1416
+ let line: GhosttyCell[] | null = null;
1417
+ const rawViewportY = this.getViewportY();
1418
+ const viewportY = Math.max(0, Math.floor(rawViewportY));
1419
+ if (viewportY > 0) {
1420
+ const scrollbackLength = this.wasmTerm.getScrollbackLength();
1421
+ if (viewportRow < viewportY) {
1422
+ // Mouse is over scrollback content
1423
+ const scrollbackOffset = scrollbackLength - viewportY + viewportRow;
1424
+ line = this.wasmTerm.getScrollbackLine(scrollbackOffset);
1425
+ } else {
1426
+ // Mouse is over screen content (bottom part of viewport)
1427
+ const screenRow = viewportRow - viewportY;
1428
+ line = this.wasmTerm.getLine(screenRow);
1429
+ }
1430
+ } else {
1431
+ // At bottom - just use screen buffer
1432
+ line = this.wasmTerm.getLine(viewportRow);
1433
+ }
1434
+
1435
+ if (line && x >= 0 && x < line.length) {
1436
+ hyperlinkId = line[x].hyperlink_id;
1437
+ }
1438
+
1439
+ // Update renderer for underline rendering
1440
+ const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0;
1441
+ if (hyperlinkId !== previousHyperlinkId) {
1442
+ this.renderer.setHoveredHyperlinkId(hyperlinkId);
1443
+
1444
+ // The 60fps render loop will pick up the change automatically
1445
+ // No need to force a render - this keeps performance smooth
1446
+ }
1447
+
1448
+ // Check if there's a link at this position (for click handling and cursor)
1449
+ // Buffer API expects absolute buffer coordinates (including scrollback)
1450
+ // When scrolled, we need to adjust the buffer row based on viewportY
1451
+ const scrollbackLength = this.wasmTerm.getScrollbackLength();
1452
+ let bufferRow: number;
1453
+
1454
+ // Use floored viewportY for buffer mapping (must match renderer & selection)
1455
+ const rawViewportYForBuffer = this.getViewportY();
1456
+ const viewportYForBuffer = Math.max(0, Math.floor(rawViewportYForBuffer));
1457
+
1458
+ if (viewportYForBuffer > 0) {
1459
+ // When scrolled, the buffer row depends on where in the viewport we are
1460
+ if (viewportRow < viewportYForBuffer) {
1461
+ // Mouse is over scrollback content
1462
+ bufferRow = scrollbackLength - viewportYForBuffer + viewportRow;
1463
+ } else {
1464
+ // Mouse is over screen content (bottom part of viewport)
1465
+ const screenRow = viewportRow - viewportYForBuffer;
1466
+ bufferRow = scrollbackLength + screenRow;
1467
+ }
1468
+ } else {
1469
+ // At bottom - buffer row is scrollback + screen row
1470
+ bufferRow = scrollbackLength + viewportRow;
1471
+ }
1472
+
1473
+ // Make async call non-blocking - don't await
1474
+ this.linkDetector
1475
+ .getLinkAt(x, bufferRow)
1476
+ .then((link) => {
1477
+ // Update hover state for cursor changes and click handling
1478
+ if (link !== this.currentHoveredLink) {
1479
+ // Notify old link we're leaving
1480
+ this.currentHoveredLink?.hover?.(false);
1481
+
1482
+ // Update current link
1483
+ this.currentHoveredLink = link;
1484
+
1485
+ // Notify new link we're entering
1486
+ link?.hover?.(true);
1487
+
1488
+ // Update cursor style on both container and canvas
1489
+ const cursorStyle = link ? 'pointer' : 'text';
1490
+ if (this.element) {
1491
+ this.element.style.cursor = cursorStyle;
1492
+ }
1493
+ if (this.canvas) {
1494
+ this.canvas.style.cursor = cursorStyle;
1495
+ }
1496
+
1497
+ // Update renderer for underline (for regex URLs without hyperlink_id)
1498
+ if (this.renderer) {
1499
+ if (link) {
1500
+ // Convert buffer coordinates to viewport coordinates
1501
+ const scrollbackLength = this.wasmTerm?.getScrollbackLength() || 0;
1502
+
1503
+ // Calculate viewport Y for start and end positions
1504
+ // Use floored viewportY so overlay rows match renderer & selection
1505
+ const rawViewportYForLinks = this.getViewportY();
1506
+ const viewportYForLinks = Math.max(0, Math.floor(rawViewportYForLinks));
1507
+ const startViewportY = link.range.start.y - scrollbackLength + viewportYForLinks;
1508
+ const endViewportY = link.range.end.y - scrollbackLength + viewportYForLinks;
1509
+
1510
+ // Only show underline if link is visible in viewport
1511
+ if (startViewportY < this.rows && endViewportY >= 0) {
1512
+ this.renderer.setHoveredLinkRange({
1513
+ startX: link.range.start.x,
1514
+ startY: Math.max(0, startViewportY),
1515
+ endX: link.range.end.x,
1516
+ endY: Math.min(this.rows - 1, endViewportY),
1517
+ });
1518
+ } else {
1519
+ this.renderer.setHoveredLinkRange(null);
1520
+ }
1521
+ } else {
1522
+ this.renderer.setHoveredLinkRange(null);
1523
+ }
1524
+ }
1525
+ }
1526
+ })
1527
+ .catch((err) => {
1528
+ console.warn('Link detection error:', err);
1529
+ });
1530
+ }
1531
+
1532
+ /**
1533
+ * Handle mouse leave to clear link hover
1534
+ */
1535
+ private handleMouseLeave = (): void => {
1536
+ this.wake();
1537
+ // Clear hyperlink underline
1538
+ if (this.renderer && this.wasmTerm) {
1539
+ const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0;
1540
+ if (previousHyperlinkId > 0) {
1541
+ this.renderer.setHoveredHyperlinkId(0);
1542
+
1543
+ // The 60fps render loop will pick up the change automatically
1544
+ }
1545
+ // Clear regex link underline
1546
+ this.renderer.setHoveredLinkRange(null);
1547
+ }
1548
+
1549
+ if (this.currentHoveredLink) {
1550
+ // Notify link we're leaving
1551
+ this.currentHoveredLink.hover?.(false);
1552
+
1553
+ // Clear hovered link
1554
+ this.currentHoveredLink = undefined;
1555
+
1556
+ // Reset cursor
1557
+ if (this.element) {
1558
+ this.element.style.cursor = 'text';
1559
+ if (this.canvas) {
1560
+ this.canvas.style.cursor = 'text';
1561
+ }
1562
+ }
1563
+ }
1564
+ };
1565
+
1566
+ /**
1567
+ * Handle mouse click for link activation
1568
+ */
1569
+ private handleClick = async (e: MouseEvent): Promise<void> => {
1570
+ this.wake();
1571
+ // For more reliable clicking, detect the link at click time
1572
+ // rather than relying on cached hover state (avoids async races)
1573
+ if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return;
1574
+
1575
+ // Get click position
1576
+ const rect = this.canvas.getBoundingClientRect();
1577
+ const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth);
1578
+ const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight);
1579
+
1580
+ // Calculate buffer row (same logic as processMouseMove)
1581
+ const viewportRow = y;
1582
+ const scrollbackLength = this.wasmTerm.getScrollbackLength();
1583
+ let bufferRow: number;
1584
+
1585
+ // Use floored viewportY for buffer mapping (must match renderer & selection)
1586
+ const rawViewportYForClick = this.getViewportY();
1587
+ const viewportYForClick = Math.max(0, Math.floor(rawViewportYForClick));
1588
+
1589
+ if (viewportYForClick > 0) {
1590
+ if (viewportRow < viewportYForClick) {
1591
+ bufferRow = scrollbackLength - viewportYForClick + viewportRow;
1592
+ } else {
1593
+ const screenRow = viewportRow - viewportYForClick;
1594
+ bufferRow = scrollbackLength + screenRow;
1595
+ }
1596
+ } else {
1597
+ bufferRow = scrollbackLength + viewportRow;
1598
+ }
1599
+
1600
+ // Get the link at this position
1601
+ const link = await this.linkDetector.getLinkAt(x, bufferRow);
1602
+
1603
+ if (link) {
1604
+ // Activate link
1605
+ link.activate(e);
1606
+
1607
+ // Prevent default action if modifier key held
1608
+ if (e.ctrlKey || e.metaKey) {
1609
+ e.preventDefault();
1610
+ }
1611
+ }
1612
+ };
1613
+
1614
+ /**
1615
+ * Handle wheel events for scrolling (Phase 2)
1616
+ */
1617
+ private handleWheel = (e: WheelEvent): void => {
1618
+ this.wake();
1619
+ // Always prevent default browser scrolling
1620
+ e.preventDefault();
1621
+ e.stopPropagation();
1622
+
1623
+ // Allow custom handler to override
1624
+ if (this.customWheelEventHandler && this.customWheelEventHandler(e)) {
1625
+ return;
1626
+ }
1627
+
1628
+ // Check if in alternate screen mode (vim, less, htop, etc.)
1629
+ const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false;
1630
+
1631
+ if (isAltScreen) {
1632
+ // Alternate screen: send arrow keys to the application
1633
+ // Applications like vim handle scrolling internally
1634
+ // Standard: ~3 arrow presses per wheel "click"
1635
+ const direction = e.deltaY > 0 ? 'down' : 'up';
1636
+ const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); // Cap at 5
1637
+
1638
+ for (let i = 0; i < count; i++) {
1639
+ if (direction === 'up') {
1640
+ this.dataEmitter.fire('\x1B[A'); // Up arrow
1641
+ } else {
1642
+ this.dataEmitter.fire('\x1B[B'); // Down arrow
1643
+ }
1644
+ }
1645
+ } else {
1646
+ // Normal screen: scroll viewport through history with smooth scrolling
1647
+ // Handle different deltaMode values for better trackpad/mouse support
1648
+ let deltaLines: number;
1649
+
1650
+ if (e.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
1651
+ // Pixel mode (trackpads): convert pixels to lines
1652
+ // Use actual line height from renderer for accurate conversion
1653
+ const lineHeight = this.renderer?.getMetrics()?.height ?? 20;
1654
+ deltaLines = e.deltaY / lineHeight;
1655
+ } else if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) {
1656
+ // Line mode (some mice): use directly
1657
+ deltaLines = e.deltaY;
1658
+ } else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
1659
+ // Page mode (rare): convert pages to lines
1660
+ deltaLines = e.deltaY * this.rows;
1661
+ } else {
1662
+ // Fallback: assume pixel mode with legacy divisor
1663
+ deltaLines = e.deltaY / 33;
1664
+ }
1665
+
1666
+ // Use smooth scrolling for any amount (no rounding needed)
1667
+ if (deltaLines !== 0) {
1668
+ // Calculate target position
1669
+ // deltaY > 0 = scroll down (decrease viewportY)
1670
+ // deltaY < 0 = scroll up (increase viewportY)
1671
+ const targetY = this.viewportY - deltaLines;
1672
+ this.smoothScrollTo(targetY);
1673
+ }
1674
+ }
1675
+ };
1676
+
1677
+ /**
1678
+ * Handle mouse down for scrollbar interaction
1679
+ */
1680
+ private handleMouseDown = (e: MouseEvent): void => {
1681
+ this.wake();
1682
+ if (!this.canvas || !this.renderer || !this.wasmTerm) return;
1683
+
1684
+ const scrollbackLength = this.wasmTerm.getScrollbackLength();
1685
+ if (scrollbackLength === 0) return; // No scrollbar if no scrollback
1686
+
1687
+ const rect = this.canvas.getBoundingClientRect();
1688
+ const mouseX = e.clientX - rect.left;
1689
+ const mouseY = e.clientY - rect.top;
1690
+
1691
+ // Calculate scrollbar dimensions (match renderer's logic)
1692
+ // Use rect dimensions which are already in CSS pixels
1693
+ const canvasWidth = rect.width;
1694
+ const canvasHeight = rect.height;
1695
+ const scrollbarWidth = 8;
1696
+ const scrollbarX = canvasWidth - scrollbarWidth - 4;
1697
+ const scrollbarPadding = 4;
1698
+
1699
+ // Check if click is in scrollbar area
1700
+ if (mouseX >= scrollbarX && mouseX <= scrollbarX + scrollbarWidth) {
1701
+ // Prevent default and stop propagation to prevent text selection
1702
+ e.preventDefault();
1703
+ e.stopPropagation();
1704
+ e.stopImmediatePropagation(); // Stop SelectionManager from seeing this event
1705
+
1706
+ // Calculate scrollbar thumb position and size
1707
+ const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2;
1708
+ const visibleRows = this.rows;
1709
+ const totalLines = scrollbackLength + visibleRows;
1710
+ const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight);
1711
+ const scrollPosition = this.viewportY / scrollbackLength;
1712
+ const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition);
1713
+
1714
+ // Check if click is on thumb
1715
+ if (mouseY >= thumbY && mouseY <= thumbY + thumbHeight) {
1716
+ // Start dragging thumb
1717
+ this.isDraggingScrollbar = true;
1718
+ this.scrollbarDragStart = mouseY;
1719
+ this.scrollbarDragStartViewportY = this.viewportY;
1720
+
1721
+ // Prevent text selection during drag
1722
+ if (this.canvas) {
1723
+ this.canvas.style.userSelect = 'none';
1724
+ this.canvas.style.webkitUserSelect = 'none';
1725
+ }
1726
+ } else {
1727
+ // Click on track - jump to position
1728
+ const relativeY = mouseY - scrollbarPadding;
1729
+ const scrollFraction = 1 - relativeY / scrollbarTrackHeight; // Inverted: top = 1, bottom = 0
1730
+ const targetViewportY = Math.round(scrollFraction * scrollbackLength);
1731
+ this.scrollToLine(Math.max(0, Math.min(scrollbackLength, targetViewportY)));
1732
+ }
1733
+ }
1734
+ };
1735
+
1736
+ /**
1737
+ * Handle mouse up for scrollbar drag
1738
+ */
1739
+ private handleMouseUp = (): void => {
1740
+ this.wake();
1741
+ if (this.isDraggingScrollbar) {
1742
+ this.isDraggingScrollbar = false;
1743
+ this.scrollbarDragStart = null;
1744
+
1745
+ // Restore text selection
1746
+ if (this.canvas) {
1747
+ this.canvas.style.userSelect = '';
1748
+ this.canvas.style.webkitUserSelect = '';
1749
+ }
1750
+
1751
+ // Schedule auto-hide after drag ends
1752
+ if (this.scrollbarVisible && this.getScrollbackLength() > 0) {
1753
+ this.showScrollbar(); // Reset the hide timer
1754
+ }
1755
+ }
1756
+ };
1757
+
1758
+ /**
1759
+ * Process scrollbar drag movement
1760
+ */
1761
+ private processScrollbarDrag(e: MouseEvent): void {
1762
+ if (!this.canvas || !this.renderer || !this.wasmTerm || this.scrollbarDragStart === null)
1763
+ return;
1764
+
1765
+ const scrollbackLength = this.wasmTerm.getScrollbackLength();
1766
+ if (scrollbackLength === 0) return;
1767
+
1768
+ const rect = this.canvas.getBoundingClientRect();
1769
+ const mouseY = e.clientY - rect.top;
1770
+
1771
+ // Calculate how much the mouse moved
1772
+ const deltaY = mouseY - this.scrollbarDragStart;
1773
+
1774
+ // Convert mouse delta to viewport delta
1775
+ // Use rect height which is already in CSS pixels
1776
+ const canvasHeight = rect.height;
1777
+ const scrollbarPadding = 4;
1778
+ const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2;
1779
+ const visibleRows = this.rows;
1780
+ const totalLines = scrollbackLength + visibleRows;
1781
+ const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight);
1782
+
1783
+ // Calculate scroll fraction from thumb movement
1784
+ // Note: thumb moves in opposite direction to viewport (thumb down = scroll down = viewportY decreases)
1785
+ const scrollFraction = -deltaY / (scrollbarTrackHeight - thumbHeight);
1786
+ const viewportDelta = Math.round(scrollFraction * scrollbackLength);
1787
+
1788
+ const newViewportY = this.scrollbarDragStartViewportY + viewportDelta;
1789
+ this.scrollToLine(Math.max(0, Math.min(scrollbackLength, newViewportY)));
1790
+ }
1791
+
1792
+ /**
1793
+ * Show scrollbar with fade-in and schedule auto-hide
1794
+ */
1795
+ private showScrollbar(): void {
1796
+ this.wake();
1797
+ // Clear any existing hide timeout
1798
+ if (this.scrollbarHideTimeout) {
1799
+ window.clearTimeout(this.scrollbarHideTimeout);
1800
+ this.scrollbarHideTimeout = undefined;
1801
+ }
1802
+
1803
+ // If not visible, start fade-in
1804
+ if (!this.scrollbarVisible) {
1805
+ this.scrollbarVisible = true;
1806
+ this.scrollbarOpacity = 0;
1807
+ this.fadeInScrollbar();
1808
+ } else {
1809
+ // Already visible, just ensure it's fully opaque
1810
+ this.scrollbarOpacity = 1;
1811
+ }
1812
+
1813
+ // Schedule auto-hide (unless dragging)
1814
+ if (!this.isDraggingScrollbar) {
1815
+ this.scrollbarHideTimeout = window.setTimeout(() => {
1816
+ this.hideScrollbar();
1817
+ }, this.SCROLLBAR_HIDE_DELAY_MS);
1818
+ }
1819
+ }
1820
+
1821
+ /**
1822
+ * Hide scrollbar with fade-out
1823
+ */
1824
+ private hideScrollbar(): void {
1825
+ this.wake();
1826
+ if (this.scrollbarHideTimeout) {
1827
+ window.clearTimeout(this.scrollbarHideTimeout);
1828
+ this.scrollbarHideTimeout = undefined;
1829
+ }
1830
+
1831
+ if (this.scrollbarVisible) {
1832
+ this.fadeOutScrollbar();
1833
+ }
1834
+ }
1835
+
1836
+ /**
1837
+ * Fade in scrollbar
1838
+ */
1839
+ private fadeInScrollbar(): void {
1840
+ this.wake();
1841
+ const startTime = Date.now();
1842
+ const animate = () => {
1843
+ const elapsed = Date.now() - startTime;
1844
+ const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1);
1845
+ this.scrollbarOpacity = progress;
1846
+
1847
+ // Trigger render to show updated opacity
1848
+ if (this.renderer && this.wasmTerm) {
1849
+ this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
1850
+ }
1851
+
1852
+ if (progress < 1) {
1853
+ requestAnimationFrame(animate);
1854
+ }
1855
+ };
1856
+ animate();
1857
+ }
1858
+
1859
+ /**
1860
+ * Fade out scrollbar
1861
+ */
1862
+ private fadeOutScrollbar(): void {
1863
+ this.wake();
1864
+ const startTime = Date.now();
1865
+ const startOpacity = this.scrollbarOpacity;
1866
+ const animate = () => {
1867
+ const elapsed = Date.now() - startTime;
1868
+ const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1);
1869
+ this.scrollbarOpacity = startOpacity * (1 - progress);
1870
+
1871
+ // Trigger render to show updated opacity
1872
+ if (this.renderer && this.wasmTerm) {
1873
+ this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
1874
+ }
1875
+
1876
+ if (progress < 1) {
1877
+ requestAnimationFrame(animate);
1878
+ } else {
1879
+ this.scrollbarVisible = false;
1880
+ this.scrollbarOpacity = 0;
1881
+ // Final render to clear scrollbar completely
1882
+ if (this.renderer && this.wasmTerm) {
1883
+ this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0);
1884
+ }
1885
+ }
1886
+ };
1887
+ animate();
1888
+ }
1889
+
1890
+ /**
1891
+ * Process any pending terminal responses and emit them via onData.
1892
+ *
1893
+ * This handles escape sequences that require the terminal to send a response
1894
+ * back to the PTY, such as:
1895
+ * - DSR 6 (cursor position): Shell sends \x1b[6n, terminal responds with \x1b[row;colR
1896
+ * - DSR 5 (operating status): Shell sends \x1b[5n, terminal responds with \x1b[0n
1897
+ *
1898
+ * Without this, shells like nushell that rely on cursor position queries
1899
+ * will hang waiting for a response that never comes.
1900
+ *
1901
+ * Note: We loop to read all pending responses, not just one. This is important
1902
+ * when multiple queries are processed in a single write() call (e.g., when
1903
+ * buffered data is written all at once during terminal initialization).
1904
+ */
1905
+ private processTerminalResponses(): void {
1906
+ if (!this.wasmTerm) return;
1907
+
1908
+ // Read all pending responses from the WASM terminal
1909
+ // Multiple responses can be queued if a single write() contained multiple queries
1910
+ while (true) {
1911
+ const response = this.wasmTerm.readResponse();
1912
+ if (response === null) break;
1913
+ if (!this.answerQueries) continue;
1914
+ // Send response back to the PTY via onData
1915
+ // This is the same path as user keyboard input
1916
+ this.dataEmitter.fire(response);
1917
+ }
1918
+ }
1919
+
1920
+ // ============================================================================
1921
+ // Host hooks: markers, clipboard, modes
1922
+ // ============================================================================
1923
+
1924
+ /**
1925
+ * Adds a marker on the cursor's line plus `cursorYOffset`, like xterm.js.
1926
+ * Returns undefined when that line is outside the active area.
1927
+ */
1928
+ public registerMarker(cursorYOffset: number = 0): IMarker | undefined {
1929
+ if (!this.wasmTerm) return undefined;
1930
+ const y = this.wasmTerm.cursorPosition().y + cursorYOffset;
1931
+ if (y < 0 || y >= this.rows) return undefined;
1932
+ const row = this.wasmTerm.trackRow(y);
1933
+ if (!row) return undefined;
1934
+ const marker = new Marker(row);
1935
+ this.markers.add(marker);
1936
+ marker.onDispose(() => this.markers.delete(marker));
1937
+ return marker;
1938
+ }
1939
+
1940
+ /**
1941
+ * Decides OSC 52 clipboard writes synchronously (true allows the write).
1942
+ * Unset, every write is denied; clipboard reads are never answered.
1943
+ */
1944
+ public set clipboardWriteHandler(handler: ((text: string) => boolean) | null) {
1945
+ this.clipboardHandler = handler;
1946
+ if (this.wasmTerm) this.wasmTerm.clipboardWriteHandler = handler;
1947
+ }
1948
+
1949
+ public get clipboardWriteHandler(): ((text: string) => boolean) | null {
1950
+ return this.clipboardHandler;
1951
+ }
1952
+
1953
+ /** xterm.js-compatible terminal modes. */
1954
+ public get modes(): {
1955
+ mouseTrackingMode: MouseTrackingMode;
1956
+ bracketedPasteMode: boolean;
1957
+ applicationCursorKeysMode: boolean;
1958
+ sendFocusMode: boolean;
1959
+ } {
1960
+ return {
1961
+ mouseTrackingMode: this.wasmTerm?.mouseTrackingMode() ?? 'none',
1962
+ bracketedPasteMode: this.wasmTerm?.hasBracketedPaste() ?? false,
1963
+ applicationCursorKeysMode: this.wasmTerm?.getMode(1, false) ?? false,
1964
+ sendFocusMode: this.wasmTerm?.hasFocusEvents() ?? false,
1965
+ };
1966
+ }
1967
+
1968
+ /** Keeps the render loop running for a while; called whenever something may need drawing. */
1969
+ public wake(): void {
1970
+ this.idleFrames = 0;
1971
+ if (this.isOpen && !this.isDisposed) this.startRenderLoop();
1972
+ }
1973
+
1974
+ private wireEngine(): void {
1975
+ for (const sub of this.engineSubscriptions) sub.dispose();
1976
+ const engine = this.wasmTerm!;
1977
+ engine.clipboardWriteHandler = this.clipboardHandler;
1978
+ this.lastAlternate = engine.isAlternateScreen();
1979
+ this.engineSubscriptions = [
1980
+ engine.onBell(() => this.bellEmitter.fire()),
1981
+ engine.onTitleChange((title) => {
1982
+ if (title === this.currentTitle) return;
1983
+ this.currentTitle = title;
1984
+ this.titleChangeEmitter.fire(title);
1985
+ }),
1986
+ engine.onPwdChange((pwd) => this.pwdEmitter.fire(pwd)),
1987
+ engine.onSemanticPrompt((event) => this.semanticPromptEmitter.fire(event)),
1988
+ engine.onDesktopNotification((n) => this.desktopNotificationEmitter.fire(n)),
1989
+ engine.onUnknownOsc((content) => this.unknownOscEmitter.fire(content)),
1990
+ ];
1991
+ }
1992
+
1993
+ private afterWrite(): void {
1994
+ const engine = this.wasmTerm;
1995
+ if (!engine) return;
1996
+ const alternate = engine.isAlternateScreen();
1997
+ if (alternate !== this.lastAlternate) {
1998
+ this.lastAlternate = alternate;
1999
+ (this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
2000
+ }
2001
+ for (const marker of [...this.markers]) marker.refresh();
2002
+ this.wake();
2003
+ }
2004
+
2005
+ /** Fires onScroll with the absolute line at the top of the viewport, as xterm.js does. */
2006
+ private fireScroll(): void {
2007
+ this.scrollEmitter.fire(this.buffer.active.viewportY);
2008
+ }
2009
+
2010
+ private disposeMarkers(): void {
2011
+ for (const marker of [...this.markers]) marker.dispose();
2012
+ this.markers.clear();
2013
+ }
2014
+
2015
+ // ============================================================================
2016
+ // Terminal Modes
2017
+ // ============================================================================
2018
+
2019
+ /**
2020
+ * Query terminal mode state
2021
+ *
2022
+ * @param mode Mode number (e.g., 2004 for bracketed paste)
2023
+ * @param isAnsi True for ANSI modes, false for DEC modes (default: false)
2024
+ * @returns true if mode is enabled
2025
+ */
2026
+ public getMode(mode: number, isAnsi: boolean = false): boolean {
2027
+ this.assertOpen();
2028
+ return this.wasmTerm!.getMode(mode, isAnsi);
2029
+ }
2030
+
2031
+ /**
2032
+ * Check if bracketed paste mode is enabled
2033
+ */
2034
+ public hasBracketedPaste(): boolean {
2035
+ this.assertOpen();
2036
+ return this.wasmTerm!.hasBracketedPaste();
2037
+ }
2038
+
2039
+ /**
2040
+ * Check if focus event reporting is enabled
2041
+ */
2042
+ public hasFocusEvents(): boolean {
2043
+ this.assertOpen();
2044
+ return this.wasmTerm!.hasFocusEvents();
2045
+ }
2046
+
2047
+ /**
2048
+ * Check if mouse tracking is enabled
2049
+ */
2050
+ public hasMouseTracking(): boolean {
2051
+ this.assertOpen();
2052
+ return this.wasmTerm!.hasMouseTracking();
2053
+ }
2054
+ }