@aurigax-ai/ghostty-web 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/terminal.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  import { getGhostty } from './index';
31
31
  import { InputHandler, type MouseTrackingConfig } from './input-handler';
32
32
  import type {
33
+ FontWeight,
33
34
  IBufferNamespace,
34
35
  IBufferRange,
35
36
  IDisposable,
@@ -44,7 +45,13 @@ import { LinkDetector } from './link-detector';
44
45
  import { type IMarker, Marker } from './marker';
45
46
  import { OSC8LinkProvider } from './providers/osc8-link-provider';
46
47
  import { UrlRegexProvider } from './providers/url-regex-provider';
47
- import { CanvasRenderer, type RendererOptions, type TerminalRenderer } from './renderer';
48
+ import {
49
+ CanvasRenderer,
50
+ type RendererOptions,
51
+ type SearchHighlight,
52
+ type SearchHighlightColors,
53
+ type TerminalRenderer,
54
+ } from './renderer';
48
55
  import { SelectionManager } from './selection-manager';
49
56
  import type { ILink, ILinkProvider } from './types';
50
57
  import { WebglRenderer } from './webgl-renderer';
@@ -97,6 +104,7 @@ export class Terminal implements ITerminalCore {
97
104
  private scrollEmitter = new EventEmitter<number>();
98
105
  private renderEmitter = new EventEmitter<{ start: number; end: number }>();
99
106
  private cursorMoveEmitter = new EventEmitter<void>();
107
+ private writeParsedEmitter = new EventEmitter<void>();
100
108
  // Public event accessors (xterm.js compatibility)
101
109
  public readonly onData: IEvent<string> = this.dataEmitter.event;
102
110
  public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event;
@@ -107,6 +115,8 @@ export class Terminal implements ITerminalCore {
107
115
  public readonly onScroll: IEvent<number> = this.scrollEmitter.event;
108
116
  public readonly onRender: IEvent<{ start: number; end: number }> = this.renderEmitter.event;
109
117
  public readonly onCursorMove: IEvent<void> = this.cursorMoveEmitter.event;
118
+ /** Fires after written data was parsed, as xterm.js's onWriteParsed. */
119
+ public readonly onWriteParsed: IEvent<void> = this.writeParsedEmitter.event;
110
120
 
111
121
  private pwdEmitter = new EventEmitter<string>();
112
122
  private semanticPromptEmitter = new EventEmitter<SemanticPromptEvent>();
@@ -188,10 +198,17 @@ export class Terminal implements ITerminalCore {
188
198
  fontSize: options.fontSize ?? 15,
189
199
  fontFamily: options.fontFamily ?? 'monospace',
190
200
  allowTransparency: options.allowTransparency ?? false,
201
+ linkHandler: options.linkHandler ?? null,
191
202
  renderer: options.renderer ?? 'webgl',
192
203
  convertEol: options.convertEol ?? false,
193
204
  disableStdin: options.disableStdin ?? false,
194
205
  smoothScrollDuration: options.smoothScrollDuration ?? 100, // Default: 100ms smooth scroll
206
+ fontWeight: options.fontWeight ?? 'normal',
207
+ fontWeightBold: options.fontWeightBold ?? 'bold',
208
+ lineHeight: options.lineHeight ?? 1,
209
+ scrollSensitivity: options.scrollSensitivity ?? 1,
210
+ minimumContrastRatio: options.minimumContrastRatio ?? 1,
211
+ macOptionIsMeta: options.macOptionIsMeta ?? false,
195
212
  };
196
213
 
197
214
  // Wrap in Proxy to intercept runtime changes (xterm.js compatibility)
@@ -243,11 +260,37 @@ export class Terminal implements ITerminalCore {
243
260
  break;
244
261
 
245
262
  case 'theme':
263
+ this.wasmTerm?.configure(this.themeConfig());
264
+ if (this.renderer && this.wasmTerm) {
265
+ this.renderer.setTheme(this.options.theme);
266
+ this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
267
+ }
268
+ break;
269
+
270
+ case 'macOptionIsMeta':
271
+ if (this.inputHandler) this.inputHandler.macOptionIsMeta = this.options.macOptionIsMeta;
272
+ break;
273
+
274
+ case 'fontWeight':
275
+ case 'fontWeightBold':
276
+ case 'lineHeight':
246
277
  if (this.renderer) {
247
- console.warn('ghostty-web: theme changes after open() are not yet fully supported');
278
+ this.renderer.setFontOptions(this.fontOptions());
279
+ this.handleFontChange();
248
280
  }
249
281
  break;
250
282
 
283
+ case 'minimumContrastRatio':
284
+ if (this.renderer && this.wasmTerm) {
285
+ this.renderer.setMinimumContrastRatio(this.options.minimumContrastRatio);
286
+ this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
287
+ }
288
+ break;
289
+
290
+ case 'scrollback':
291
+ this.wasmTerm?.configure({ scrollbackLimit: this.options.scrollback });
292
+ break;
293
+
251
294
  case 'fontSize':
252
295
  if (this.renderer) {
253
296
  this.renderer.setFontSize(this.options.fontSize);
@@ -293,67 +336,48 @@ export class Terminal implements ITerminalCore {
293
336
  * Parse a CSS color string to 0xRRGGBB format.
294
337
  * Returns 0 if the color is undefined or invalid.
295
338
  */
296
- private parseColorToHex(color?: string): number {
297
- if (!color) return 0;
298
-
299
- // Handle hex colors (#RGB, #RRGGBB)
339
+ private parseColorToHex(color?: string): number | undefined {
340
+ if (!color) return undefined;
300
341
  if (color.startsWith('#')) {
301
342
  let hex = color.slice(1);
302
- if (hex.length === 3) {
343
+ if (hex.length === 3 || hex.length === 4) {
303
344
  hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
304
345
  }
305
- const value = Number.parseInt(hex, 16);
306
- return Number.isNaN(value) ? 0 : value;
346
+ const value = Number.parseInt(hex.slice(0, 6), 16);
347
+ return hex.length >= 6 && !Number.isNaN(value) ? value : undefined;
307
348
  }
308
-
309
- // Handle rgb(r, g, b) format
310
- const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
349
+ const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
311
350
  if (match) {
312
351
  const r = Number.parseInt(match[1], 10);
313
352
  const g = Number.parseInt(match[2], 10);
314
353
  const b = Number.parseInt(match[3], 10);
315
354
  return (r << 16) | (g << 8) | b;
316
355
  }
317
-
318
- return 0;
356
+ return undefined;
319
357
  }
320
358
 
321
- /**
322
- * Convert terminal options to WASM terminal config.
323
- */
324
- private buildWasmConfig(): GhosttyTerminalConfig | undefined {
359
+ /** Theme colors in the form libghostty-vt takes; a color the theme leaves out keeps Ghostty's default. */
360
+ private themeConfig(): GhosttyTerminalConfig {
325
361
  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
-
362
+ const palette = [
363
+ theme?.black,
364
+ theme?.red,
365
+ theme?.green,
366
+ theme?.yellow,
367
+ theme?.blue,
368
+ theme?.magenta,
369
+ theme?.cyan,
370
+ theme?.white,
371
+ theme?.brightBlack,
372
+ theme?.brightRed,
373
+ theme?.brightGreen,
374
+ theme?.brightYellow,
375
+ theme?.brightBlue,
376
+ theme?.brightMagenta,
377
+ theme?.brightCyan,
378
+ theme?.brightWhite,
379
+ ].map((color) => this.parseColorToHex(color));
355
380
  return {
356
- scrollbackLimit: scrollback,
357
381
  fgColor: this.parseColorToHex(theme?.foreground),
358
382
  bgColor: this.parseColorToHex(theme?.background),
359
383
  cursorColor: this.parseColorToHex(theme?.cursor),
@@ -361,6 +385,10 @@ export class Terminal implements ITerminalCore {
361
385
  };
362
386
  }
363
387
 
388
+ private buildWasmConfig(): GhosttyTerminalConfig {
389
+ return { ...this.themeConfig(), scrollbackLimit: this.options.scrollback };
390
+ }
391
+
364
392
  // ==========================================================================
365
393
  // Lifecycle Methods
366
394
  // ==========================================================================
@@ -506,6 +534,7 @@ export class Terminal implements ITerminalCore {
506
534
  this.textarea,
507
535
  mouseConfig
508
536
  );
537
+ this.inputHandler.macOptionIsMeta = this.options.macOptionIsMeta;
509
538
 
510
539
  // Create selection manager (pass textarea for context menu positioning)
511
540
  this.selectionManager = new SelectionManager(
@@ -537,6 +566,8 @@ export class Terminal implements ITerminalCore {
537
566
  parent.addEventListener('mousedown', this.handleMouseDown, { capture: true });
538
567
  parent.addEventListener('mousemove', this.handleMouseMove);
539
568
  parent.addEventListener('mouseleave', this.handleMouseLeave);
569
+ parent.addEventListener('focusin', this.handleFocusIn);
570
+ parent.addEventListener('focusout', this.handleFocusOut);
540
571
  parent.addEventListener('click', this.handleClick);
541
572
 
542
573
  // Setup document-level mouseup for scrollbar drag (so drag works even outside canvas)
@@ -551,9 +582,6 @@ export class Terminal implements ITerminalCore {
551
582
 
552
583
  // Start render loop
553
584
  this.startRenderLoop();
554
-
555
- // Focus input (auto-focus so user can start typing immediately)
556
- this.focus();
557
585
  } catch (error) {
558
586
  // Clean up on error
559
587
  this.isOpen = false;
@@ -567,8 +595,22 @@ export class Terminal implements ITerminalCore {
567
595
  return this.renderer instanceof WebglRenderer ? 'webgl' : 'canvas';
568
596
  }
569
597
 
598
+ private fontOptions(): {
599
+ fontWeight: FontWeight;
600
+ fontWeightBold: FontWeight;
601
+ lineHeight: number;
602
+ } {
603
+ return {
604
+ fontWeight: this.options.fontWeight,
605
+ fontWeightBold: this.options.fontWeightBold,
606
+ lineHeight: this.options.lineHeight,
607
+ };
608
+ }
609
+
570
610
  private rendererOptions(): RendererOptions {
571
611
  return {
612
+ ...this.fontOptions(),
613
+ minimumContrastRatio: this.options.minimumContrastRatio,
572
614
  fontSize: this.options.fontSize,
573
615
  fontFamily: this.options.fontFamily,
574
616
  cursorStyle: this.options.cursorStyle,
@@ -589,7 +631,8 @@ export class Terminal implements ITerminalCore {
589
631
  }
590
632
  }
591
633
  renderer ??= new CanvasRenderer(this.canvas!, this.rendererOptions());
592
- renderer.onNeedsFrame = () => this.wake();
634
+ renderer.onNeedsFrame = () => this.requestFrame();
635
+ renderer.setFocused(this.focused);
593
636
  return renderer;
594
637
  }
595
638
 
@@ -599,8 +642,10 @@ export class Terminal implements ITerminalCore {
599
642
  const hoveredLink = this.renderer.getHoveredHyperlinkId();
600
643
  this.renderer.dispose();
601
644
  const renderer = new CanvasRenderer(this.canvas, this.rendererOptions());
602
- renderer.onNeedsFrame = () => this.wake();
645
+ renderer.onNeedsFrame = () => this.requestFrame();
603
646
  renderer.setHoveredHyperlinkId(hoveredLink);
647
+ renderer.setFocused(this.focused);
648
+ renderer.setSearchHighlights(this.searchHighlights.list, this.searchHighlights.colors);
604
649
  if (this.selectionManager) {
605
650
  renderer.setSelectionManager(this.selectionManager);
606
651
  this.selectionManager.setRenderer(renderer);
@@ -654,8 +699,9 @@ export class Terminal implements ITerminalCore {
654
699
 
655
700
  // Call callback if provided
656
701
  if (callback) {
657
- // Queue callback after next render
658
- requestAnimationFrame(callback);
702
+ // The data is parsed by now, so run the callback as soon as this task
703
+ // ends, as xterm.js does once a write is processed.
704
+ queueMicrotask(callback);
659
705
  }
660
706
 
661
707
  if (this.awaitingEcho) {
@@ -809,16 +855,7 @@ export class Terminal implements ITerminalCore {
809
855
  */
810
856
  focus(): void {
811
857
  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
- }
858
+ if (this.isOpen && this.element) this.element.focus();
822
859
  }
823
860
 
824
861
  /**
@@ -1035,16 +1072,18 @@ export class Terminal implements ITerminalCore {
1035
1072
  }
1036
1073
  }
1037
1074
 
1038
- /**
1039
- * Scroll viewport to a specific line in the buffer
1040
- * @param line Line number (0 = top of scrollback, scrollbackLength = bottom)
1041
- */
1042
1075
  /** Scrolls so that absolute buffer line `line` (0 = oldest scrollback line) is at the top, like xterm.js. */
1043
1076
  public scrollToLine(line: number): void {
1044
- this.wake();
1045
1077
  const scrollbackLength = this.getScrollbackLength();
1046
1078
  const top = Math.max(0, Math.min(scrollbackLength, Math.round(line)));
1047
- const newViewportY = scrollbackLength - top;
1079
+ this.scrollToViewportY(scrollbackLength - top);
1080
+ }
1081
+
1082
+ /** Scrolls to `viewportY` lines above the bottom. */
1083
+ private scrollToViewportY(viewportY: number): void {
1084
+ this.wake();
1085
+ const scrollbackLength = this.getScrollbackLength();
1086
+ const newViewportY = Math.max(0, Math.min(scrollbackLength, Math.round(viewportY)));
1048
1087
 
1049
1088
  if (newViewportY !== this.viewportY) {
1050
1089
  this.viewportY = newViewportY;
@@ -1201,6 +1240,7 @@ export class Terminal implements ITerminalCore {
1201
1240
  this.scrollEmitter.dispose();
1202
1241
  this.renderEmitter.dispose();
1203
1242
  this.cursorMoveEmitter.dispose();
1243
+ this.writeParsedEmitter.dispose();
1204
1244
  }
1205
1245
 
1206
1246
  // ==========================================================================
@@ -1308,6 +1348,8 @@ export class Terminal implements ITerminalCore {
1308
1348
  this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true });
1309
1349
  this.element.removeEventListener('mousemove', this.handleMouseMove);
1310
1350
  this.element.removeEventListener('mouseleave', this.handleMouseLeave);
1351
+ this.element.removeEventListener('focusin', this.handleFocusIn);
1352
+ this.element.removeEventListener('focusout', this.handleFocusOut);
1311
1353
  this.element.removeEventListener('click', this.handleClick);
1312
1354
 
1313
1355
  // Remove contenteditable and accessibility attributes added in open()
@@ -1663,6 +1705,8 @@ export class Terminal implements ITerminalCore {
1663
1705
  deltaLines = e.deltaY / 33;
1664
1706
  }
1665
1707
 
1708
+ deltaLines *= this.options.scrollSensitivity;
1709
+
1666
1710
  // Use smooth scrolling for any amount (no rounding needed)
1667
1711
  if (deltaLines !== 0) {
1668
1712
  // Calculate target position
@@ -1728,7 +1772,7 @@ export class Terminal implements ITerminalCore {
1728
1772
  const relativeY = mouseY - scrollbarPadding;
1729
1773
  const scrollFraction = 1 - relativeY / scrollbarTrackHeight; // Inverted: top = 1, bottom = 0
1730
1774
  const targetViewportY = Math.round(scrollFraction * scrollbackLength);
1731
- this.scrollToLine(Math.max(0, Math.min(scrollbackLength, targetViewportY)));
1775
+ this.scrollToViewportY(targetViewportY);
1732
1776
  }
1733
1777
  }
1734
1778
  };
@@ -1786,7 +1830,7 @@ export class Terminal implements ITerminalCore {
1786
1830
  const viewportDelta = Math.round(scrollFraction * scrollbackLength);
1787
1831
 
1788
1832
  const newViewportY = this.scrollbarDragStartViewportY + viewportDelta;
1789
- this.scrollToLine(Math.max(0, Math.min(scrollbackLength, newViewportY)));
1833
+ this.scrollToViewportY(newViewportY);
1790
1834
  }
1791
1835
 
1792
1836
  /**
@@ -1965,6 +2009,30 @@ export class Terminal implements ITerminalCore {
1965
2009
  };
1966
2010
  }
1967
2011
 
2012
+ private focused = false;
2013
+
2014
+ private readonly handleFocusIn = (): void => this.setFocused(true);
2015
+
2016
+ private readonly handleFocusOut = (event: FocusEvent): void => {
2017
+ const next = event.relatedTarget;
2018
+ if (next instanceof Node && this.element?.contains(next)) return;
2019
+ this.setFocused(false);
2020
+ };
2021
+
2022
+ private setFocused(focused: boolean): void {
2023
+ if (focused === this.focused) return;
2024
+ this.focused = focused;
2025
+ this.renderer?.setFocused(focused);
2026
+ this.requestFrame();
2027
+ }
2028
+
2029
+ /** Draws one frame, for a change that needs no follow-up frames (a cursor blink). */
2030
+ private requestFrame(): void {
2031
+ if (this.animationFrameId) return;
2032
+ this.idleFrames = Terminal.IDLE_FRAMES_BEFORE_SLEEP;
2033
+ if (this.isOpen && !this.isDisposed) this.startRenderLoop();
2034
+ }
2035
+
1968
2036
  /** Keeps the render loop running for a while; called whenever something may need drawing. */
1969
2037
  public wake(): void {
1970
2038
  this.idleFrames = 0;
@@ -1999,6 +2067,19 @@ export class Terminal implements ITerminalCore {
1999
2067
  (this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
2000
2068
  }
2001
2069
  for (const marker of [...this.markers]) marker.refresh();
2070
+ this.writeParsedEmitter.fire();
2071
+ this.wake();
2072
+ }
2073
+
2074
+ private searchHighlights: {
2075
+ list: SearchHighlight[] | null;
2076
+ colors?: SearchHighlightColors;
2077
+ } = { list: null };
2078
+
2079
+ /** Shows search matches on viewport rows (null clears them). Used by SearchAddon. */
2080
+ setSearchHighlights(list: SearchHighlight[] | null, colors?: SearchHighlightColors): void {
2081
+ this.searchHighlights = { list, colors: colors ?? this.searchHighlights.colors };
2082
+ this.renderer?.setSearchHighlights(list, colors);
2002
2083
  this.wake();
2003
2084
  }
2004
2085
 
package/lib/types.ts CHANGED
@@ -394,7 +394,8 @@ export interface GhosttyTerminalConfig {
394
394
  fgColor?: number;
395
395
  bgColor?: number;
396
396
  cursorColor?: number;
397
- palette?: number[];
397
+ /** The 16 ANSI colors; an undefined entry keeps Ghostty's default. */
398
+ palette?: (number | undefined)[];
398
399
  }
399
400
 
400
401
  /**
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Packs the renderer's per-cell instance slots into dense arrays so a frame
3
+ * draws only the instances that show something. Slots with a zero width are
4
+ * empty. Rects that continue the previous one on the same row with the same
5
+ * color are merged into it.
6
+ */
7
+
8
+ export const RECT_WORDS = 5;
9
+ export const GLYPH_WORDS = 8;
10
+
11
+ export interface InstanceArrays {
12
+ words: Float32Array;
13
+ bits: Uint32Array;
14
+ }
15
+
16
+ export function instanceArrays(instances: number, wordsPer: number): InstanceArrays {
17
+ const buffer = new ArrayBuffer(instances * wordsPer * 4);
18
+ return { words: new Float32Array(buffer), bits: new Uint32Array(buffer) };
19
+ }
20
+
21
+ export function compactRects(
22
+ src: InstanceArrays,
23
+ first: number,
24
+ count: number,
25
+ out: InstanceArrays
26
+ ): number {
27
+ const { words, bits } = src;
28
+ const outWords = out.words;
29
+ const outBits = out.bits;
30
+ let n = 0;
31
+ let last = -1;
32
+ for (let i = first; i < first + count; i++) {
33
+ const at = i * RECT_WORDS;
34
+ const width = words[at + 2];
35
+ if (width === 0) continue;
36
+ if (
37
+ last >= 0 &&
38
+ bits[at + 4] === outBits[last + 4] &&
39
+ words[at + 1] === outWords[last + 1] &&
40
+ words[at + 3] === outWords[last + 3] &&
41
+ outWords[last] + outWords[last + 2] === words[at]
42
+ ) {
43
+ outWords[last + 2] += width;
44
+ continue;
45
+ }
46
+ last = n * RECT_WORDS;
47
+ outBits[last] = bits[at];
48
+ outBits[last + 1] = bits[at + 1];
49
+ outBits[last + 2] = bits[at + 2];
50
+ outBits[last + 3] = bits[at + 3];
51
+ outBits[last + 4] = bits[at + 4];
52
+ n++;
53
+ }
54
+ return n;
55
+ }
56
+
57
+ export function compactGlyphs(src: InstanceArrays, count: number, out: InstanceArrays): number {
58
+ const { words, bits } = src;
59
+ const outBits = out.bits;
60
+ let n = 0;
61
+ for (let i = 0; i < count; i++) {
62
+ const at = i * GLYPH_WORDS;
63
+ if (words[at + 4] === 0) continue;
64
+ const to = n * GLYPH_WORDS;
65
+ for (let w = 0; w < GLYPH_WORDS; w++) outBits[to + w] = bits[at + w];
66
+ n++;
67
+ }
68
+ return n;
69
+ }