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