@cplieger/web-terminal-ui 5.2.3 → 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.
@@ -8,7 +8,13 @@
8
8
  // reads it through a typed token.
9
9
 
10
10
  import type { LoadingMessages } from "./loading-status.js";
11
- import type { ScreenMessage, ModesMessage, LineStore } from "@cplieger/web-terminal-engine";
11
+ import type {
12
+ ScreenMessage,
13
+ ModesMessage,
14
+ LineStore,
15
+ StoreSnapshot,
16
+ ViewMemory,
17
+ } from "@cplieger/web-terminal-engine";
12
18
 
13
19
  /** Cancels a subscription or registration. Idempotent by convention. */
14
20
  export type Unsubscribe = () => void;
@@ -81,12 +87,29 @@ export interface RenderHandle {
81
87
  setPredictedCursor(row: number, col: number, active: boolean): void;
82
88
  getCursorPx(): { left: number; top: number; cellH: number };
83
89
  computeSize(): { cols: number; rows: number };
84
- /** Point the renderer at a store and rebuild from it (tabs, on switch). */
85
- bind(store: LineStore): void;
90
+ /** Point the renderer at a store and rebuild from it (tabs, on switch).
91
+ * `opts.view` is the per-view scroll memory captureViewMemory() returned when
92
+ * this store was last active, and passing it makes the swap ATOMIC: the
93
+ * follow half is adopted synchronously (so the first flush's bottom pin is
94
+ * gated on the INCOMING view, not the outgoing one) and the position half is
95
+ * re-asserted across the rebuild's frames until the row it names is built.
96
+ * Omitting it keeps the pre-3.8 behavior. */
97
+ bind(store: LineStore, opts?: { view?: ViewMemory | null }): void;
98
+ /** Capture the current view as per-view scroll memory: the absolute LINE at
99
+ * the viewport top plus its on-screen offset, not a pixel scrollTop. Null
100
+ * when there is nothing to remember (no content rows, or the alternate
101
+ * screen is active). Pairs with bind's `opts.view`. */
102
+ captureViewMemory(): ViewMemory | null;
86
103
  /** The store the renderer is currently bound to. */
87
104
  boundStore(): LineStore;
88
105
  /** Highest absolute line index the active store holds (-1 if empty). */
89
106
  getHighestIndex(): number;
107
+ /** Rows queued for a DOM (re)build but not yet built: non-zero means the
108
+ * surface is still materializing content the store already holds. Reaches
109
+ * zero between a replay's chunks, so it is a "this frame's backlog is
110
+ * drained" signal, not "the restore finished arriving" — pair it with the
111
+ * resume bounds for the latter. */
112
+ pendingRowCount(): number;
90
113
  }
91
114
 
92
115
  /** The scroll methods features may drive. */
@@ -98,8 +121,25 @@ export interface ScrollHandle {
98
121
  * features go through this seam, never the DOM element (engine >= v3). */
99
122
  currentScrollTop(): number;
100
123
  /** Restore a saved offset; follow/hold re-derives from the resulting scroll
101
- * event exactly as for a user scroll — the write half of scroll memory. */
124
+ * event exactly as for a user scroll — the write half of scroll memory.
125
+ *
126
+ * @deprecated A pixel offset cannot identify a reading position: replayed
127
+ * into a surface whose rows are still building it is silently clamped, and
128
+ * replayed into one whose content grew it points at a different line. Use
129
+ * render.captureViewMemory() + render.bind(store, { view }), which restore a
130
+ * LINE. Removed in the next major. */
102
131
  restoreScrollTop(top: number): void;
132
+ /** Restore an offset AND its follow state together, which is the only way to
133
+ * express "holding at the bottom" (a content shrink clamps a scrolled-up reader
134
+ * there without engaging follow, so position alone cannot say it).
135
+ *
136
+ * NOT the per-view scroll-memory API: this is still a PIXEL offset, with the
137
+ * same defect as `restoreScrollTop` above — a rebuild clamps it and a tab whose
138
+ * content grew while backgrounded lands on a different line. `render.bind(store,
139
+ * { view })` with `render.captureViewMemory()` is what a consumer wants; the
140
+ * engine calls this internally from that path. Declared here because it is part
141
+ * of the engine's scroll surface, not because a consumer should reach for it. */
142
+ restoreView(view: { top: number; following: boolean }): void;
103
143
  }
104
144
 
105
145
  // --- Typed event bus payloads (section 22.4) ---
@@ -237,6 +277,22 @@ export interface TerminalContext {
237
277
  /** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
238
278
  tablist(): TablistController;
239
279
 
280
+ /** Construct a LineStore honoring the terminal's configured retained-line
281
+ * cap (`CreateTerminalOptions.scrollbackLines`; engine default when unset).
282
+ * A feature that keeps per-session stores (tabs' switching cache) creates
283
+ * every store through this factory, so ONE consumer option governs the
284
+ * kernel's implicit store and every per-tab store alike — the two cannot
285
+ * drift.
286
+ *
287
+ * Pass the session id the store belongs to. It is optional only so an
288
+ * existing feature keeps compiling; a feature that owns sessions should
289
+ * always pass it, because it is what lets the kernel return a store HYDRATED
290
+ * from `CreateTerminalOptions.persistScrollback` (and register it for
291
+ * saving). Omitting it yields a correct but always-empty store, so that
292
+ * feature's sessions silently opt out of persistence — the kernel warns once
293
+ * when persistence is enabled and an id is withheld. */
294
+ newLineStore(sessionId?: string): LineStore;
295
+
240
296
  /** The current layout facts a feature keys touch-vs-desktop behavior on.
241
297
  * `narrow` is a ROOT compact in EITHER dimension against the kernel's
242
298
  * breakpoints: skinny (width, a portrait phone or a narrow embedded panel)
@@ -379,6 +435,97 @@ export type TerminalStartupFailure =
379
435
  readonly surface: HTMLElement | undefined;
380
436
  };
381
437
 
438
+ /** One stored scrollback entry: the engine's store snapshot plus the timestamp
439
+ * the library stamps on it.
440
+ *
441
+ * The timestamp is the library's, not the consumer's, because the maximum age
442
+ * is ENFORCED here rather than documented as someone else's job. The case that
443
+ * motivates persisting at all — iOS discarding a backgrounded tab — is exactly
444
+ * the case where no "closed cleanly" path ever runs, so without an age bound
445
+ * the consumer's storage accumulates snapshots for sessions that stopped
446
+ * existing weeks ago. A rejected entry is also dropped through
447
+ * `ScrollbackPersistence.drop`, which makes collection-on-access automatic; a
448
+ * consumer that wants to sweep proactively has `savedAt` to sweep by. */
449
+ export interface PersistedScrollback {
450
+ /** `Date.now()` when the library wrote this entry. */
451
+ readonly savedAt: number;
452
+ /** The engine's plain-data store snapshot (structuredClone-safe). */
453
+ readonly snapshot: StoreSnapshot;
454
+ }
455
+
456
+ /** The consumer-supplied storage seam for scrollback persistence
457
+ * (`CreateTerminalOptions.persistScrollback`).
458
+ *
459
+ * Storage is the CONSUMER's, deliberately, for two reasons. Chrome's
460
+ * page-lifecycle guidance is to close IndexedDB connections on freeze because a
461
+ * held connection affects bfcache eligibility, and this library depends on
462
+ * bfcache (it reconnects and re-measures on `pageshow`) — so the library must
463
+ * not own a database. And terminal scrollback contains secrets while browser
464
+ * storage has no meaningful at-rest protection, which makes "where does this
465
+ * live, and for how long" a decision for the application, not its terminal
466
+ * widget.
467
+ *
468
+ * `sessionId` is a real session id, never a key of the consumer's invention:
469
+ * the same id the tabs feature uses, or — for a single unmanaged terminal — the
470
+ * engine's per-tab id from `connection.currentSessionId()`. That is load-bearing
471
+ * rather than tidy, because the id is also what the persisted server epoch is
472
+ * adopted against. A consumer may of course prefix it inside its own storage.
473
+ *
474
+ * `load` is SYNCHRONOUS, which is a real constraint and worth understanding
475
+ * before fighting it: hydration has to complete before the resume goes out, and
476
+ * a resume is not restartable — a store hydrated after `haveThrough` was sent
477
+ * has already lost the argument. A consumer whose storage is asynchronous
478
+ * (IndexedDB) reads its snapshots into memory before calling `createTerminal`
479
+ * and answers from there, which is also the shape that holds no database
480
+ * connection open.
481
+ *
482
+ * Every callback may throw or return nonsense without consequence: the library
483
+ * treats any failure as "nothing was restored" and takes a full resume, exactly
484
+ * as if persistence were switched off. */
485
+ export interface ScrollbackPersistence {
486
+ /** Return the stored entry for a session, or null/undefined when there is
487
+ * none. Called once per session, before that session connects. */
488
+ load(sessionId: string): PersistedScrollback | null | undefined;
489
+ /** Store an entry, replacing any previous one for the session.
490
+ *
491
+ * THROW when nothing was persisted (over quota, storage revoked, an entry the
492
+ * store refuses). Returning normally is taken as "this is on disk" and records
493
+ * a watermark against it; a failure reported that way made the library skip the
494
+ * session until its output advanced again, which is how persistence stopped
495
+ * silently. A throw is caught, warned once, and retried on the next pass. */
496
+ save(sessionId: string, entry: PersistedScrollback): void;
497
+ /** Delete a session's entry. Called when a tab is closed, and when the
498
+ * library rejects a stored entry (too old, unusable, or unverifiable), so a
499
+ * bad or expired entry is not re-read on every load. */
500
+ drop(sessionId: string): void;
501
+ /** Newest lines to persist per session (default 200).
502
+ *
503
+ * A bound rather than the whole store because the cost is a repeated
504
+ * serialize-and-store on a device that may already be under memory pressure,
505
+ * and because the screen plus recent history is what a returning user needs.
506
+ * The default follows the nearest precedent — VS Code's
507
+ * `terminal.integrated.persistentSessionScrollback` restores 100 lines — with
508
+ * headroom, and against measurement: 200 lines is ~60 K characters of a
509
+ * coloured session and serialises in under a millisecond, where 1000 is ~300 K
510
+ * and ~4 ms, paid on every backgrounding and every timer tick while output
511
+ * advances. The depth NOT kept is reported honestly after hydration: the store
512
+ * shows its "earlier output trimmed" marker rather than implying the buffer is
513
+ * complete. Non-integer or non-positive values are ignored. */
514
+ lines?: number;
515
+ /** Maximum age of a stored entry (default 7 days). An entry outside this is
516
+ * neither loaded nor kept. Distance in EITHER direction counts, so a phone
517
+ * whose clock moved cannot leave an entry that never expires. */
518
+ maxAgeMs?: number;
519
+ /** Interval for the background save while content is changing (default 10s).
520
+ *
521
+ * `visibilitychange` to hidden and `pagehide` are the last reliable callbacks
522
+ * before a discard and the library writes on both, but Chrome's page-lifecycle
523
+ * documentation is explicit that `pagehide` is not guaranteed. The timer is
524
+ * what makes a killed tab have a recent-enough snapshot rather than none. It
525
+ * skips sessions whose content has not advanced since their last save. */
526
+ saveIntervalMs?: number;
527
+ }
528
+
382
529
  /** Options for createTerminal. */
383
530
  export interface CreateTerminalOptions {
384
531
  /** The feature list, as a FUNCTION that produces it; omitted means the bare
@@ -406,6 +553,64 @@ export interface CreateTerminalOptions {
406
553
  wsPath?: string;
407
554
  /** CSS font shorthand awaited before the first resize. */
408
555
  fontReady?: string;
556
+ /** Retained scrollback lines per terminal (and per tab under the tabs
557
+ * feature) — the client-side line cap; the engine's default is 5000.
558
+ *
559
+ * This is the page's dominant memory dial: the cap bounds both the styled
560
+ * run arrays each store retains AND the DOM rows the renderer keeps (one
561
+ * `div.term-row` per retained line, no offscreen virtualisation), which in
562
+ * turn bounds the height of the scrolled-contents layer the browser
563
+ * composites. It is a HISTORY budget floored at the live screen: the
564
+ * engine never evicts the current window's rows, so a value at or below
565
+ * the terminal height keeps the full screen and simply retains no
566
+ * scrollback. Choose a value comfortably above the largest expected
567
+ * terminal height (a few multiples of it): near or below the height,
568
+ * batched eviction has no history headroom to work with and degrades
569
+ * back to per-line eviction — the screen stays correct, but the churn
570
+ * the batching exists to remove returns. A memory-constrained consumer (iOS Safari, where the content
571
+ * process is a jetsam victim under system pressure) passes a smaller
572
+ * budget; scrolling back then reaches fewer lines, so this is a consumer
573
+ * policy choice, not a tuning knob the library second-guesses. Applies to
574
+ * the kernel's implicit store (via the engine renderer) and to every store
575
+ * created through `ctx.newLineStore()` (the tabs switching cache).
576
+ * Non-integer or non-positive values are ignored. */
577
+ scrollbackLines?: number;
578
+ /** Persist each session's scrollback across a page discard, through storage
579
+ * the CONSUMER supplies. Omitted means off, which is the default on purpose.
580
+ *
581
+ * What it fixes: a page that is discarded and reloaded otherwise resumes
582
+ * holding nothing, so it asks the server for everything and refills its whole
583
+ * buffer over the wire. On iOS that is the normal case rather than an edge
584
+ * case — Safari evicts backgrounded tabs under memory pressure and returning
585
+ * to one re-runs the page — and the refill is both slow and visible. With a
586
+ * snapshot restored, the resume asks only for what was printed while the tab
587
+ * was gone.
588
+ *
589
+ * Scope it honestly: this helps the FRESH-LOAD case only. A warm reconnect and
590
+ * an in-page tab switch already replay nothing, because their stores never
591
+ * went away.
592
+ *
593
+ * Off by default, and the default is the LIBRARY's rather than a
594
+ * recommendation. `localStorage` is a shared, origin-wide, quota-limited
595
+ * resource, and this package is embedded in host applications that keep their
596
+ * own state there; consuming it uninvited would risk the embedder's writes
597
+ * failing, whose degradation is theirs to design and not ours to assume. Our
598
+ * own failure under quota pressure is graceful (nothing restored, terminal
599
+ * unaffected). An application decides durability for its own users; a library
600
+ * does not decide it for an embedder who never asked. All three reference apps
601
+ * DO enable it — see `localScrollbackStorage`, which is the ready-made answer.
602
+ *
603
+ * Note for a consumer weighing it: enabling this writes terminal output where
604
+ * it is readable from that browser without reaching the server and outlives the
605
+ * tab (bounded by a per-session delete on close and a seven-day expiry). No
606
+ * permission prompt is involved — `localStorage` needs none — and a browser
607
+ * that blocks site data simply restores nothing.
608
+ *
609
+ * It applies to the whole terminal, not one composition: the kernel's implicit
610
+ * store (a single unmanaged terminal) and every store a session-owning feature
611
+ * creates through `ctx.newLineStore(sessionId)` (the tabs cache) are both
612
+ * hydrated and both saved. */
613
+ persistScrollback?: ScrollbackPersistence;
409
614
  /** Optional pre-JS loading overlay the kernel fades out on first paint. Give
410
615
  * it the `wt-loading` class and a `wt-loading-bar` child and css/page.css
411
616
  * styles it; the kernel also writes a progressive status line into it while
package/src/viewport.ts CHANGED
@@ -10,7 +10,10 @@
10
10
  // 2. While in transition: autoscroll is suppressed, input dimensions
11
11
  // may shift, no resize is sent to the server.
12
12
  // 3. After SETTLE_MS of no further events, fire onSettled — the caller
13
- // then sends the final size, snaps to bottom if was-at-bottom, etc.
13
+ // then sends the final size, pins to the bottom if still following, etc.
14
+ // The follow state is read AT SETTLE (see startTransition): a latch taken
15
+ // at the start of the burst goes stale, because every further event
16
+ // extends the window without re-reading it.
14
17
 
15
18
  import { scroll } from "@cplieger/web-terminal-engine";
16
19
 
@@ -27,7 +30,6 @@ let termWrap: HTMLElement;
27
30
  let varTarget: HTMLElement;
28
31
  let onSettled: ((wasAtBottom: boolean) => void) | null = null;
29
32
  let inTransition = false;
30
- let wasAtBottomAtStart = true;
31
33
  let settleTimer: ReturnType<typeof setTimeout> | null = null;
32
34
  // When true, ignore the visualViewport keyboard geometry entirely (a hardware
33
35
  // keyboard/trackpad is present, so the soft keyboard never opens). Set at init
@@ -40,28 +42,43 @@ let suppressKeyboardInset: () => boolean = () => false;
40
42
  let cleanup: (() => void)[] = [];
41
43
 
42
44
  function startTransition(): void {
43
- // Capture pre-transition scroll state at the *start* of the transition,
44
- // not on every event in the burst.
45
- if (!inTransition) {
46
- inTransition = true;
47
- wasAtBottomAtStart = !scroll.isUserScrolledUp();
48
- }
49
-
45
+ inTransition = true;
50
46
  if (settleTimer !== null) {
51
47
  clearTimeout(settleTimer);
52
48
  }
53
49
  settleTimer = setTimeout(() => {
54
50
  settleTimer = null;
55
51
  inTransition = false;
56
- if (wasAtBottomAtStart) {
57
- scroll.scrollToBottom();
52
+ // Read the follow state AT SETTLE, not at the start of the burst. Every
53
+ // viewport event re-arms this timer without re-reading, and on iOS a wake or
54
+ // keyboard slide emits a stream of them, so a latch captured at the start
55
+ // stays true across a user scrolling up mid-burst and then yanks them back
56
+ // down. Asking now is only safe because a content shrink can no longer
57
+ // corrupt the follow state on its own (the engine's scroll controller
58
+ // re-engages follow only on a downward user move); before that, this snap
59
+ // was accidentally repairing the corruption it was also causing.
60
+ const stillFollowing = !scroll.isUserScrolledUp();
61
+ if (stillFollowing) {
62
+ // Pin, do not force. A geometry change can leave scrollTop stale short of
63
+ // the bottom, which the pin fixes; scrollToBottom would additionally
64
+ // OVERRIDE the follow state, which is not this handler's call to make.
65
+ scroll.stickToBottom();
58
66
  }
59
67
  if (onSettled) {
60
- onSettled(wasAtBottomAtStart);
68
+ onSettled(stillFollowing);
61
69
  }
62
70
  }, SETTLE_MS);
63
71
  }
64
72
 
73
+ /**
74
+ * Whether a viewport transition is in flight (an iOS keyboard slide, a rotation,
75
+ * a font-load reflow). Geometry measured now is provisional, so a caller that
76
+ * puts a measurement on the wire should wait for the settle instead.
77
+ */
78
+ export function isInTransition(): boolean {
79
+ return inTransition;
80
+ }
81
+
65
82
  export function init(opts: {
66
83
  termWrap: HTMLElement;
67
84
  /** The terminal root (.wt-root): receives the geometry CSS vars the sibling