@hex1b/web-terminal 0.173.0-alpha.1801.1.3dc2d9b → 0.173.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/README.md CHANGED
@@ -169,6 +169,7 @@ const container = document.getElementById("terminal");
169
169
  if (!container) throw new Error("Missing terminal container");
170
170
  const terminal = await WebTerminal.mount(container, {
171
171
  url: "/ws/terminal",
172
+ preserveOnDisconnect: true,
172
173
  colorMode: "system", // "light", "dark", or the browser's prefers-color-scheme.
173
174
  lightModePalette: { ...defaultLightPalette, background: "#fafafa" },
174
175
  darkModePalette: { ...defaultDarkPalette, background: "#202020" },
@@ -297,7 +298,41 @@ view after transport loss or leave an ended tab/dialog visible. Abort, explicit
297
298
  disposal, mount timeout, and local initialization/renderer failures do not
298
299
  synthesize `onClose`; no callback runs after disposal. Callback exceptions are
299
300
  reported to the host, not swallowed or retried, and do not leave mounting pending.
300
- The API does not retain the producer after exit or promise a final rendered frame.
301
+ Set `preserveOnDisconnect: true` to finish presenting the last accepted complete
302
+ frame before `onClose` and keep it visible until `dispose()`. The retained view
303
+ can repaint when its container or palette changes, but input and producer-backed
304
+ history, selection, and copying are disconnected. Cursor/text blinking stops.
305
+ The default is `false`, preserving the existing renderer-release behavior.
306
+ Explicit disposal or abort still removes the view and releases its GPU resources.
307
+ Local rendering failures cannot guarantee preservation.
308
+
309
+ Preservation retains the received KGP and Sixel textures and placements, including
310
+ PNG/compressed KGP, cropped and relative placements, Unicode placeholders, and
311
+ the current server-driven animation frame. Animation freezes rather than replaying
312
+ locally. Styled/wide/combining text, DEC enlarged rows, the current alternate
313
+ screen, link decorations already presented, selection highlights, viewport text,
314
+ markers, and activity metadata remain available for display and local redraw.
315
+ Link activation and producer-backed navigation/copy operations stop. Only the
316
+ current received history viewport is retained, not a browsable offline scrollback
317
+ archive; historical HWT1 viewports do not currently include graphics.
318
+
319
+ Preservation alone cannot recover bytes the server never sent. On the server,
320
+ await `Hex1bTerminal.RunAsync()` to finish consuming process output, then await
321
+ `Hwt1PresentationAdapter.DrainAsync(cancellationToken)` **while the frame sender
322
+ and client-message receiver remain running**. Only after the drain completes
323
+ should the host cancel those loops, close the transport, and dispose the
324
+ producer. The drain requests a fresh snapshot and waits for the final
325
+ presentation acknowledgement, including all marker inventory pages; its total
326
+ wait is bounded by `AcknowledgementTimeout`, and cancellation or timeout reports
327
+ failure rather than claiming delivery. An HMP1 replica must also finish consuming
328
+ its ordered output before its HWT1 drain. The demo does this for direct and relay
329
+ views on normal workload exit.
330
+
331
+ HWT1 transfers coalesced terminal state, not every intermediate output event.
332
+ The guarantee is the final retained terminal view, not an unlimited output log.
333
+ Abrupt network loss can retain only the last complete frame actually received;
334
+ it cannot promise the producer's final state. The API does not retain the producer
335
+ after exit.
301
336
 
302
337
  ### Browser and deployment requirements
303
338
 
@@ -425,6 +460,7 @@ does not claim universal or individually verified bundler support.
425
460
  | `label` | Accessible label for the terminal's hidden keyboard input. |
426
461
  | `onTitleChange` | Initial authoritative workload title, then distinct presented changes; see below. |
427
462
  | `onClose` | Native WebSocket close details, including pre-mount transport failure; not workload completion. |
463
+ | `preserveOnDisconnect` | Opt-in final-frame presentation and retained display after transport close, until disposal; defaults to `false`. |
428
464
  | `onProgressChange`, `onShellIntegrationChange` | Initial authoritative activity, then distinct presented changes for host-owned chrome. |
429
465
  | `onWorkingDirectoryChange`, `onCommandMarkChange` | Initial authoritative OSC 7 directory and latest OSC 133 marker, then distinct presented changes; see below. |
430
466
  | `inputBindings`, `onInput`, `actions` | Per-view input policy and custom actions. |
package/dist/index.js CHANGED
@@ -767,9 +767,14 @@ var init_transport_session = __esm({
767
767
  #ready = Promise.withResolvers();
768
768
  #connection;
769
769
  #frame = false;
770
+ #receiving;
771
+ #closing = false;
770
772
  #sending = false;
771
773
  #queue = [];
772
774
  #bytes = 0;
775
+ get closing() {
776
+ return this.#closing;
777
+ }
773
778
  start(transport2) {
774
779
  void this.#connect(transport2);
775
780
  }
@@ -777,16 +782,20 @@ var init_transport_session = __esm({
777
782
  try {
778
783
  const connection = await transport2.connect({
779
784
  signal: this.#abort.signal,
780
- onFrame: (frame) => this.#receive(frame),
785
+ onFrame: (frame) => {
786
+ const receiving = this.#receive(frame);
787
+ this.#receiving = receiving;
788
+ return receiving;
789
+ },
781
790
  onClose: (details) => {
782
- if (this.#abort.signal.aborted) return;
791
+ if (this.#abort.signal.aborted || this.#closing) return;
783
792
  if (!details || typeof details.reason !== "string" || details.code !== void 0 && (!Number.isInteger(details.code) || typeof details.wasClean !== "boolean")) {
784
793
  this.#fail(new TypeError("Invalid terminal transport close details"));
785
794
  return;
786
795
  }
787
796
  const snapshot = details.code === void 0 ? { reason: details.reason } : { code: details.code, reason: details.reason, wasClean: details.wasClean };
788
- this.dispose();
789
- this.callbacks.onClose(snapshot);
797
+ this.#closing = true;
798
+ void this.#close(snapshot);
790
799
  },
791
800
  onError: (error) => this.#fail(error)
792
801
  });
@@ -806,6 +815,7 @@ var init_transport_session = __esm({
806
815
  async #receive(frame) {
807
816
  try {
808
817
  this.#abort.signal.throwIfAborted();
818
+ if (this.#closing) throw new Error("Terminal transport is closing");
809
819
  if (this.#frame) throw new Error("Concurrent terminal transport frames are not allowed");
810
820
  if (!(frame instanceof ArrayBuffer) && !(frame instanceof Uint8Array && frame.buffer instanceof ArrayBuffer))
811
821
  throw new TypeError("Transport frames must be ArrayBuffer or Uint8Array backed by ArrayBuffer");
@@ -823,8 +833,19 @@ var init_transport_session = __esm({
823
833
  throw error;
824
834
  }
825
835
  }
836
+ async #close(details) {
837
+ try {
838
+ await this.#receiving;
839
+ } catch {
840
+ return;
841
+ }
842
+ if (this.#abort.signal.aborted) return;
843
+ this.dispose();
844
+ this.callbacks.onClose(details);
845
+ }
826
846
  send(control) {
827
847
  if (this.#abort.signal.aborted) return Promise.reject(this.#abort.signal.reason);
848
+ if (this.#closing) return Promise.reject(new DOMException("Terminal transport is closing", "AbortError"));
828
849
  const bytes = new TextEncoder().encode(control).byteLength;
829
850
  if (this.#queue.length >= 256 || this.#bytes + bytes > 1024 * 1024) {
830
851
  const error = new Error("Terminal transport control queue exceeded its bounded capacity");
@@ -842,7 +863,7 @@ var init_transport_session = __esm({
842
863
  this.#sending = true;
843
864
  try {
844
865
  await this.#ready.promise;
845
- while (!this.#abort.signal.aborted && this.#queue.length) {
866
+ while (!this.#abort.signal.aborted && !this.#closing && this.#queue.length) {
846
867
  const item = this.#queue[0];
847
868
  await this.#connection.send(item.control);
848
869
  if (this.#abort.signal.aborted) return;
@@ -851,7 +872,7 @@ var init_transport_session = __esm({
851
872
  item.resolve();
852
873
  }
853
874
  } catch (error) {
854
- this.#fail(error);
875
+ if (!this.#closing) this.#fail(error);
855
876
  } finally {
856
877
  this.#sending = false;
857
878
  }
@@ -2503,10 +2524,21 @@ function postStatus(message, level = "info") {
2503
2524
  self.postMessage({ type: "status", message, level });
2504
2525
  }
2505
2526
  function send(message) {
2506
- if (stats.connected && !failed && !stopped) void transport?.send(JSON.stringify(message)).catch(fail);
2527
+ if (stats.connected && !disconnected && !transport?.closing && !failed && !stopped) {
2528
+ void transport?.send(JSON.stringify(message)).catch((error) => {
2529
+ if (!transport?.closing) fail(error);
2530
+ });
2531
+ }
2507
2532
  }
2508
2533
  function emitStats(text) {
2509
2534
  if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());
2535
+ else if (renderer?.disposed) Object.assign(stats, {
2536
+ imageCount: 0,
2537
+ textureBytes: 0,
2538
+ atlasGlyphs: 0,
2539
+ atlasBytes: 0,
2540
+ instanceBufferBytes: 0
2541
+ });
2510
2542
  self.postMessage({ type: "stats", stats: { ...stats }, ...text === void 0 ? {} : { text } });
2511
2543
  }
2512
2544
  function fail(error) {
@@ -2524,11 +2556,11 @@ function fail(error) {
2524
2556
  }
2525
2557
  function scheduleRender() {
2526
2558
  needsRender = true;
2527
- if (scheduled || drawing || processing || failed || stopped || !metadata || markerPages.pending || awaitingFull) return;
2559
+ if (scheduled || drawing || processing || failed || stopped || disconnected && stats.connected || !metadata || markerPages.pending || awaitingFull) return;
2528
2560
  scheduled = true;
2529
2561
  self.requestAnimationFrame(() => {
2530
2562
  scheduled = false;
2531
- if (processing || drawing || failed || stopped || markerPages.pending || awaitingFull) return;
2563
+ if (processing || drawing || failed || stopped || disconnected && stats.connected || markerPages.pending || awaitingFull) return;
2532
2564
  renderPromise = drawFrame();
2533
2565
  });
2534
2566
  }
@@ -2541,7 +2573,7 @@ async function drawFrame() {
2541
2573
  if (frame) links.prepare(cells, metadata);
2542
2574
  const linkSubmission = links.submission();
2543
2575
  renderer.resize(metadata.columns, metadata.rows, viewport);
2544
- const blink = Math.floor(performance.now() / 600) % 2 === 0;
2576
+ const blink = disconnected || Math.floor(performance.now() / 600) % 2 === 0;
2545
2577
  const result = renderer.render(cells, metadata, blink, linkSubmission.mask, palette);
2546
2578
  await renderer.idle();
2547
2579
  if (failed || stopped) return;
@@ -2605,7 +2637,7 @@ async function drawFrame() {
2605
2637
  }
2606
2638
  }
2607
2639
  async function receiveFrame(buffer) {
2608
- if (failed || stopped || !renderer) return;
2640
+ if (failed || stopped || disconnected || !renderer) return;
2609
2641
  if (frameInFlight) throw new Error("Server sent a second state frame before acknowledgement");
2610
2642
  frameInFlight = true;
2611
2643
  processing = true;
@@ -2666,6 +2698,7 @@ async function receiveFrame(buffer) {
2666
2698
  }
2667
2699
  async function initialize(message) {
2668
2700
  if (renderer || transport) throw new Error("Worker is already initialized");
2701
+ preserveOnDisconnect = message.preserveOnDisconnect ?? false;
2669
2702
  palette = compilePalette(normalizePalette(message.palette === void 0 ? defaultDarkPalette : message.palette));
2670
2703
  if (typeof self.requestAnimationFrame !== "function") {
2671
2704
  throw new Error("This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker");
@@ -2693,17 +2726,31 @@ async function initialize(message) {
2693
2726
  onFrame: receiveFrame,
2694
2727
  onError: fail,
2695
2728
  onClose(details) {
2696
- if (failed || stopped) return;
2697
- stats.connected = false;
2698
- stats.gpu = "stopped";
2699
- stats.fps = 0;
2700
- stopped = true;
2729
+ if (failed || stopped || disconnected) return;
2730
+ disconnected = true;
2701
2731
  clearInterval(metricsTimer);
2702
2732
  clearInterval(blinkTimer);
2703
- renderer?.dispose();
2704
- self.postMessage({ type: "closed", details });
2705
- postStatus(`View disconnected (${details.code === void 0 ? details.reason : `${details.code}${details.reason ? `: ${details.reason}` : ""}`}). Attach another view to reconnect.`, "error");
2706
- emitStats();
2733
+ void finishClose().catch(fail);
2734
+ async function finishClose() {
2735
+ if (preserveOnDisconnect) {
2736
+ await renderPromise;
2737
+ if (failed || stopped) return;
2738
+ if ((needsRender || hasBlink && !lastBlink) && !markerPages.pending && !awaitingFull) {
2739
+ needsRender = true;
2740
+ await drawFrame();
2741
+ }
2742
+ if (failed || stopped) return;
2743
+ } else {
2744
+ stopped = true;
2745
+ renderer?.dispose();
2746
+ }
2747
+ stats.connected = false;
2748
+ stats.gpu = "stopped";
2749
+ stats.fps = 0;
2750
+ self.postMessage({ type: "closed", details });
2751
+ postStatus(`View disconnected (${details.code === void 0 ? details.reason : `${details.code}${details.reason ? `: ${details.reason}` : ""}`}). Attach another view to reconnect.`, "error");
2752
+ emitStats();
2753
+ }
2707
2754
  }
2708
2755
  });
2709
2756
  metricsTimer = setInterval(() => {
@@ -2732,7 +2779,7 @@ async function initialize(message) {
2732
2779
  }
2733
2780
  });
2734
2781
  }
2735
- var renderer, transport, bridge, bridgeReady, bridgeSent, failed, stopped, processing, drawing, frameInFlight, scheduled, needsRender, hasBlink, lastBlink, metadata, cells, localRevision, pendingFrame, renderPromise, metricsTimer, blinkTimer, viewport, links, markerPages, awaitingFull, requestedColorEncoding, palette, stats, sample;
2782
+ var renderer, transport, bridge, bridgeReady, bridgeSent, failed, stopped, disconnected, preserveOnDisconnect, processing, drawing, frameInFlight, scheduled, needsRender, hasBlink, lastBlink, metadata, cells, localRevision, pendingFrame, renderPromise, metricsTimer, blinkTimer, viewport, links, markerPages, awaitingFull, requestedColorEncoding, palette, stats, sample;
2736
2783
  var init_terminal_worker = __esm({
2737
2784
  "src/terminal-worker.ts"() {
2738
2785
  "use strict";
@@ -2746,6 +2793,8 @@ var init_terminal_worker = __esm({
2746
2793
  init_transport_session();
2747
2794
  failed = false;
2748
2795
  stopped = false;
2796
+ disconnected = false;
2797
+ preserveOnDisconnect = false;
2749
2798
  processing = false;
2750
2799
  drawing = false;
2751
2800
  frameInFlight = false;
@@ -2847,7 +2896,7 @@ var init_terminal_worker = __esm({
2847
2896
  } catch (error) {
2848
2897
  fail(error);
2849
2898
  }
2850
- } else if (message.type === "linkDetection" && !failed && !stopped) {
2899
+ } else if (message.type === "linkDetection" && !failed && !stopped && !disconnected) {
2851
2900
  try {
2852
2901
  if (!links.configure(message.enabled, message.generation)) return;
2853
2902
  if (!processing && !drawing && !pendingFrame) {
@@ -2858,7 +2907,7 @@ var init_terminal_worker = __esm({
2858
2907
  } catch (error) {
2859
2908
  fail(error);
2860
2909
  }
2861
- } else if (message.type === "linkDecorations" && !failed && !stopped) {
2910
+ } else if (message.type === "linkDecorations" && !failed && !stopped && !disconnected) {
2862
2911
  try {
2863
2912
  if (links.accept(
2864
2913
  message.revision,
@@ -5496,6 +5545,7 @@ var WebTerminal = class _WebTerminal {
5496
5545
  #connected = false;
5497
5546
  #closed = false;
5498
5547
  #readOnly;
5548
+ #preserveOnDisconnect;
5499
5549
  #disposed = false;
5500
5550
  #hasGeometry = false;
5501
5551
  #resizeTimer;
@@ -5575,6 +5625,9 @@ var WebTerminal = class _WebTerminal {
5575
5625
  if (options.readOnly !== void 0 && typeof options.readOnly !== "boolean")
5576
5626
  throw new TypeError("readOnly must be a boolean");
5577
5627
  this.#readOnly = options.readOnly ?? false;
5628
+ if (options.preserveOnDisconnect !== void 0 && typeof options.preserveOnDisconnect !== "boolean")
5629
+ throw new TypeError("preserveOnDisconnect must be a boolean");
5630
+ this.#preserveOnDisconnect = options.preserveOnDisconnect ?? false;
5578
5631
  this.#renderer = normalizeRenderer(options.renderer);
5579
5632
  this.#colorMode = normalizeColorMode(options.colorMode);
5580
5633
  this.#palettes = {
@@ -5889,7 +5942,8 @@ var WebTerminal = class _WebTerminal {
5889
5942
  scale,
5890
5943
  font,
5891
5944
  renderer: this.#renderer,
5892
- palette: this.#palettes[this.resolvedColorMode]
5945
+ palette: this.#palettes[this.resolvedColorMode],
5946
+ preserveOnDisconnect: this.#preserveOnDisconnect
5893
5947
  }, [canvas]);
5894
5948
  this.#applyPalette();
5895
5949
  this.#postLinkConfiguration();
@@ -5914,7 +5968,8 @@ var WebTerminal = class _WebTerminal {
5914
5968
  this.#transportSession.start(this.#transport);
5915
5969
  } else if (message.type === "transportSend") {
5916
5970
  void Promise.resolve(this.#transportFrame?.promise).then(() => this.#transportSession?.send(message.control)).then(() => this.#post({ type: "transportSent" })).catch((error) => {
5917
- if (!this.#disposed) this.#post({ type: "transportError", message: errorMessage(error) });
5971
+ if (!this.#disposed && !this.#transportSession?.closing)
5972
+ this.#post({ type: "transportError", message: errorMessage(error) });
5918
5973
  });
5919
5974
  } else if (message.type === "transportReceived") {
5920
5975
  this.#transportFrame?.resolve();