@aurigax-ai/ghostty-web 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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();
280
+ }
281
+ break;
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);
248
287
  }
249
288
  break;
250
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)
@@ -567,8 +598,22 @@ export class Terminal implements ITerminalCore {
567
598
  return this.renderer instanceof WebglRenderer ? 'webgl' : 'canvas';
568
599
  }
569
600
 
601
+ private fontOptions(): {
602
+ fontWeight: FontWeight;
603
+ fontWeightBold: FontWeight;
604
+ lineHeight: number;
605
+ } {
606
+ return {
607
+ fontWeight: this.options.fontWeight,
608
+ fontWeightBold: this.options.fontWeightBold,
609
+ lineHeight: this.options.lineHeight,
610
+ };
611
+ }
612
+
570
613
  private rendererOptions(): RendererOptions {
571
614
  return {
615
+ ...this.fontOptions(),
616
+ minimumContrastRatio: this.options.minimumContrastRatio,
572
617
  fontSize: this.options.fontSize,
573
618
  fontFamily: this.options.fontFamily,
574
619
  cursorStyle: this.options.cursorStyle,
@@ -589,7 +634,8 @@ export class Terminal implements ITerminalCore {
589
634
  }
590
635
  }
591
636
  renderer ??= new CanvasRenderer(this.canvas!, this.rendererOptions());
592
- renderer.onNeedsFrame = () => this.wake();
637
+ renderer.onNeedsFrame = () => this.requestFrame();
638
+ renderer.setFocused(this.focused);
593
639
  return renderer;
594
640
  }
595
641
 
@@ -599,8 +645,10 @@ export class Terminal implements ITerminalCore {
599
645
  const hoveredLink = this.renderer.getHoveredHyperlinkId();
600
646
  this.renderer.dispose();
601
647
  const renderer = new CanvasRenderer(this.canvas, this.rendererOptions());
602
- renderer.onNeedsFrame = () => this.wake();
648
+ renderer.onNeedsFrame = () => this.requestFrame();
603
649
  renderer.setHoveredHyperlinkId(hoveredLink);
650
+ renderer.setFocused(this.focused);
651
+ renderer.setSearchHighlights(this.searchHighlights.list, this.searchHighlights.colors);
604
652
  if (this.selectionManager) {
605
653
  renderer.setSelectionManager(this.selectionManager);
606
654
  this.selectionManager.setRenderer(renderer);
@@ -1035,16 +1083,18 @@ export class Terminal implements ITerminalCore {
1035
1083
  }
1036
1084
  }
1037
1085
 
1038
- /**
1039
- * Scroll viewport to a specific line in the buffer
1040
- * @param line Line number (0 = top of scrollback, scrollbackLength = bottom)
1041
- */
1042
1086
  /** Scrolls so that absolute buffer line `line` (0 = oldest scrollback line) is at the top, like xterm.js. */
1043
1087
  public scrollToLine(line: number): void {
1044
- this.wake();
1045
1088
  const scrollbackLength = this.getScrollbackLength();
1046
1089
  const top = Math.max(0, Math.min(scrollbackLength, Math.round(line)));
1047
- const newViewportY = scrollbackLength - top;
1090
+ this.scrollToViewportY(scrollbackLength - top);
1091
+ }
1092
+
1093
+ /** Scrolls to `viewportY` lines above the bottom. */
1094
+ private scrollToViewportY(viewportY: number): void {
1095
+ this.wake();
1096
+ const scrollbackLength = this.getScrollbackLength();
1097
+ const newViewportY = Math.max(0, Math.min(scrollbackLength, Math.round(viewportY)));
1048
1098
 
1049
1099
  if (newViewportY !== this.viewportY) {
1050
1100
  this.viewportY = newViewportY;
@@ -1201,6 +1251,7 @@ export class Terminal implements ITerminalCore {
1201
1251
  this.scrollEmitter.dispose();
1202
1252
  this.renderEmitter.dispose();
1203
1253
  this.cursorMoveEmitter.dispose();
1254
+ this.writeParsedEmitter.dispose();
1204
1255
  }
1205
1256
 
1206
1257
  // ==========================================================================
@@ -1308,6 +1359,8 @@ export class Terminal implements ITerminalCore {
1308
1359
  this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true });
1309
1360
  this.element.removeEventListener('mousemove', this.handleMouseMove);
1310
1361
  this.element.removeEventListener('mouseleave', this.handleMouseLeave);
1362
+ this.element.removeEventListener('focusin', this.handleFocusIn);
1363
+ this.element.removeEventListener('focusout', this.handleFocusOut);
1311
1364
  this.element.removeEventListener('click', this.handleClick);
1312
1365
 
1313
1366
  // Remove contenteditable and accessibility attributes added in open()
@@ -1663,6 +1716,8 @@ export class Terminal implements ITerminalCore {
1663
1716
  deltaLines = e.deltaY / 33;
1664
1717
  }
1665
1718
 
1719
+ deltaLines *= this.options.scrollSensitivity;
1720
+
1666
1721
  // Use smooth scrolling for any amount (no rounding needed)
1667
1722
  if (deltaLines !== 0) {
1668
1723
  // Calculate target position
@@ -1728,7 +1783,7 @@ export class Terminal implements ITerminalCore {
1728
1783
  const relativeY = mouseY - scrollbarPadding;
1729
1784
  const scrollFraction = 1 - relativeY / scrollbarTrackHeight; // Inverted: top = 1, bottom = 0
1730
1785
  const targetViewportY = Math.round(scrollFraction * scrollbackLength);
1731
- this.scrollToLine(Math.max(0, Math.min(scrollbackLength, targetViewportY)));
1786
+ this.scrollToViewportY(targetViewportY);
1732
1787
  }
1733
1788
  }
1734
1789
  };
@@ -1786,7 +1841,7 @@ export class Terminal implements ITerminalCore {
1786
1841
  const viewportDelta = Math.round(scrollFraction * scrollbackLength);
1787
1842
 
1788
1843
  const newViewportY = this.scrollbarDragStartViewportY + viewportDelta;
1789
- this.scrollToLine(Math.max(0, Math.min(scrollbackLength, newViewportY)));
1844
+ this.scrollToViewportY(newViewportY);
1790
1845
  }
1791
1846
 
1792
1847
  /**
@@ -1965,6 +2020,30 @@ export class Terminal implements ITerminalCore {
1965
2020
  };
1966
2021
  }
1967
2022
 
2023
+ private focused = false;
2024
+
2025
+ private readonly handleFocusIn = (): void => this.setFocused(true);
2026
+
2027
+ private readonly handleFocusOut = (event: FocusEvent): void => {
2028
+ const next = event.relatedTarget;
2029
+ if (next instanceof Node && this.element?.contains(next)) return;
2030
+ this.setFocused(false);
2031
+ };
2032
+
2033
+ private setFocused(focused: boolean): void {
2034
+ if (focused === this.focused) return;
2035
+ this.focused = focused;
2036
+ this.renderer?.setFocused(focused);
2037
+ this.requestFrame();
2038
+ }
2039
+
2040
+ /** Draws one frame, for a change that needs no follow-up frames (a cursor blink). */
2041
+ private requestFrame(): void {
2042
+ if (this.animationFrameId) return;
2043
+ this.idleFrames = Terminal.IDLE_FRAMES_BEFORE_SLEEP;
2044
+ if (this.isOpen && !this.isDisposed) this.startRenderLoop();
2045
+ }
2046
+
1968
2047
  /** Keeps the render loop running for a while; called whenever something may need drawing. */
1969
2048
  public wake(): void {
1970
2049
  this.idleFrames = 0;
@@ -1999,6 +2078,19 @@ export class Terminal implements ITerminalCore {
1999
2078
  (this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
2000
2079
  }
2001
2080
  for (const marker of [...this.markers]) marker.refresh();
2081
+ this.writeParsedEmitter.fire();
2082
+ this.wake();
2083
+ }
2084
+
2085
+ private searchHighlights: {
2086
+ list: SearchHighlight[] | null;
2087
+ colors?: SearchHighlightColors;
2088
+ } = { list: null };
2089
+
2090
+ /** Shows search matches on viewport rows (null clears them). Used by SearchAddon. */
2091
+ setSearchHighlights(list: SearchHighlight[] | null, colors?: SearchHighlightColors): void {
2092
+ this.searchHighlights = { list, colors: colors ?? this.searchHighlights.colors };
2093
+ this.renderer?.setSearchHighlights(list, colors);
2002
2094
  this.wake();
2003
2095
  }
2004
2096
 
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
+ }