@cplieger/web-terminal-ui 5.2.2 → 5.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.
@@ -20,14 +20,24 @@
20
20
  // default the previous UI already had); adding mouse tracking with focus
21
21
  // suppression is a tracked follow-up.
22
22
 
23
- import { render, scroll, connection, keyboard, modes } from "@cplieger/web-terminal-engine";
23
+ import {
24
+ render,
25
+ scroll,
26
+ connection,
27
+ keyboard,
28
+ modes,
29
+ LineStore,
30
+ } from "@cplieger/web-terminal-engine";
24
31
  import * as composition from "../composition.js";
25
32
  import * as viewport from "../viewport.js";
26
33
  import { INPUT_PLACEHOLDER, resetToPlaceholder } from "../input-placeholder.js";
27
34
  import { createBus } from "./bus.js";
35
+ import { optionalPositiveIntOption } from "./options.js";
36
+ import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "./gesture.js";
28
37
  import { createRegions } from "./regions.js";
29
38
  import { createAnnouncer, createTablist } from "./a11y.js";
30
39
  import { createConnState } from "./conn-state.js";
40
+ import { createScrollbackKeeper } from "./scrollback.js";
31
41
  import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
32
42
  import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
33
43
  import type {
@@ -43,14 +53,15 @@ import type {
43
53
  const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
44
54
 
45
55
  const DEFAULT_WS_PATH = "/ws";
46
- const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
56
+ const DEFAULT_FONT_READY = '14px "Monaspace Neon NF"';
57
+
47
58
  const TOAST_MS = 3000;
48
- const TAP_MOVEMENT_PX = 10;
49
59
  // A touch that focuses the input (opens the soft keyboard) must be a genuine
50
60
  // tap: short and low-movement. A longer hold is a long-press, which belongs to
51
61
  // native text selection / the context menu — so tap-to-focus bows out above
52
- // this duration and never steals a long-press or a selection.
53
- const TAP_MAX_MS = 500;
62
+ // this duration and never steals a long-press or a selection. The thresholds
63
+ // live in gesture.ts because the contextMenu feature classifies the other side
64
+ // of the same boundary from them.
54
65
  // The narrow-layout breakpoints, in ROOT pixels. The single source of the
55
66
  // numbers: the kernel mirrors the resulting fact into the .wt-narrow root
56
67
  // class for CSS (paired there with pointer-coarseness media queries where
@@ -166,6 +177,21 @@ function buildTerminal(
166
177
  ): TerminalHandle {
167
178
  const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
168
179
  const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
180
+ // The one resolved retained-line cap: the engine renderer's implicit store
181
+ // and every ctx.newLineStore() (the tabs switching cache) both honor it, so
182
+ // a consumer sets its memory budget in exactly one place.
183
+ // Invalid reads as OMITTED, so the engine's own default applies — and the engine
184
+ // additionally floors the cap at the live screen, so even a tiny valid value
185
+ // cannot truncate the visible window.
186
+ const scrollbackLines = optionalPositiveIntOption(opts.scrollbackLines, "scrollbackLines");
187
+ // Scrollback persistence (off unless the consumer supplied storage). One
188
+ // keeper serves the whole terminal: the kernel's implicit store below and
189
+ // every per-session store a session-owning feature creates through
190
+ // ctx.newLineStore(sessionId).
191
+ const scrollbackKeeper =
192
+ opts.persistScrollback !== undefined
193
+ ? createScrollbackKeeper(opts.persistScrollback, scrollbackLines)
194
+ : null;
169
195
  // At most one feature owns session selection (fail fast, before any DOM
170
196
  // work): the kernel drives the first connect through its registration.
171
197
  const owners = featureList.filter((f) => f.sessionOwner !== undefined);
@@ -330,6 +356,89 @@ function buildTerminal(
330
356
 
331
357
  // --- Loading lifecycle ---
332
358
  let ready = false;
359
+ /**
360
+ * Every LineStore this kernel hands out, held WEAKLY and keyed by session. A
361
+ * closed tab's store must stay collectable, and a weak registry cannot leak or
362
+ * go stale the way a strong map paired with an unregister call can — the store
363
+ * outlives the tab record in more than one teardown order.
364
+ *
365
+ * Keyed rather than a bare set because two features need to reach a SPECIFIC
366
+ * session's store, not just "all of them": the restore guard below, and the
367
+ * browse-cache sweep. The implicit store (a bare kernel with no session owner)
368
+ * has no id, so it takes a reserved key no session can collide with.
369
+ */
370
+ const knownStores = new Map<string, WeakRef<LineStore>>();
371
+ const IMPLICIT_STORE_KEY = "\u0000implicit";
372
+ /** The live stores, pruning entries whose store has been collected. */
373
+ function liveStores(): LineStore[] {
374
+ const out: LineStore[] = [];
375
+ for (const [key, ref] of knownStores) {
376
+ const store = ref.deref();
377
+ if (store === undefined) {
378
+ knownStores.delete(key); // the tab closed and its store was collected
379
+ continue;
380
+ }
381
+ out.push(store);
382
+ }
383
+ return out;
384
+ }
385
+
386
+ // Which restored scrollbacks have not yet been confirmed against the live
387
+ // server. A session enters when its snapshot is hydrated and leaves on the
388
+ // first resumeAck for THAT session; in between, the content is last session's —
389
+ // plausible but unconfirmed.
390
+ //
391
+ // Per SESSION, and that is the whole point. Two booleans stood here, and under
392
+ // the tabs preset (which every reference app uses) they could not do the job:
393
+ // N sessions are hydrated at boot and all set the same flag, the active
394
+ // session's first ack cleared it for everyone, and the reset reached only the
395
+ // renderer-BOUND store. So tabs 2..N were neither verified nor discardable for
396
+ // the life of the page, and the exact failure this guard exists to prevent —
397
+ // the previous run's output under a "Session ended" banner — stayed reachable
398
+ // by switching tabs. The claimed invariant ("only ever discards a restore,
399
+ // never live content") also did not hold: a background tab's restore
400
+ // authorised a reset of the bound tab's store.
401
+ const unverifiedRestores = new Set<string>();
402
+ /** Drop restored scrollback that will never be confirmed.
403
+ *
404
+ * The epoch check makes a stale restore self-correcting ONLY when a resumeAck
405
+ * arrives: that is what compares the seeded epoch and fires the restart reset.
406
+ * Two closes reach `markReady` without one — a process-exited 4001 (the session
407
+ * is gone, which is what a container restart leaves behind) and a wire-revision
408
+ * refusal (a cached client one revision behind, which is what an image update
409
+ * leaves behind). Both are exactly the restart case, and without this the
410
+ * overlay lifts over the PREVIOUS run's output under a "Session ended" banner —
411
+ * the one path where the design's stated worst case was reachable.
412
+ *
413
+ * SCOPE matters because the two triggers differ: a 4001 says one session's
414
+ * process is gone, so only that session's restore is condemned; a wire refusal
415
+ * says this client cannot talk to this server at all, so every unverified
416
+ * restore is. Discarding page-wide on a 4001 would wipe restores for sessions
417
+ * that are perfectly alive.
418
+ *
419
+ * Only ever discards a restore, never live content: a session leaves the set
420
+ * the moment its resume is served, and a session that was never hydrated never
421
+ * enters it. */
422
+ function discardUnverifiedRestore(scope: { session: string } | "page"): void {
423
+ const ids = scope === "page" ? [...unverifiedRestores] : [scope.session];
424
+ const bound = render.boundStore();
425
+ for (const id of ids) {
426
+ if (!unverifiedRestores.delete(id)) {
427
+ continue; // never hydrated, or already verified
428
+ }
429
+ const store = knownStores.get(id)?.deref();
430
+ if (store === undefined || store === bound) {
431
+ // The BOUND store goes through the renderer so the DOM is reconciled;
432
+ // an unknown id can only be the implicit store, which is also bound.
433
+ render.resetScrollback();
434
+ render.resetScreen();
435
+ continue;
436
+ }
437
+ // A background store has no DOM: resetting it is enough, and the next
438
+ // bind rebuilds from whatever it then holds.
439
+ store.reset();
440
+ }
441
+ }
333
442
  let firstFrameRendered = false;
334
443
  let fontsLoaded = false;
335
444
  let wsOpen = false;
@@ -376,6 +485,14 @@ function buildTerminal(
376
485
  composition.positionCompositionView();
377
486
  bus.emit("render:cursor", undefined);
378
487
  },
488
+ // Demand-paged scrollback (engine docs/paged-scrollback.md §5.4). The
489
+ // renderer owns the DECISION (which gap the reader is approaching, and how
490
+ // much to ask for); the transport owns the request. Wrapped rather than
491
+ // passed by reference so neither module's identity leaks into the other's
492
+ // options object.
493
+ requestHistory: (fromAbs, maxLines) => connection.requestHistory(fromAbs, maxLines),
494
+ historyBudget: () => connection.historyBudget(),
495
+ ...(scrollbackLines !== undefined ? { maxLines: scrollbackLines } : {}),
379
496
  });
380
497
  render.updateFontMetrics();
381
498
 
@@ -392,20 +509,84 @@ function buildTerminal(
392
509
  onUserScrollChange(scrolledUp) {
393
510
  bus.emit("scroll:state", { scrolledUp });
394
511
  },
512
+ // Every position change, not only a follow/hold toggle: a reader scrolling
513
+ // WITHIN history never toggles follow, and that is exactly when paging has
514
+ // to work. The trigger re-evaluates its own guards, so firing for the
515
+ // browser's own clamps as well as gestures costs nothing.
516
+ onScrollPosition: () => {
517
+ render.maybeFetchHistory();
518
+ },
395
519
  });
396
520
 
521
+ // The size this client would announce right now, or null when it cannot
522
+ // measure trustworthily yet. Both the resume-time announce (the engine's
523
+ // Callbacks.initialSize) and the open-time send below read it, so there is one
524
+ // definition of "measurable" rather than two that can disagree.
525
+ //
526
+ // Unmeasurable means either web fonts are still loading (cell metrics would be
527
+ // wrong, so cols/rows would be) or a viewport transition is in flight (an iOS
528
+ // keyboard slide or rotation, whose intermediate geometry is provisional). In
529
+ // both cases announcing costs a second resize once the real size is known, and
530
+ // therefore a second redraw from any program that repaints on SIGWINCH.
531
+ function measurableSize(): { cols: number; rows: number } | null {
532
+ if (!fontsLoaded || viewport.isInTransition()) {
533
+ return null;
534
+ }
535
+ render.updateFontMetrics();
536
+ return render.computeSize();
537
+ }
538
+
397
539
  function maybeSendFirstResize(): void {
398
- if (!fontsLoaded || !wsOpen) {
540
+ if (!wsOpen || measurableSize() === null) {
399
541
  return;
400
542
  }
401
- render.updateFontMetrics();
402
543
  connection.sendResize();
403
544
  }
404
545
 
405
546
  connection.init({
406
547
  computeSize: render.computeSize,
407
548
  getHaveThrough: render.getHighestIndex,
408
- onResumeBounds: render.noteResumeBounds,
549
+ // --- Demand-paged scrollback (engine docs/paged-scrollback.md §4-5) ---
550
+ // The transport is store-blind and viewport-blind by design, so every one of
551
+ // these forwards a decision only the renderer can make. Without them the
552
+ // engine's paging machinery is inert: the server still declares the
553
+ // capability and still bounds its replay, but nothing ever asks for a page.
554
+ //
555
+ // How much replay this client wants on attach: no more than it intends to
556
+ // keep resident. The server bounds the replay regardless, and the client
557
+ // sends the same number, so its replay-jump prediction and the server's
558
+ // actual start agree.
559
+ getReplayMax: render.replayMaxForResume,
560
+ onHistoryReply: render.handleHistoryReply,
561
+ // ONE transition per ack, carrying the values this socket SENT. It must not
562
+ // be split into separate callbacks: the store's five steps (epoch reset,
563
+ // bounds, cap flip, replay-jump prediction, budget pass) are ordered against
564
+ // each other, and an implementer with five hooks can interleave them.
565
+ onResumeTransition: render.applyResumeTransition,
566
+ // The solicited window: the store's permission to admit lines below its
567
+ // stale-re-send watermark, opened for exactly the range in flight and closed
568
+ // when the reply lands.
569
+ noteSolicited: render.noteSolicited,
570
+ clearSolicited: render.clearSolicited,
571
+ // A denied request (paced by the token bucket, or a data timeout) re-runs the
572
+ // FULL trigger rather than replaying the denied range: by the time the bucket
573
+ // refills the gap may have healed, or the session may have entered alt.
574
+ onHistoryRetry: () => {
575
+ render.maybeFetchHistory();
576
+ },
577
+ onResumeBounds(committed, oldest) {
578
+ // A resume VERIFIES the restored store of the session it belongs to: the
579
+ // server has answered under a known epoch, so that content is confirmed
580
+ // rather than merely plausible. Recorded here because onResumeBounds rides
581
+ // every resumeAck, which is also where the epoch comparison happens. Scoped
582
+ // to the socket's OWN session — one ack cannot vouch for a tab it never
583
+ // talked to.
584
+ unverifiedRestores.delete(connection.currentSessionId());
585
+ render.noteResumeBounds(committed, oldest);
586
+ },
587
+ // Announced BEFORE the resume so the server's snapshot and history replay
588
+ // come back at this client's geometry (engine Callbacks.initialSize).
589
+ initialSize: measurableSize,
409
590
  wsPath,
410
591
  onMessage(msg) {
411
592
  if (msg.type === "screen") {
@@ -459,6 +640,7 @@ function buildTerminal(
459
640
  // wedge. Then surface the end state: "Session ended", not a flapping
460
641
  // "Reconnecting…", since no reconnect is coming. The final screen (when
461
642
  // the server delivered it) stays rendered behind the banner.
643
+ discardUnverifiedRestore({ session: connection.currentSessionId() });
462
644
  markReady();
463
645
  connState.ended();
464
646
  },
@@ -466,6 +648,7 @@ function buildTerminal(
466
648
  // Compatibility refusal is terminal for this page instance. Lower the
467
649
  // pre-JS overlay even if no screen frame arrived, then leave the
468
650
  // actionable banner visible while the engine suppresses reconnects.
651
+ discardUnverifiedRestore("page");
469
652
  markReady();
470
653
  connState.incompatible();
471
654
  },
@@ -614,7 +797,7 @@ function buildTerminal(
614
797
  if (e.pointerType !== "touch") {
615
798
  return;
616
799
  }
617
- if ((e.target as HTMLElement).closest(".term-link")) {
800
+ if (isLinkTarget(e.target)) {
618
801
  return;
619
802
  }
620
803
  const dx = Math.abs(e.clientX - pointerDownX);
@@ -657,13 +840,47 @@ function buildTerminal(
657
840
  termWrap.addEventListener(
658
841
  "mousedown",
659
842
  (e) => {
660
- // Cancel the synthetic mousedown after a touch tap so iOS keeps the
661
- // keyboard up (xterm.js focus-preservation pattern, scoped to touch). Skip
662
- // it when a fine pointer is present (iPad + trackpad / Magic Keyboard):
663
- // there is no soft keyboard to protect, and suppressing the mousedown was
664
- // defeating the native focus, so the terminal needed several taps to focus.
665
- if (lastPointerType === "touch" && !hasFinePointer()) {
666
- e.preventDefault();
843
+ // Touch is the platform's press, start to finish: its own selection UI
844
+ // (long-press word select, the drag handles, the OS callout) owns a press
845
+ // that lands on a selection, and it never uses HTML5 drag-and-drop. So a
846
+ // touch press gets nothing here but the keyboard preservation below, and
847
+ // the selection policy that follows is deliberately mouse-and-pen only.
848
+ if (lastPointerType === "touch") {
849
+ // Cancel the synthetic mousedown after a touch tap so iOS keeps the
850
+ // keyboard up (xterm.js focus-preservation pattern, scoped to touch).
851
+ // Skip it when a fine pointer is present (iPad + trackpad / Magic
852
+ // Keyboard): there is no soft keyboard to protect, and suppressing the
853
+ // mousedown was defeating the native focus, so the terminal needed
854
+ // several taps to focus.
855
+ if (!hasFinePointer()) {
856
+ e.preventDefault();
857
+ }
858
+ return;
859
+ }
860
+ // Terminal text is display-only, never draggable content — but a browser
861
+ // does not know that, and a bare left press that lands INSIDE the current
862
+ // selection is the one gesture where it matters: Blink and Gecko both read
863
+ // it as the start of a native drag-and-drop of the selected text, so the
864
+ // press neither collapses the selection nor begins a new one, and the
865
+ // release drops nothing (there is no drop target). Drag over text you just
866
+ // selected and the selection is stuck: no new selection, and not even a
867
+ // click clears it, because a real mouse always moves a pixel or two and
868
+ // that is enough to re-enter the drag path. Collapsing the selection here,
869
+ // before the browser resolves the gesture, is what keeps it out: with
870
+ // nothing selected under the press both engines take their ordinary select
871
+ // path and the drag is never considered. A press OUTSIDE the selection
872
+ // collapses it natively anyway, so this only ever pre-empts the browser,
873
+ // never contradicts it — no hit test against the selection needed.
874
+ // Non-left and modified presses are left alone: a right-click has to keep
875
+ // the selection for the context menu's Copy, a middle-click for the
876
+ // primary selection it pastes, Shift-click extends a selection, and
877
+ // Ctrl-drag adds a second range in Firefox.
878
+ if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) {
879
+ return;
880
+ }
881
+ const pressSel = window.getSelection();
882
+ if (pressSel && !pressSel.isCollapsed) {
883
+ pressSel.removeAllRanges();
667
884
  }
668
885
  },
669
886
  { signal },
@@ -695,8 +912,11 @@ function buildTerminal(
695
912
  root,
696
913
  suppressKeyboardInset: hasFinePointer,
697
914
  onSettled() {
698
- render.updateFontMetrics();
699
- if (fontsLoaded) {
915
+ // The settle is the authoritative size for this geometry: measurableSize
916
+ // re-measures the font metrics and now reports a viewport that has stopped
917
+ // moving, so this is the one resize a keyboard slide or rotation should
918
+ // cost. sendResize deduplicates, so a settle that changed nothing is free.
919
+ if (measurableSize() !== null) {
700
920
  connection.sendResize();
701
921
  }
702
922
  composition.positionCompositionView();
@@ -726,16 +946,124 @@ function buildTerminal(
726
946
  onFontSettled();
727
947
  }
728
948
 
949
+ // --- Browse-cache TTL (engine docs/paged-scrollback.md §5.6) ---
950
+ //
951
+ // Paged-in history is disposable by construction: recovery is one fetch. The
952
+ // ENGINE provides the mechanism and this layer owns the clock, because the
953
+ // engine has no notion of a page or a tab. Eviction is by INACTIVITY, never
954
+ // eagerly, so that rapid scrolling in any direction stays instant.
955
+ //
956
+ // The visible-page drop is CONDITIONAL inside the store: a reader parked on
957
+ // cached rows is inactive while looking straight at them, so the store skips
958
+ // and this timer retries. A hidden page has no reader, so its drop is
959
+ // unconditional.
960
+ const BROWSE_CACHE_TTL_MS = 5 * 60_000;
961
+ /**
962
+ * Every store EXCEPT the renderer-bound one, pruning refs whose store has been
963
+ * collected. A background tab has no reader, which is what lets both callers
964
+ * below drop its cache without a position to protect.
965
+ */
966
+ const backgroundStores = (bound: LineStore): LineStore[] => {
967
+ const out: LineStore[] = [];
968
+ for (const store of liveStores()) {
969
+ if (store !== bound) {
970
+ out.push(store);
971
+ }
972
+ }
973
+ return out;
974
+ };
975
+ const browseSweep = window.setInterval(() => {
976
+ // The BOUND store is the one with a reader, so its drop is conditional and
977
+ // goes through the renderer — the only layer that knows where that reader is.
978
+ const bound = render.boundStore();
979
+ if (
980
+ render.browseCacheSize() > 0 &&
981
+ Date.now() - render.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
982
+ ) {
983
+ render.dropBrowseCache(document.visibilityState === "visible");
984
+ }
985
+ // Every OTHER store belongs to a background tab. Without this pass the sweep
986
+ // only ever reached the visible tab and every background tab's cache was
987
+ // immortal for the life of the page — up to the engine's whole cache budget
988
+ // per tab, on the phone this feature exists for.
989
+ for (const store of backgroundStores(bound)) {
990
+ if (
991
+ store.browseCacheSize() > 0 &&
992
+ Date.now() - store.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
993
+ ) {
994
+ store.dropBrowseCache(-1, false); // no reader: no position to exempt
995
+ }
996
+ }
997
+ }, 60_000);
998
+
999
+ /**
1000
+ * Drop every cache unconditionally, TTL ignored. The LAST CHANCE handler: it
1001
+ * runs when the page is about to stop executing entirely, which is the one
1002
+ * state the sweep above cannot cover — a frozen page runs no code at all, so
1003
+ * without this its caches stay resident for as long as the freeze lasts, and a
1004
+ * discard then throws them away unread. Ten to twenty megabytes (estimate), for
1005
+ * an unbounded time, for nobody.
1006
+ *
1007
+ * Unconditional is right HERE and wrong on the return transition, and the
1008
+ * difference is which way the reader is walking. `visibilitychange` to visible
1009
+ * fires as a reader ARRIVES, so dropping there deletes the rows they are about
1010
+ * to look at (that drop existed and was removed). `freeze` fires as the page
1011
+ * stops being a running program, with no reader and none imminent — and the
1012
+ * cache is disposable by construction, so the cost of being wrong is one fetch
1013
+ * on a return that may never come.
1014
+ */
1015
+ const dropEveryBrowseCache = (): void => {
1016
+ const bound = render.boundStore();
1017
+ render.dropBrowseCache(false); // through the renderer: it schedules the reconcile
1018
+ for (const store of backgroundStores(bound)) {
1019
+ store.dropBrowseCache(-1, false);
1020
+ }
1021
+ };
1022
+ // Two hooks for one state, because no single one covers both engines: `freeze`
1023
+ // is Chrome's Page Lifecycle signal, and `pagehide` with `persisted` is entry
1024
+ // into the back/forward cache — the Safari path, on the platform this feature
1025
+ // is for. Either can fire without the other.
1026
+ document.addEventListener(
1027
+ "freeze",
1028
+ () => {
1029
+ // Persist BEFORE dropping. The keeper already wrote on `visibilitychange`
1030
+ // to hidden, but a hidden page's socket keeps delivering, so the tail can
1031
+ // have advanced since — and `freeze` is the last code that runs, so a write
1032
+ // skipped here is a write that never happens. The order matters only in that
1033
+ // direction: the snapshot excludes browse cache by classification, so
1034
+ // flushing first costs nothing and dropping first could lose a page of live
1035
+ // tail on a page that never wakes.
1036
+ scrollbackKeeper?.flush();
1037
+ dropEveryBrowseCache();
1038
+ },
1039
+ { signal },
1040
+ );
1041
+ signal.addEventListener("abort", () => {
1042
+ clearInterval(browseSweep);
1043
+ });
1044
+
729
1045
  // --- Reconnect-on-wake ---
730
1046
  document.addEventListener(
731
1047
  "visibilitychange",
732
1048
  () => {
733
1049
  if (document.visibilityState === "visible") {
1050
+ // No cache drop on the RETURN transition. An earlier version enforced the
1051
+ // TTL the throttled hidden period owed, with hidden-page semantics
1052
+ // (unconditional) — which deleted the rows the returning reader was
1053
+ // parked on, in the one moment they are certain to look at them. The page
1054
+ // is visible again the instant this fires, so the visible-page rule
1055
+ // applies, and that is exactly what the periodic sweep does within its
1056
+ // next tick. There is nothing left for this branch to add.
734
1057
  if (connectionInitiated) {
735
1058
  connection.reconnectNow();
736
1059
  }
737
1060
  focusTerminal();
1061
+ return;
738
1062
  }
1063
+ // Hidden. This and pagehide are the last callbacks that reliably run
1064
+ // before a discard, and a discard is the case scrollback persistence
1065
+ // exists for, so write now rather than waiting for the background pass.
1066
+ scrollbackKeeper?.flush();
739
1067
  },
740
1068
  { signal },
741
1069
  );
@@ -749,6 +1077,22 @@ function buildTerminal(
749
1077
  },
750
1078
  { signal },
751
1079
  );
1080
+ window.addEventListener(
1081
+ "pagehide",
1082
+ (event) => {
1083
+ // Both halves of the last-chance write: visibilitychange fires when the
1084
+ // page is backgrounded, pagehide when it is unloaded or frozen into
1085
+ // bfcache. Chrome's page-lifecycle guidance is explicit that pagehide is
1086
+ // not guaranteed to run, which is why the keeper also saves on a timer.
1087
+ scrollbackKeeper?.flush();
1088
+ if (event.persisted) {
1089
+ // Into the back/forward cache: the page is frozen, so this is the last
1090
+ // code that runs until it is restored — if it ever is.
1091
+ dropEveryBrowseCache();
1092
+ }
1093
+ },
1094
+ { signal },
1095
+ );
752
1096
  window.addEventListener(
753
1097
  "online",
754
1098
  () => {
@@ -870,6 +1214,30 @@ function buildTerminal(
870
1214
  loadingStatus.reason(message);
871
1215
  },
872
1216
  tablist: () => tablistController,
1217
+ newLineStore: (sessionId) => {
1218
+ // Registered on EVERY path, because this factory is the only way a store
1219
+ // enters the app and the browse-cache sweep has to be able to reach a
1220
+ // background tab's store (the renderer only ever sees the bound one).
1221
+ const track = (store: LineStore, key: string): LineStore => {
1222
+ knownStores.set(key, new WeakRef(store));
1223
+ return store;
1224
+ };
1225
+ if (scrollbackKeeper !== null) {
1226
+ if (sessionId !== undefined) {
1227
+ // Hydrated from storage when a usable entry exists, and tracked for
1228
+ // saving either way. Necessarily before this session connects: the
1229
+ // caller is building the tab, and the kernel's switch (which opens
1230
+ // the socket) happens afterwards.
1231
+ const store = scrollbackKeeper.storeFor(sessionId);
1232
+ if (store.highestIndex() >= 0) {
1233
+ unverifiedRestores.add(sessionId);
1234
+ }
1235
+ return track(store, sessionId);
1236
+ }
1237
+ scrollbackKeeper.noteMissingSessionId();
1238
+ }
1239
+ return track(new LineStore(scrollbackLines), sessionId ?? IMPLICIT_STORE_KEY);
1240
+ },
873
1241
  layout: () => ({
874
1242
  narrow: isNarrow(),
875
1243
  coarse:
@@ -880,6 +1248,11 @@ function buildTerminal(
880
1248
  },
881
1249
  dropSession(id) {
882
1250
  connection.forgetSession(id);
1251
+ // A closed tab's scrollback should not outlive it. This is also the only
1252
+ // collection path that runs promptly; the keeper's age bound covers the
1253
+ // case this one cannot see, which is a tab the browser discarded without
1254
+ // ever running a close.
1255
+ scrollbackKeeper?.forget(id);
883
1256
  },
884
1257
  onError(fn) {
885
1258
  errorHandlers.add(fn);
@@ -925,6 +1298,11 @@ function buildTerminal(
925
1298
  return;
926
1299
  }
927
1300
  runtimeCleaned = true;
1301
+ // Before anything is torn down: a deliberate teardown (a host closing an
1302
+ // embedded panel, a reload path calling destroy()) is still a page the user
1303
+ // may come back to, and the tracked stores are about to become unreachable.
1304
+ scrollbackKeeper?.flush();
1305
+ scrollbackKeeper?.stop();
928
1306
  teardownFeatures();
929
1307
  kernelAbort.abort();
930
1308
  narrowObserver?.disconnect();
@@ -1075,6 +1453,41 @@ function buildTerminal(
1075
1453
  render.updateFontMetrics();
1076
1454
  composition.positionCompositionView();
1077
1455
  if (!sessionOwner) {
1456
+ // A single unmanaged terminal has no feature to own its store, so the kernel
1457
+ // hydrates the renderer's implicit one here — before connect(), because the
1458
+ // resume announces what this client already holds (Callbacks.getHaveThrough)
1459
+ // and a store hydrated afterwards has already asked for everything.
1460
+ //
1461
+ // The session id comes from the engine rather than from this library: an
1462
+ // unmanaged terminal's identity is a per-tab, sessionStorage-backed id minted
1463
+ // inside the connection layer, with exactly the semantics a persistence key
1464
+ // needs (stable across a reload and an iOS tab restore, fresh in a genuinely
1465
+ // new tab). Under a session owner the same work happens per session, through
1466
+ // ctx.newLineStore(sessionId).
1467
+ //
1468
+ // Note what is deliberately NOT done: restored content does not dismiss the
1469
+ // loading overlay. It is last session's output until the resume confirms it,
1470
+ // and showing it as live is the same mistake the persisted epoch exists to
1471
+ // prevent. The overlay still lifts on the first real frame — which now
1472
+ // arrives sooner, because the replay behind it is a delta instead of a
1473
+ // refill.
1474
+ if (scrollbackKeeper !== null) {
1475
+ const sessionId = connection.currentSessionId();
1476
+ const restored = scrollbackKeeper.storeFor(sessionId);
1477
+ if (restored.highestIndex() >= 0) {
1478
+ render.bind(restored);
1479
+ // Registered under the SAME id the resumeAck will report, so the guard
1480
+ // can both verify and discard it. This store does not come through
1481
+ // `newLineStore` — an unmanaged terminal has no feature to own it.
1482
+ knownStores.set(sessionId, new WeakRef(restored));
1483
+ unverifiedRestores.add(sessionId);
1484
+ } else {
1485
+ // Nothing usable was stored. Keep the renderer's own store and track
1486
+ // that one instead of swapping in an empty replacement, so this path
1487
+ // changes nothing about a terminal with no snapshot to restore.
1488
+ scrollbackKeeper.track(sessionId, render.boundStore());
1489
+ }
1490
+ }
1078
1491
  connection.connect();
1079
1492
  connectionInitiated = true;
1080
1493
  }
@@ -0,0 +1,58 @@
1
+ // Validation for the consumer's numeric options, in ONE place.
2
+ //
3
+ // The policy is a product decision, not a formatting choice: an out-of-range
4
+ // numeric option is REJECTED with a warning and the library default applies. It
5
+ // is deliberately not clamped, because clamping hides the mistake — and every
6
+ // one of these knobs has a plausible typo that would silently reconfigure
7
+ // durability rather than fail (a zero `lines` persists nothing; a zero
8
+ // `maxAgeMs` expires everything instantly; a zero `scrollbackLines` would zero
9
+ // the scrollback budget).
10
+ //
11
+ // It lives here because three modules were enforcing it independently and one of
12
+ // them did not warn, so the SAME typo was reported or swallowed depending on
13
+ // which option carried it — `localScrollbackStorage({ maxAgeMs: 0 })` silently
14
+ // took the default while `persistScrollback: { maxAgeMs: 0 }` said so out loud.
15
+ // One class of mistake cannot have two reporting policies.
16
+
17
+ /**
18
+ * A positive integer, or `fallback` with a warning naming `path`.
19
+ *
20
+ * `path` is the full option path as the consumer wrote it
21
+ * (`"persistScrollback.maxAgeMs"`, `"localScrollbackStorage.maxBytes"`), so the
22
+ * warning points at their code rather than at ours.
23
+ */
24
+ export function positiveIntOption(
25
+ value: number | undefined,
26
+ fallback: number,
27
+ path: string,
28
+ ): number {
29
+ if (value === undefined) {
30
+ return fallback;
31
+ }
32
+ if (!Number.isInteger(value) || value < 1) {
33
+ console.warn(`web-terminal-ui: ignoring invalid ${path} ${String(value)}`);
34
+ return fallback;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ /**
40
+ * A positive integer, or `undefined` with a warning naming `path`.
41
+ *
42
+ * The same policy for a knob whose absence is meaningful: `scrollbackLines`
43
+ * omitted means "the engine's choice", which is a different statement from any
44
+ * value this library could substitute, so an invalid one must read as omitted.
45
+ */
46
+ export function optionalPositiveIntOption(
47
+ value: number | undefined,
48
+ path: string,
49
+ ): number | undefined {
50
+ if (value === undefined) {
51
+ return undefined;
52
+ }
53
+ if (!Number.isInteger(value) || value < 1) {
54
+ console.warn(`web-terminal-ui: ignoring invalid ${path} ${String(value)}`);
55
+ return undefined;
56
+ }
57
+ return value;
58
+ }