@hex1b/web-terminal 0.1.0 → 0.166.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.
Files changed (50) hide show
  1. package/README.md +314 -11
  2. package/dist/backend-selection.d.ts +7 -0
  3. package/dist/backend-selection.d.ts.map +1 -0
  4. package/dist/backend-selection.js +23 -0
  5. package/dist/backend-selection.js.map +1 -0
  6. package/dist/hyperlinks.d.ts +10 -0
  7. package/dist/hyperlinks.d.ts.map +1 -0
  8. package/dist/hyperlinks.js +28 -0
  9. package/dist/hyperlinks.js.map +1 -0
  10. package/dist/mouse-input.d.ts +3 -0
  11. package/dist/mouse-input.d.ts.map +1 -1
  12. package/dist/mouse-input.js +69 -2
  13. package/dist/mouse-input.js.map +1 -1
  14. package/dist/protocol.d.ts +1 -0
  15. package/dist/protocol.d.ts.map +1 -1
  16. package/dist/protocol.js +40 -0
  17. package/dist/protocol.js.map +1 -1
  18. package/dist/render-backend.d.ts +31 -0
  19. package/dist/render-backend.d.ts.map +1 -0
  20. package/dist/render-backend.js +5 -0
  21. package/dist/render-backend.js.map +1 -0
  22. package/dist/renderer-options.d.ts +3 -0
  23. package/dist/renderer-options.d.ts.map +1 -0
  24. package/dist/renderer-options.js +6 -0
  25. package/dist/renderer-options.js.map +1 -0
  26. package/dist/renderer.d.ts +12 -24
  27. package/dist/renderer.d.ts.map +1 -1
  28. package/dist/renderer.js +35 -173
  29. package/dist/renderer.js.map +1 -1
  30. package/dist/terminal-worker.js +18 -10
  31. package/dist/terminal-worker.js.map +1 -1
  32. package/dist/types.d.ts +77 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/dist/web-terminal.d.ts +8 -1
  36. package/dist/web-terminal.d.ts.map +1 -1
  37. package/dist/web-terminal.js +130 -17
  38. package/dist/web-terminal.js.map +1 -1
  39. package/dist/webgl2-backend.d.ts +38 -0
  40. package/dist/webgl2-backend.d.ts.map +1 -0
  41. package/dist/webgl2-backend.js +456 -0
  42. package/dist/webgl2-backend.js.map +1 -0
  43. package/dist/webgpu-backend.d.ts +24 -0
  44. package/dist/webgpu-backend.d.ts.map +1 -0
  45. package/dist/webgpu-backend.js +234 -0
  46. package/dist/webgpu-backend.js.map +1 -0
  47. package/dist/wire-types.d.ts +20 -2
  48. package/dist/wire-types.d.ts.map +1 -1
  49. package/dist/wire-types.js.map +1 -1
  50. package/package.json +2 -2
@@ -1,11 +1,13 @@
1
1
  import { captureMouse } from "./mouse-input.js";
2
2
  import { normalizeFont } from "./terminal-font.js";
3
+ import { normalizeRenderer } from "./renderer-options.js";
3
4
  import { dimensions, normalizeSizing, requestedGrid, fittedScale } from "./terminal-sizing.js";
4
5
  import { HistoryState } from "./history-state.js";
5
6
  import { terminalThemeCss } from "./terminal-theme.js";
6
7
  import { InputPolicy, InputRoute, TerminalAction, inputModifiers } from "./input-policy.js";
7
8
  import { assertCommandSize } from "./protocol.js";
8
9
  import { SelectionUI } from "./selection-ui.js";
10
+ import { Hyperlinks } from "./hyperlinks.js";
9
11
  import { errorMessage, isRecord } from "./validation.js";
10
12
  export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js";
11
13
  function requiredElement(root, selector, type) {
@@ -21,6 +23,7 @@ function requiredElement(root, selector, type) {
21
23
  export class WebTerminal {
22
24
  element;
23
25
  #options;
26
+ #renderer;
24
27
  // DOM and worker fields are initialized by mount before a handle is returned.
25
28
  #worker;
26
29
  #surface;
@@ -34,15 +37,23 @@ export class WebTerminal {
34
37
  #geometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };
35
38
  #peer = { id: null, primaryId: null, isPrimary: false };
36
39
  #connected = false;
40
+ #closed = false;
41
+ #readOnly;
37
42
  #disposed = false;
38
43
  #hasGeometry = false;
39
44
  #resizeTimer;
40
45
  #lastRequested;
41
46
  #compositionTimer;
47
+ #resetComposition;
42
48
  #ready = Promise.withResolvers();
43
49
  #readyTimer;
44
50
  #stats = {};
45
51
  #screenText = "";
52
+ #title = "";
53
+ #hasTitle = false;
54
+ #progress = { state: "none", percentage: null };
55
+ #shellIntegration = { phase: "unknown", lastExitCode: null };
56
+ #hasActivity = false;
46
57
  #history;
47
58
  #highlights;
48
59
  #inspection;
@@ -57,6 +68,7 @@ export class WebTerminal {
57
68
  #selectionOverlay;
58
69
  #selectionUIError = "";
59
70
  #canvasSize = { width: 0, height: 0 };
71
+ #hyperlinks = new Hyperlinks();
60
72
  /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */
61
73
  static async mount(container, options) {
62
74
  if (!(container instanceof HTMLElement))
@@ -65,8 +77,9 @@ export class WebTerminal {
65
77
  throw new TypeError("A terminal WebSocket URL is required");
66
78
  if (options.signal?.aborted)
67
79
  throw options.signal.reason;
68
- if (!window.isSecureContext || !navigator.gpu)
69
- throw new Error("WebTerminal requires WebGPU over HTTPS or localhost");
80
+ if (normalizeRenderer(options.renderer) === "webgpu" && (!window.isSecureContext || !navigator.gpu)) {
81
+ throw new Error("The requested WebGPU renderer requires WebGPU over HTTPS or localhost");
82
+ }
70
83
  if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||
71
84
  !HTMLCanvasElement.prototype.transferControlToOffscreen) {
72
85
  throw new Error("WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas");
@@ -83,6 +96,10 @@ export class WebTerminal {
83
96
  }
84
97
  constructor(options) {
85
98
  this.#options = options;
99
+ if (options.readOnly !== undefined && typeof options.readOnly !== "boolean")
100
+ throw new TypeError("readOnly must be a boolean");
101
+ this.#readOnly = options.readOnly ?? false;
102
+ this.#renderer = normalizeRenderer(options.renderer);
86
103
  if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&
87
104
  (typeof options.workerUrl !== "string" || !options.workerUrl.trim()))
88
105
  throw new TypeError("workerUrl must be a nonempty URL string or URL");
@@ -100,6 +117,11 @@ export class WebTerminal {
100
117
  get geometry() { return { ...this.#geometry }; }
101
118
  get peer() { return { ...this.#peer }; }
102
119
  get connected() { return this.#connected; }
120
+ get readOnly() { return this.#readOnly; }
121
+ /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */
122
+ get title() { return this.#title; }
123
+ get progress() { return { ...this.#progress }; }
124
+ get shellIntegration() { return { ...this.#shellIntegration }; }
103
125
  get stats() { return { ...this.#stats }; }
104
126
  get screenText() { return this.#screenText; }
105
127
  get sizing() { return { ...this.#sizing }; }
@@ -176,7 +198,7 @@ export class WebTerminal {
176
198
  element: this.element, overlay: this.#selectionOverlay,
177
199
  button: requiredElement(this.#inspection, ".copy-selection", HTMLButtonElement), signal: this.#listeners.signal,
178
200
  getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,
179
- canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }),
201
+ canvasSize: this.#canvasSize, connected: this.#connected, readOnly: this.#readOnly }),
180
202
  runAction: this.runAction.bind(this),
181
203
  onSelectionUI: this.#options.onSelectionUI,
182
204
  reportError: error => {
@@ -202,14 +224,23 @@ export class WebTerminal {
202
224
  }
203
225
  };
204
226
  this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {
205
- state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly,
227
+ state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: this.#readOnly,
206
228
  selection: this.selection }),
207
229
  begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),
208
230
  extend: point => inspect(() => this.#history.extend(point)),
209
231
  scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),
210
232
  end: cancelled => this.#history.endGesture(cancelled),
211
233
  resolve: input => this.#resolveInput(input),
212
- execute: (decision, input) => this.#executeInputAction(decision, input)
234
+ execute: (decision, input) => this.#executeInputAction(decision, input),
235
+ hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null,
236
+ openHyperlink: uri => {
237
+ try {
238
+ window.open(uri, "_blank", "noopener,noreferrer");
239
+ }
240
+ catch (error) {
241
+ this.#actionFailed(error);
242
+ }
243
+ }
213
244
  });
214
245
  this.#bindKeyboard();
215
246
  requiredElement(this.#inspection, ".return-live", HTMLButtonElement).addEventListener("click", () => {
@@ -250,7 +281,8 @@ export class WebTerminal {
250
281
  });
251
282
  this.#worker.addEventListener("messageerror", () => this.#fail(new Error("Terminal worker message could not be decoded")));
252
283
  const canvas = this.#canvas.transferControlToOffscreen();
253
- this.#post({ type: "init", canvas, url: url.href, scale, font }, [canvas]);
284
+ this.#post({ type: "init", canvas, url: url.href, scale, font,
285
+ renderer: this.#renderer }, [canvas]);
254
286
  }
255
287
  #message(message) {
256
288
  if (this.#disposed)
@@ -259,8 +291,19 @@ export class WebTerminal {
259
291
  this.#connected = true;
260
292
  this.#input.disabled = !this.#canInput();
261
293
  }
262
- else if (message.type === "disconnected") {
263
- this.#disconnect();
294
+ else if (message.type === "closed") {
295
+ if (this.#closed)
296
+ return;
297
+ this.#closed = true;
298
+ clearTimeout(this.#readyTimer);
299
+ try {
300
+ this.#disconnect();
301
+ if (!this.#disposed)
302
+ this.#options.onClose?.(Object.freeze({ ...message.details }));
303
+ }
304
+ finally {
305
+ this.#ready.reject(new Error(`Terminal WebSocket closed (${message.details.code}${message.details.reason ? `: ${message.details.reason}` : ""}) before mounting completed`));
306
+ }
264
307
  }
265
308
  else if (message.type === "status") {
266
309
  if (message.level === "error") {
@@ -283,7 +326,7 @@ export class WebTerminal {
283
326
  this.#input.disabled = !this.#canInput();
284
327
  if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement)
285
328
  this.focus();
286
- this.#mouse?.update(message.columns, message.rows, message.mouseTracking);
329
+ this.#hyperlinks.update(message.hyperlinks);
287
330
  if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary)
288
331
  this.#fit();
289
332
  if (!this.#peer.isPrimary) {
@@ -299,6 +342,7 @@ export class WebTerminal {
299
342
  this.#screenText = message.text;
300
343
  this.#history.accept(message.history, message.revision);
301
344
  }
345
+ this.#mouse?.update(message.columns, message.rows, message.mouseTracking);
302
346
  if (geometryChanged)
303
347
  this.#options.onGeometry?.(this.geometry);
304
348
  if (first)
@@ -306,6 +350,24 @@ export class WebTerminal {
306
350
  if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {
307
351
  this.#options.onRoleChange?.(this.peer);
308
352
  }
353
+ if (!this.#disposed && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {
354
+ const titleChanged = !this.#hasTitle || this.#title !== message.title;
355
+ const progressChanged = !this.#hasActivity || this.#progress.state !== message.progress.state ||
356
+ this.#progress.percentage !== message.progress.percentage;
357
+ const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase ||
358
+ this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode;
359
+ this.#title = message.title;
360
+ this.#hasTitle = true;
361
+ this.#progress = { ...message.progress };
362
+ this.#shellIntegration = { ...message.shellIntegration };
363
+ this.#hasActivity = true;
364
+ if (titleChanged)
365
+ this.#options.onTitleChange?.(this.#title);
366
+ if (!this.#disposed && progressChanged)
367
+ this.#options.onProgressChange?.(this.progress);
368
+ if (!this.#disposed && shellChanged)
369
+ this.#options.onShellIntegrationChange?.(this.shellIntegration);
370
+ }
309
371
  }
310
372
  else if (message.type === "history") {
311
373
  this.#screenText = message.text;
@@ -315,7 +377,8 @@ export class WebTerminal {
315
377
  this.#stats = message.stats;
316
378
  if (message.text !== undefined)
317
379
  this.#screenText = message.text;
318
- if (message.stats.revision > 0 && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {
380
+ if (message.stats.revision > 0 && this.#hasTitle && this.#hasActivity && this.#connected &&
381
+ (this.#peer.id !== null || this.#peer.isPrimary)) {
319
382
  clearTimeout(this.#readyTimer);
320
383
  this.#ready.resolve(this);
321
384
  }
@@ -341,13 +404,13 @@ export class WebTerminal {
341
404
  #queueResize(includeFixed = false) {
342
405
  if (this.#sizing.mode === "fixed" && !includeFixed)
343
406
  return;
344
- if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed)
407
+ if (!this.#canInput() || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed)
345
408
  return;
346
409
  // Throttle (rather than debounce) so dragging a primary view updates peers live.
347
410
  this.#resizeTimer = setTimeout(() => {
348
411
  this.#resizeTimer = undefined;
349
412
  const grid = this.#fittedGrid();
350
- if (!grid || !this.#peer.isPrimary || !this.#connected)
413
+ if (!grid || !this.#peer.isPrimary || !this.#canInput())
351
414
  return;
352
415
  const key = `${grid.columns}x${grid.rows}`;
353
416
  if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested)
@@ -361,6 +424,8 @@ export class WebTerminal {
361
424
  #send(command) {
362
425
  if (!this.#connected || this.#disposed)
363
426
  throw new Error("Terminal view is not connected");
427
+ if (this.#readOnly && ["input", "paste", "key", "mouse", "resize", "requestPrimary"].includes(command.type))
428
+ throw new Error("Terminal view does not accept input");
364
429
  this.#post({ type: "command", command });
365
430
  }
366
431
  #inputCommand(command) {
@@ -380,6 +445,7 @@ export class WebTerminal {
380
445
  #inspectionChanged() {
381
446
  const viewport = this.viewport;
382
447
  const selection = this.selection;
448
+ this.#mouse?.refresh();
383
449
  if (selection.status === "invalidated")
384
450
  this.#mouse?.cancel();
385
451
  if (this.#highlights) {
@@ -466,20 +532,23 @@ export class WebTerminal {
466
532
  }
467
533
  }
468
534
  #canInput() {
469
- return this.#connected && this.#hasGeometry && !this.#options.readOnly &&
535
+ return this.#connected && this.#hasGeometry && !this.#readOnly &&
470
536
  (this.#peer.id !== null || this.#peer.isPrimary);
471
537
  }
472
538
  get inputContext() {
473
539
  return Object.freeze({
474
540
  terminal: this, selection: this.selection, viewport: this.viewport,
475
- buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0,
541
+ buffer: this.viewport.buffer ?? null, mouseCaptured: !this.#readOnly && this.#geometry.mouseTracking !== 0,
476
542
  historical: !this.viewport.following || this.viewport.pending,
477
- readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer
543
+ readOnly: this.#readOnly, connected: this.#connected, peer: this.peer
478
544
  });
479
545
  }
480
546
  #resolveInput(input) {
481
547
  try {
482
- return this.#policy.resolve(Object.freeze(input), this.inputContext);
548
+ const decision = this.#policy.resolve(Object.freeze(input), this.inputContext);
549
+ if (this.#readOnly && decision.route === InputRoute.Application)
550
+ return { route: input.type === "pointer" || input.type === "wheel" ? InputRoute.Continue : InputRoute.Consume };
551
+ return decision;
483
552
  }
484
553
  catch (error) {
485
554
  this.#actionFailed(error);
@@ -524,7 +593,7 @@ export class WebTerminal {
524
593
  try {
525
594
  if (this.selection.active || (this.selection.pending && this.selection.canExtend))
526
595
  return await this.copySelection({ clear: true });
527
- if (!this.#options.readOnly)
596
+ if (!this.#readOnly)
528
597
  return await this.pasteClipboard();
529
598
  return;
530
599
  }
@@ -559,6 +628,34 @@ export class WebTerminal {
559
628
  this.paste(text);
560
629
  return text;
561
630
  }
631
+ /** Changes per-view input policy without reconnecting; server authorization remains host-owned. */
632
+ setReadOnly(readOnly) {
633
+ if (typeof readOnly !== "boolean")
634
+ throw new TypeError("readOnly must be a boolean");
635
+ if (this.#disposed)
636
+ throw new Error("Terminal view is disposed");
637
+ if (this.#readOnly === readOnly)
638
+ return;
639
+ const inputFocused = document.activeElement === this.element &&
640
+ (!this.element.shadowRoot?.activeElement || this.element.shadowRoot.activeElement === this.#input);
641
+ this.#readOnly = readOnly;
642
+ this.#inputSerial++;
643
+ this.#resetComposition?.();
644
+ if (this.#input) {
645
+ this.#input.value = "";
646
+ this.#input.disabled = !this.#canInput();
647
+ }
648
+ // Set the policy before cancelling so pending moves and button releases cannot leak.
649
+ this.#mouse?.cancel();
650
+ this.#mouse?.refresh();
651
+ clearTimeout(this.#resizeTimer);
652
+ this.#resizeTimer = undefined;
653
+ this.#lastRequested = undefined;
654
+ this.#queueResize(true);
655
+ if (inputFocused)
656
+ this.focus();
657
+ this.#selectionUI?.refresh();
658
+ }
562
659
  #forwardInput(input) {
563
660
  if (input.type === "key") {
564
661
  if (input.meta)
@@ -599,6 +696,11 @@ export class WebTerminal {
599
696
  const options = { signal: this.#listeners.signal };
600
697
  let composing = false;
601
698
  let compositionCommit = null;
699
+ this.#resetComposition = () => {
700
+ composing = false;
701
+ compositionCommit = null;
702
+ clearTimeout(this.#compositionTimer);
703
+ };
602
704
  this.element.addEventListener("keydown", event => {
603
705
  if (event.defaultPrevented)
604
706
  return;
@@ -619,11 +721,15 @@ export class WebTerminal {
619
721
  input.value = "";
620
722
  }, options);
621
723
  input.addEventListener("compositionstart", () => {
724
+ if (!this.#canInput())
725
+ return;
622
726
  composing = true;
623
727
  compositionCommit = null;
624
728
  clearTimeout(this.#compositionTimer);
625
729
  }, options);
626
730
  input.addEventListener("compositionend", event => {
731
+ if (!composing)
732
+ return;
627
733
  composing = false;
628
734
  // Accommodate browsers placing the final input before or after compositionend.
629
735
  compositionCommit = typeof event.data === "string" ? event.data : input.value;
@@ -654,6 +760,8 @@ export class WebTerminal {
654
760
  }
655
761
  /** Request HMP1 primary explicitly; peer notifications confirm the result. */
656
762
  requestPrimary() {
763
+ if (!this.#canInput())
764
+ throw new Error("Terminal view does not accept input");
657
765
  if (this.#size.width <= 0 || this.#size.height <= 0)
658
766
  throw new Error("Show the terminal container before taking primary");
659
767
  const grid = this.#fittedGrid();
@@ -670,6 +778,8 @@ export class WebTerminal {
670
778
  /** Request a grid; never reflow locally before the authoritative response. */
671
779
  resize(columns, rows) {
672
780
  const grid = dimensions(columns, rows);
781
+ if (!this.#canInput())
782
+ throw new Error("Terminal view does not accept input");
673
783
  if (!this.#peer.isPrimary)
674
784
  throw new Error("Only the primary view can request a terminal resize");
675
785
  this.#send({ type: "resize", ...grid });
@@ -680,6 +790,8 @@ export class WebTerminal {
680
790
  const next = normalizeSizing(sizing, this.#sizing.fontSize);
681
791
  if (!this.#connected || this.#disposed)
682
792
  throw new Error("Terminal view is not connected");
793
+ if (this.#readOnly)
794
+ throw new Error("Terminal view does not accept input");
683
795
  if (!this.#peer.isPrimary)
684
796
  throw new Error("Only the primary view can change terminal sizing");
685
797
  this.#sizing = next;
@@ -694,6 +806,7 @@ export class WebTerminal {
694
806
  #disconnect() {
695
807
  this.#inputSerial++;
696
808
  this.#connected = false;
809
+ this.#hyperlinks.update([]);
697
810
  if (this.#input)
698
811
  this.#input.disabled = true;
699
812
  this.#mouse?.update(1, 1, 0);