@cplieger/web-terminal-engine 3.5.0 → 3.7.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/src/render.ts CHANGED
@@ -16,9 +16,10 @@
16
16
  // when real history commits — never oscillating mid-redraw.
17
17
 
18
18
  import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
19
- import { LineStore } from "./store.js";
19
+ import { LineStore, PAGE_SIZE, PREFETCH_THRESHOLD } from "./store.js";
20
20
  import * as scroll from "./scroll.js";
21
21
  import { isReverseVideo } from "./modes.js";
22
+ import { interiorGaps } from "./intervals.js";
22
23
 
23
24
  // --- Width cache (two-tier, xterm.js style) ---
24
25
  const WIDTH_FLAT_SIZE = 256;
@@ -29,6 +30,12 @@ const widthMap = new Map<string, number>();
29
30
  const VARIANT_REGULAR = 0;
30
31
  const VARIANT_BOLD = 1;
31
32
  const VARIANT_ITALIC = 2;
33
+ // widthMap holds one entry per unique (bold, italic, glyph) measured — bounded
34
+ // by the rendered repertoire in practice, but a long CJK/emoji-heavy session
35
+ // can accumulate tens of thousands of keys that survive eviction, reset, and
36
+ // tab close (only a font change clears it). Cap it: a clear on overflow costs
37
+ // an occasional re-measure and changes no rendered output.
38
+ const WIDTH_MAP_MAX = 20_000;
32
39
  const variantCtx: (CanvasRenderingContext2D | null)[] = [null, null, null, null];
33
40
  let fontString = "";
34
41
 
@@ -88,6 +95,9 @@ function measureChar(ch: string, bold: boolean, italic: boolean): number {
88
95
  variant |= VARIANT_ITALIC;
89
96
  }
90
97
  const w = variantContext(variant).measureText(ch).width;
98
+ if (widthMap.size >= WIDTH_MAP_MAX) {
99
+ widthMap.clear();
100
+ }
91
101
  widthMap.set(key, w);
92
102
  return w;
93
103
  }
@@ -124,6 +134,25 @@ const rowEls = new Map<number, HTMLDivElement>();
124
134
  // as a module ref so it is reused rather than recreated each flush.
125
135
  let trimMarkerEl: HTMLDivElement | null = null;
126
136
 
137
+ /**
138
+ * Per-gap "earlier output not loaded" markers, keyed by the gap's LOW index.
139
+ *
140
+ * Distinct from the singleton trim marker above in two ways that matter. Each
141
+ * one carries its gap's low index as `data-abs`, so `insertRowInOrder` and the
142
+ * read-anchor binary searches keep their monotonic-`data-abs` invariant with
143
+ * the marker in place — the trim marker can be a `data-abs`-less first child
144
+ * precisely because it only ever sits at the very top, and an INTERIOR marker
145
+ * cannot. And a marker here is a PROJECTION of the store's gap geometry: it is
146
+ * re-derived (value and position) whenever either edge of its gap moves, so a
147
+ * gap healing from its low edge carries its marker up with it, and it is
148
+ * removed when the gap closes (docs/paged-scrollback.md §5.4).
149
+ */
150
+ const gapMarkerEls = new Map<number, HTMLDivElement>();
151
+
152
+ /** Transport seams for demand paging, wired by the consumer at init. */
153
+ let requestHistoryFn: ((fromAbs: number, maxLines: number) => boolean) | null = null;
154
+ let historyBudgetFn: (() => number) | null = null;
155
+
127
156
  // Rows awaiting a DOM (re)build, processed in budgeted batches across frames.
128
157
  // A session restore (kiro-cli's /chat), a post-resize transcript reprint, or a
129
158
  // `cat bigfile` dumps thousands of lines in one wire frame; building them all
@@ -205,6 +234,23 @@ function termPadding(): { padL: number; padT: number } {
205
234
  let flushDrainedThisPass = 0;
206
235
  let renderNoProgressStreak = 0;
207
236
  const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
237
+ // Whether the CURRENT flush performed a full reset (server restart / epoch
238
+ // change). Read by restoreReadAnchor after the flush: a re-anchor across a
239
+ // reset would match unrelated content from the new index space (R2 review).
240
+ let fullResetThisPass = false;
241
+ // The ED3 base observed since the last flush, or -1. Set by handleScreen from
242
+ // the frame itself; consumed (and reset) by the pass that applies it. Renderer-
243
+ // local on purpose: the renderer is the caller of every path that discards a
244
+ // REGION rather than trimming the cap, so nothing in the store's change set has
245
+ // to grow a field for it (docs/scroll-position-fidelity.md §5).
246
+ let discardedBelowPending = -1;
247
+ let discardedBelowThisPass = -1;
248
+ // Whether THIS pass removed rows from the DOM. Only a pass that did can have
249
+ // caused a clamp, and announcing one that did not is unsound on Safari, which
250
+ // updates scrollTop PAST the maximum during an overscroll bounce: the settle back
251
+ // from a rubber-band is a downward move in value with no content change at all,
252
+ // and arming for it would hand the user's own gesture a pass-through.
253
+ let removedRowsThisPass = false;
208
254
 
209
255
  /**
210
256
  * Initialize the renderer by attaching it to a pair of DOM elements: the
@@ -214,23 +260,67 @@ const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
214
260
  * @param opts.output Inner element that holds row children.
215
261
  * @param opts.termWrap Outer scroll container.
216
262
  * @param opts.onCursorMove Optional callback invoked when the cursor moves.
263
+ * @param opts.maxLines Optional retained-line cap for the implicit store
264
+ * (default 5000) — a HISTORY budget floored at the
265
+ * live screen (the store never evicts the current
266
+ * window, so a cap at or below the terminal height
267
+ * keeps the full screen with no scrollback). Governs
268
+ * how many absolute-index lines — and therefore DOM
269
+ * rows — the client keeps; a memory-constrained
270
+ * consumer (a phone) passes a smaller budget. A
271
+ * consumer that manages stores itself (a tabbed
272
+ * shell) constructs each `LineStore` with its own cap
273
+ * and `bind()`s it AFTER init; init always installs a
274
+ * fresh implicit store, so call it before any bind.
275
+ * For the batched eviction to help, choose a cap
276
+ * comfortably above the terminal height (near or
277
+ * below it, eviction degrades to per-line — the
278
+ * screen stays correct, but the churn returns).
279
+ * Non-positive or non-integer values warn and are
280
+ * ignored (the default applies).
217
281
  */
218
282
  export function init(opts: {
219
283
  output: HTMLElement;
220
284
  termWrap: HTMLElement;
221
285
  onCursorMove?: () => void;
286
+ maxLines?: number;
287
+ /** Ask the transport for a page of history. Wired by the consumer to
288
+ * `connection.requestHistory`; absent means paging is not available and the
289
+ * controller stays dormant (docs/paged-scrollback.md §5.4). */
290
+ requestHistory?: (fromAbs: number, maxLines: number) => boolean;
291
+ /** The transport's current adaptive request budget
292
+ * (`connection.historyBudget`). Read at FIRE time, and used for both the
293
+ * request's length and its anchor: a shrunken length with a full-size anchor
294
+ * serves a range that ends far from the reader. */
295
+ historyBudget?: () => number;
222
296
  }): void {
223
297
  output = opts.output;
224
298
  termWrap = opts.termWrap;
225
299
  onCursorMove = opts.onCursorMove ?? null;
300
+ requestHistoryFn = opts.requestHistory ?? null;
301
+ historyBudgetFn = opts.historyBudget ?? null;
226
302
  // New attach target: the cached padding belongs to the previous termWrap.
227
303
  padValid = false;
228
- // Fresh attach: drop any prior model + DOM so re-init (and vitest's
229
- // non-isolated module reuse) starts clean.
230
- store.reset();
304
+ // Fresh attach: ALWAYS install a fresh implicit store, so no cap — a
305
+ // consumer-configured one, or a bound per-tab store's — leaks from a
306
+ // previous attachment into this one (init is the attachment boundary;
307
+ // vitest's non-isolated module reuse relies on the same reset).
308
+ const validCap =
309
+ opts.maxLines !== undefined && Number.isInteger(opts.maxLines) && opts.maxLines > 0;
310
+ if (opts.maxLines !== undefined && !validCap) {
311
+ console.warn(`vterm: ignoring invalid maxLines ${String(opts.maxLines)}`);
312
+ }
313
+ // An absent OR invalid cap is forwarded as absent, which is how the store
314
+ // spells "engine's choice"; a value here would be indistinguishable from a
315
+ // consumer decision and would pin the tail at the compatibility cap forever.
316
+ store = new LineStore(validCap ? opts.maxLines : undefined);
231
317
  rowEls.clear();
232
318
  renderQueue.clear();
233
319
  trimMarkerEl = null;
320
+ for (const el of gapMarkerEls.values()) {
321
+ el.remove();
322
+ }
323
+ gapMarkerEls.clear();
234
324
  // Drop the predicted-cursor + caret overlays (re-created lazily against the
235
325
  // new termWrap) so re-init starts clean.
236
326
  if (predCursorEl) {
@@ -249,6 +339,14 @@ export function init(opts: {
249
339
  }
250
340
  flushDrainedThisPass = 0;
251
341
  renderNoProgressStreak = 0;
342
+ // Attachment boundary: a view restore armed against the PREVIOUS surface must
343
+ // not survive it, or its timer outlives teardown and writes scrollTop on a
344
+ // detached element. Same discipline scroll.init applies to its own arms.
345
+ bindGen++;
346
+ clearPendingRestore();
347
+ discardedBelowPending = -1;
348
+ discardedBelowThisPass = -1;
349
+ lastInboundMs = 0;
252
350
  syncCursorBlink();
253
351
  }
254
352
 
@@ -277,10 +375,68 @@ export function resetScrollback(): void {
277
375
  * active tab's cached LineStore (design sections 5, 6, 8). The DOM is wiped and
278
376
  * repainted viewport-first from the new store; this is local, so the last-known
279
377
  * screen paints without a network round-trip.
378
+ *
379
+ * `opts.view` is the per-view scroll memory `captureViewMemory()` returned when
380
+ * the consumer last left this store, and passing it makes the swap ATOMIC. Both
381
+ * halves matter and they land at different times on purpose:
382
+ *
383
+ * - the FOLLOW half is adopted SYNCHRONOUSLY, before the wipe, so the very
384
+ * first flush's bottom pin is gated on the INCOMING view's state. The
385
+ * follow flag is global (one per kernel), so without this the first frame
386
+ * after a switch is gated on the state of the tab the user just LEFT —
387
+ * binding a following tab while holding in another rendered the cached
388
+ * screen above the viewport and left a black gap until a touch re-engaged
389
+ * follow. It is applied through restoreView at the CURRENT offset, which
390
+ * sets the flag without moving the position.
391
+ * - the POSITION half is armed and re-asserted across the rebuild's frames
392
+ * (see applyPendingRestore), because the row it names is usually not built
393
+ * yet. A view left FOLLOWING arms nothing: the per-flush bottom pin already
394
+ * is the correct answer, which keeps the common path free of any restore.
280
395
  */
281
- export function bind(next: LineStore): void {
396
+ export function bind(next: LineStore, opts?: { view?: ViewMemory | null }): void {
397
+ const view = opts?.view ?? null;
398
+ // Passing `opts` at all is a statement that this caller owns the view. A NULL
399
+ // view is therefore not "no opinion" but "no memory" — a tab never visited, or
400
+ // one left on the alternate screen, where there is no absolute index worth
401
+ // remembering — and the right follow state for no memory is the tail. Without
402
+ // this, those tabs inherited the OUTGOING tab's follow flag, which is the
403
+ // stale-global-flag bug this seam exists to close, just narrowed to the tabs
404
+ // that have nothing saved. A caller that passes no `opts` keeps the pre-3.7
405
+ // behavior and its follow state is left alone.
406
+ if (opts !== undefined) {
407
+ scroll.restoreView({
408
+ top: scroll.currentScrollTop(),
409
+ following: view === null ? true : view.following,
410
+ });
411
+ }
412
+ // The OUTGOING store cannot have a request in flight: the socket that issued it
413
+ // is being switched away with it. Closing its solicited window here is what
414
+ // stops it stranding open, because the transport's own `clearSolicited` lands on
415
+ // whichever store is bound WHEN IT FIRES — and the order of `bind` against the
416
+ // consumer's `setSession`/reconnect teardown is the consumer's choice, which
417
+ // neither module constrains. Bound after the switch, that clear closes the
418
+ // INCOMING store's (empty) window and leaves the outgoing one permanently open:
419
+ // a standing exemption from apply-line guard 2 for that index range, with no
420
+ // socket and no timer left to close it, so any later frame in that range can
421
+ // resurrect an evicted row and be classified as browse cache. The sibling
422
+ // pendingRestore arm already had a generation guard for the same hazard; this
423
+ // one had none.
424
+ store.clearSolicited();
282
425
  store = next;
426
+ // A new bind invalidates any restore armed for the previous one: cancel, then
427
+ // arm, one slot, so a second switch mid-drain cannot land the first tab's
428
+ // anchor into this store.
429
+ bindGen++;
430
+ clearPendingRestore();
283
431
  rebuild();
432
+ if (view !== null && !view.following) {
433
+ pendingRestore = {
434
+ view,
435
+ gen: bindGen,
436
+ lastWrote: scroll.currentScrollTop(),
437
+ deadline: Date.now() + RESTORE_MAX_MS,
438
+ };
439
+ }
284
440
  }
285
441
 
286
442
  /**
@@ -331,10 +487,19 @@ function queueRowsViewportFirst(): void {
331
487
  * force a full repaint of the current store.
332
488
  */
333
489
  export function rebuild(): void {
490
+ // The wipe is the largest content shrink this module performs, and it happens
491
+ // OUTSIDE a flush (bind calls it synchronously), so the flush's own
492
+ // announcement cannot cover it. Unannounced, the clamp it causes falls back to
493
+ // the epsilon — exactly the inference §4 exists to stop relying on.
494
+ const scrollTopBeforeWipe = scroll.currentScrollTop();
334
495
  output.replaceChildren();
335
496
  rowEls.clear();
336
497
  renderQueue.clear();
337
498
  trimMarkerEl = null;
499
+ for (const el of gapMarkerEls.values()) {
500
+ el.remove();
501
+ }
502
+ gapMarkerEls.clear();
338
503
  cursorAbs = -1;
339
504
  altRendered = false;
340
505
  altPrevRows = [];
@@ -346,6 +511,7 @@ export function rebuild(): void {
346
511
  if (!store.isAlt()) {
347
512
  queueRowsViewportFirst();
348
513
  }
514
+ scroll.noteContentShrink(scrollTopBeforeWipe);
349
515
  scheduleFlush();
350
516
  }
351
517
 
@@ -370,6 +536,118 @@ export function noteResumeBounds(committed: number, oldest: number): void {
370
536
  scheduleFlush();
371
537
  }
372
538
 
539
+ // --- demand-paged scrollback: the consumer's seams (docs/paged-scrollback.md) ---
540
+
541
+ /**
542
+ * Apply a correlated history page and, when the reply proved history is gone,
543
+ * raise the paging floor so nothing below it is requested again.
544
+ *
545
+ * Wired to `connection`'s `onHistoryReply`. The viewport index is supplied here
546
+ * rather than by the caller because the renderer is the only layer that knows
547
+ * it, and the store's eviction needs it to decide which end of the cache is
548
+ * safe to drop.
549
+ */
550
+ export function handleHistoryReply(msg: ScrollMessage, raiseFloorTo: number | null): void {
551
+ if (raiseFloorTo !== null) {
552
+ store.raisePagingFloor(raiseFloorTo);
553
+ }
554
+ store.applyHistoryScroll(msg, viewportAbs());
555
+ scheduleFlush();
556
+ }
557
+
558
+ /**
559
+ * Run the store's single resume-ack transition, supplying the viewport the
560
+ * store cannot see. Wired to `connection`'s `onResumeTransition`.
561
+ */
562
+ export function applyResumeTransition(ack: {
563
+ epochChanged: boolean;
564
+ committed: number | null;
565
+ serverOldest: number | null;
566
+ paging: boolean;
567
+ sentHaveThrough: number;
568
+ sentReplayMax: number | null;
569
+ }): void {
570
+ // A tab switch RECONNECTS (connection.setSession -> reconnectNow), so this
571
+ // transition routinely runs while the surface is mid-rebuild — at a clamped
572
+ // offset, over rows that are still being built. The live measurement there is
573
+ // a transient that belongs to neither the outgoing nor the incoming view, and
574
+ // the store's reclassify pass uses it to decide which rows survive: reading
575
+ // the transient can evict the very rows an armed restore is about to bring
576
+ // back. An armed restore names the position the user is REGAINING, so it is
577
+ // the answer to "which rows must survive" (docs/scroll-position-fidelity.md
578
+ // §7.2, §7.3). Every other viewportAbs consumer asks about NOW and keeps the
579
+ // live value.
580
+ const pending = pendingRestoreAbs();
581
+ // The store no longer infers whether the reader is following; this layer knows.
582
+ // `isUserScrolledUp()` is the one authoritative source, and an ARMED RESTORE
583
+ // overrides it outright: mid-rebuild the browser has clamped the offset, so the
584
+ // live flag describes the transient rather than the view the reader is about to
585
+ // regain — and a restore is armed only for a position in history.
586
+ const pendingRestoreArmed = pending !== null;
587
+ store.applyResumeAck({
588
+ ...ack,
589
+ viewportAbs: pending ?? viewportAbs(),
590
+ following: !pendingRestoreArmed && !scroll.isUserScrolledUp(),
591
+ });
592
+ scheduleFlush();
593
+ }
594
+
595
+ /** Record the window of a request going out (the store port's half). */
596
+ export function noteSolicited(fromAbs: number, end: number): void {
597
+ store.noteSolicited(fromAbs, end);
598
+ }
599
+
600
+ /** Release the in-flight window (reply applied, timed out, socket gone). */
601
+ export function clearSolicited(): void {
602
+ store.clearSolicited();
603
+ }
604
+
605
+ /**
606
+ * Drop the browse cache, supplying the viewport the skip rule needs. The
607
+ * consumer owns the TTL and the visibility state; the engine has no notion of
608
+ * tabs or page visibility.
609
+ */
610
+ export function dropBrowseCache(pageVisible: boolean): void {
611
+ store.dropBrowseCache(viewportAbs(), pageVisible);
612
+ scheduleFlush();
613
+ }
614
+
615
+ /** When the browse cache was last created or refreshed, for a consumer's TTL. */
616
+ export function lastBrowseActivityMs(): number {
617
+ return store.lastBrowseActivityMs();
618
+ }
619
+
620
+ /** Lines currently held as disposable browse cache (diagnostics, tests). */
621
+ export function browseCacheSize(): number {
622
+ return store.browseCacheSize();
623
+ }
624
+
625
+ /**
626
+ * The resume replay bound to send: the client's own residency minus the window
627
+ * it is about to be sent anyway, so an attach does not download rows the cap
628
+ * would immediately trim. Wired to `connection`'s `getReplayMax`.
629
+ */
630
+ export function replayMaxForResume(): number {
631
+ return Math.max(1, store.tailCap() - store.getWindow().height);
632
+ }
633
+
634
+ /**
635
+ * How many rows are queued for a DOM (re)build but not yet built. Non-zero
636
+ * means the surface is still materializing content the store already holds: a
637
+ * resume replay, a wipe-and-rebuild after a store swap, or any burst larger
638
+ * than one frame's build budget.
639
+ *
640
+ * Exposed so a consumer can surface a "still catching up" affordance during a
641
+ * large restore. It is a RENDER-side measure, not a transport one: it reaches
642
+ * zero between the server's replay chunks, so on its own it is not a
643
+ * restore-complete signal. Pair it with the resumeAck bounds (the store's
644
+ * highestIndex reaching the reported `committed`) to tell "this frame's backlog
645
+ * is drained" from "the restore has finished arriving".
646
+ */
647
+ export function pendingRowCount(): number {
648
+ return renderQueue.size;
649
+ }
650
+
373
651
  // --- Color helpers ---
374
652
  function colorHex(c: number | undefined): string | null {
375
653
  if (c === undefined || c < 0) {
@@ -649,6 +927,17 @@ function buildRowSpans(runs: readonly WireRun[]): (HTMLSpanElement | HTMLAnchorE
649
927
  * index, so no client-side frame coalescing is needed.
650
928
  */
651
929
  export function handleScreen(msg: ScreenMessage): void {
930
+ lastInboundMs = Date.now();
931
+ if (msg.scrollbackCleared) {
932
+ // ED3: the application discarded its saved lines, so everything the store
933
+ // holds below this base is about to go. Recorded HERE, at the one place that
934
+ // sees the frame before the store consumes it, so restoreReadAnchor can tell
935
+ // a region DISCARD from an ordinary cap trim — the two need opposite
936
+ // recoveries and only the frame knows which happened
937
+ // (docs/scroll-position-fidelity.md §5). Max, because several frames can
938
+ // land between flushes.
939
+ discardedBelowPending = Math.max(discardedBelowPending, msg.base);
940
+ }
652
941
  store.applyScreen(msg);
653
942
  scheduleFlush();
654
943
  }
@@ -658,6 +947,7 @@ export function handleScreen(msg: ScreenMessage): void {
658
947
  * index, then schedule a render flush.
659
948
  */
660
949
  export function handleScroll(msg: ScrollMessage): void {
950
+ lastInboundMs = Date.now();
661
951
  store.applyScroll(msg);
662
952
  scheduleFlush();
663
953
  }
@@ -688,16 +978,42 @@ function scheduleFlush(): void {
688
978
  // all, because the bottom pin owns its position.
689
979
  interface ReadAnchor {
690
980
  el: HTMLElement;
981
+ /** The anchor row's absolute index, so an eviction that removes the element
982
+ * itself (batched cap eviction frees up to a whole batch at once) can
983
+ * re-resolve to the nearest surviving row instead of giving up. */
984
+ abs: number;
691
985
  /** Where the row sat ON SCREEN: offsetTop minus the scroll offset. Measuring
692
986
  * the screen position rather than the container offset is what makes the
693
987
  * correction idempotent — see restoreReadAnchor. */
694
988
  screenTop: number;
695
989
  }
696
990
 
697
- function captureReadAnchor(): ReadAnchor | null {
698
- if (!scroll.isUserScrolledUp()) {
699
- return null; // following: stickToBottom owns the position
700
- }
991
+ // rowAtViewportTop returns the first CONTENT row at or below the viewport top —
992
+ // the ONE row-selection primitive every "where is the reader" question resolves
993
+ // through: the read anchor, the paging trigger's viewportAbs, and the per-view
994
+ // memory a tabbed consumer saves. One definition because two would drift, and
995
+ // they would drift exactly during a rebuild, which is when the answer matters
996
+ // most (docs/scroll-position-fidelity.md §7.2).
997
+ //
998
+ // Children are in document order with monotonic offsetTop, so this is a binary
999
+ // search: ~13 reads for a full 5000-row buffer. Non-content children are NEVER a
1000
+ // reading position and are skipped: a marker is an annotation ABOUT a hole, so
1001
+ // holding one in place would pin the annotation rather than the text the reader
1002
+ // is looking at.
1003
+ //
1004
+ // "Content row" is decided by IDENTITY in rowEls, not by the presence of a
1005
+ // data-abs attribute. A gap marker carries one — updateGapMarkers sets it to the
1006
+ // gap's LOW index so insertRowInOrder keeps the container monotonic — so an
1007
+ // attribute test skips only the trim marker and happily returns a gap marker as
1008
+ // a reading position. Its index is the first ABSENT line, which rowEls can never
1009
+ // resolve, so a restore anchored there could never land and a viewportAbs built
1010
+ // on it would name a line the store does not hold.
1011
+ function isContentRow(el: HTMLElement): boolean {
1012
+ const abs = rowAbs(el);
1013
+ return abs >= 0 && rowEls.get(abs) === el;
1014
+ }
1015
+
1016
+ function rowAtViewportTop(): HTMLElement | null {
701
1017
  const kids = output.children;
702
1018
  if (kids.length === 0) {
703
1019
  return null;
@@ -716,15 +1032,123 @@ function captureReadAnchor(): ReadAnchor | null {
716
1032
  lo = mid + 1;
717
1033
  }
718
1034
  }
719
- // Everything is above the viewport top (a shrink already clamped past the
720
- // last row): anchor on the last row rather than giving up.
721
- const el = found ?? (kids[kids.length - 1] as HTMLElement);
722
- return { el, screenTop: el.offsetTop - offset };
1035
+ // Walk past any markers the search landed on. The bound is O(gaps + 1), not 1:
1036
+ // two gap markers CAN be adjacent siblings, and can be the last two children —
1037
+ // measured once predictReplayJump retires the window, since emptyWindow()'s
1038
+ // base of 0 makes the drain ascending. Cheap either way (a handful of gaps at
1039
+ // most), but do not assume a single step.
1040
+ while (found !== null && !isContentRow(found)) {
1041
+ found = found.nextElementSibling as HTMLElement | null;
1042
+ }
1043
+ return found;
1044
+ }
1045
+
1046
+ function captureReadAnchor(): ReadAnchor | null {
1047
+ if (!scroll.isUserScrolledUp()) {
1048
+ return null; // following: stickToBottom owns the position
1049
+ }
1050
+ const el = rowAtViewportTop();
1051
+ if (el === null) {
1052
+ // Nothing at or below the viewport top. Reaching this with a container that
1053
+ // CLAMPS scrollTop needs the viewport to be shorter than a single row, so it
1054
+ // is effectively unreachable in a browser; the previous last-row fallback
1055
+ // was reachable mainly under a test fixture whose scrollTop setter did not
1056
+ // clamp. Standing down is the right answer either way: using the TAIL as a
1057
+ // proxy reading position is what turned a large content shrink into a
1058
+ // tail-drag (docs/scroll-position-fidelity.md §1.2).
1059
+ return null;
1060
+ }
1061
+ return { el, abs: rowAbs(el), screenTop: el.offsetTop - scroll.currentScrollTop() };
1062
+ }
1063
+
1064
+ // firstRowAtOrAfter returns the first output child whose absolute index is
1065
+ // >= abs — the nearest surviving CONTENT at or below a lost reading position.
1066
+ // Binary search over the children, mirroring rowAtViewportTop: they are kept in
1067
+ // ascending data-abs order.
1068
+ //
1069
+ // Markers are skipped by identity (isContentRow), not by reading -1 from a
1070
+ // missing data-abs. That older property held only while the trim marker was the
1071
+ // single non-row child; a per-gap marker carries its gap's LOW index, so an
1072
+ // attribute test would return the marker as the "nearest surviving row" and the
1073
+ // anchor would pin an annotation about a hole to the reader's screen position.
1074
+ // (~13 reads at the 5000-row cap; this runs inside the eviction flush, the
1075
+ // frame's most expensive pass already.)
1076
+ function firstRowAtOrAfter(abs: number): HTMLElement | null {
1077
+ const kids = output.children;
1078
+ let lo = 0;
1079
+ let hi = kids.length - 1;
1080
+ let found: HTMLElement | null = null;
1081
+ while (lo <= hi) {
1082
+ const mid = (lo + hi) >> 1;
1083
+ const el = kids[mid] as HTMLElement;
1084
+ if (rowAbs(el) >= abs) {
1085
+ found = el;
1086
+ hi = mid - 1;
1087
+ } else {
1088
+ lo = mid + 1;
1089
+ }
1090
+ }
1091
+ while (found !== null && !isContentRow(found)) {
1092
+ found = found.nextElementSibling as HTMLElement | null;
1093
+ }
1094
+ return found;
723
1095
  }
724
1096
 
725
1097
  function restoreReadAnchor(anchor: ReadAnchor | null): void {
726
- if (anchor?.el.parentElement !== output) {
727
- return; // not holding, or the anchor row was itself evicted (mass trim)
1098
+ if (anchor === null) {
1099
+ return; // following: stickToBottom owns the position
1100
+ }
1101
+ let el: HTMLElement | null = anchor.el;
1102
+ if (el.parentElement !== output) {
1103
+ if (fullResetThisPass) {
1104
+ // Server restart / epoch change: absolute indices restarted from 0, so
1105
+ // a child with a matching data-abs is UNRELATED content from the new
1106
+ // session — re-anchoring on it would pull an arbitrary row to the old
1107
+ // reading position. Stand down; the fresh session's own follow state
1108
+ // owns the viewport.
1109
+ return;
1110
+ }
1111
+ // The anchor row itself was evicted out from under the reader: batched
1112
+ // cap eviction frees up to a whole batch (256 at the default cap) in one
1113
+ // pass, so a reader parked within a batch of the buffer top loses the
1114
+ // anchored ELEMENT while rows they had not read yet survive below it.
1115
+ // Re-resolve to the first surviving row at or after the anchored index
1116
+ // and hold THAT at the anchor's screen position — the view resumes at
1117
+ // the nearest surviving content instead of silently jumping past up to
1118
+ // a batch of unread lines (WebKit has no native anchoring to catch it).
1119
+ el = firstRowAtOrAfter(anchor.abs);
1120
+ if (el === null) {
1121
+ return; // nothing survives (reset/clear): nothing to hold
1122
+ }
1123
+ // A region DISCARD, not a cap trim: the application erased its saved lines
1124
+ // (ED3), so nothing surviving is guaranteed ADJACENT to what the reader was
1125
+ // looking at. On an inline TUI that reprints on resize the same text comes
1126
+ // straight back at new indices, so the nearest surviving row is unrelated
1127
+ // content — and holding it at the reader's screen position is the "random jump
1128
+ // on resize" symptom. A cap trim is the opposite case and keeps the re-resolve
1129
+ // above: it removes the oldest contiguous run, so the survivor really is the
1130
+ // reader's neighbour (docs/scroll-position-fidelity.md §1.2, §5).
1131
+ //
1132
+ // The test is the ANCHOR's index. An earlier version also required the
1133
+ // SURVIVOR to sit at or above the discard base, which never held for the shape
1134
+ // this exists for: the reprint re-delivers lines below that base in the same
1135
+ // frame, so the survivor looked adjacent and the correction fired anyway.
1136
+ //
1137
+ // The remaining `anchor.abs <` clause is a GUARD, not a live path, and the
1138
+ // reachability argument is worth recording because it is not obvious. For a
1139
+ // single well-formed frame it cannot fire: `msg.base` is both the discard bound
1140
+ // and the new window base, so an anchor at or above it sits inside the live
1141
+ // window, which cap eviction protects and `truncateBelowWindow` only trims
1142
+ // above. Across several frames in one batch (ED3 at a low base, the window
1143
+ // settling high) the anchor's ELEMENT normally survives instead — the store
1144
+ // coalesces an evict-then-reapply of the same index, so `upsertRow` reuses the
1145
+ // element and this whole branch is skipped, leaving the ordinary drift
1146
+ // correction to handle the history the discard removed above the reader, which
1147
+ // is right. Measured: that path applies a legitimate -1700px correction for
1148
+ // 100 discarded rows, and it is NOT this branch.
1149
+ if (discardedBelowThisPass >= 0 && anchor.abs < discardedBelowThisPass) {
1150
+ return;
1151
+ }
728
1152
  }
729
1153
  // Correct by how far the row DRIFTED ON SCREEN, not by how much the content
730
1154
  // above it changed. The two differ exactly when the browser already did this
@@ -734,14 +1158,16 @@ function restoreReadAnchor(anchor: ReadAnchor | null): void {
734
1158
  // the content delta instead would double-compensate there and throw the view
735
1159
  // the other way. On Safari, which has no native anchoring, the drift is the
736
1160
  // whole content delta and this is the only thing that fixes it.
737
- const drift = anchor.el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
1161
+ const drift = el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
738
1162
  scroll.adjustForContentShift(drift);
739
1163
  }
740
1164
 
741
1165
  function flushRender(): void {
742
1166
  pendingFrame = undefined;
743
- // Measured BEFORE any DOM mutation, so the post-mutation read below reveals
744
- // exactly how much the content above the reading position moved.
1167
+ // Read BEFORE any mutation, twice over: the anchor needs the pre-mutation
1168
+ // screen position, and noteContentShrink needs the pre-mutation offset to tell
1169
+ // whether this pass's row removals actually moved the viewport.
1170
+ const scrollTopBefore = scroll.currentScrollTop();
745
1171
  const anchor = captureReadAnchor();
746
1172
  try {
747
1173
  flushRenderInner();
@@ -776,14 +1202,45 @@ function flushRender(): void {
776
1202
  console.error("vterm: giving up render retry after repeated no-progress errors");
777
1203
  }
778
1204
  }
779
- // Two position invariants, applied after every DOM mutation and in this order:
780
- // hold the reader's line against content that shifted above it, then (only if
781
- // following) pin to the new bottom. They are mutually exclusive by
782
- // construction — the anchor is null while following — so the order is for
783
- // readability, not correctness.
784
- restoreReadAnchor(anchor);
1205
+ // Tell the scroll controller whether this pass's row removals moved the
1206
+ // viewport. Must run BEFORE any write of our own below, or the comparison
1207
+ // measures our write instead of the browser's clamp. Announcing the clamp is
1208
+ // what keeps a content shrink from reading as "the user scrolled up" and
1209
+ // silently switching auto-follow off (scroll.ts's header; §1.3).
1210
+ if (removedRowsThisPass) {
1211
+ scroll.noteContentShrink(scrollTopBefore);
1212
+ }
1213
+ // Three position invariants, applied after every DOM mutation and in this
1214
+ // order. An armed view restore goes first because it OWNS the position while
1215
+ // it is armed; the read anchor then holds whatever line is on screen; the
1216
+ // bottom pin last, and only if following.
1217
+ //
1218
+ // The anchor is skipped ONLY in the frame the restore actually LANDED, and
1219
+ // that exception is load-bearing rather than tidy: the anchor was captured
1220
+ // BEFORE this frame's mutations, so once the restore has authoritatively moved
1221
+ // the viewport, the anchor's drift measures the restore's own write and
1222
+ // corrects it straight back out — the restore and the anchor cancel and the
1223
+ // reader stays at the top. Skipping it while merely ARMED would be wrong for
1224
+ // the opposite reason: a rebuild spans many frames, and suppressing the anchor
1225
+ // across all of them reintroduces the WebKit read-position slide for every one.
1226
+ const restoreLanded = applyPendingRestore();
1227
+ if (!restoreLanded) {
1228
+ restoreReadAnchor(anchor);
1229
+ }
785
1230
  // Single auto-follow invariant, applied after every DOM mutation.
786
1231
  stickToBottomIfFollowing();
1232
+ // Absorb every write this frame made — ours and the browser's native scroll
1233
+ // anchoring — into the restore's baseline, so only a move that happens BETWEEN
1234
+ // frames (a real gesture) reads as one.
1235
+ if (pendingRestore !== null) {
1236
+ pendingRestore.lastWrote = scroll.currentScrollTop();
1237
+ }
1238
+ // The post-flush trigger. The reader's position relative to the store's gaps
1239
+ // can change without any scroll event — a tail trim moves the frontier up
1240
+ // under a stationary reader, and a byte-short page leaves a fresh sub-gap
1241
+ // beside them — so the flush is the other place the trigger must run
1242
+ // (docs/paged-scrollback.md §5.4).
1243
+ maybeFetchHistory();
787
1244
  }
788
1245
 
789
1246
  function flushRenderInner(): void {
@@ -792,18 +1249,28 @@ function flushRenderInner(): void {
792
1249
  // the count-so-far for flushRender's catch to read.
793
1250
  flushDrainedThisPass = 0;
794
1251
  const ch = store.drainChanges();
1252
+ fullResetThisPass = ch.fullReset;
1253
+ discardedBelowThisPass = discardedBelowPending;
1254
+ discardedBelowPending = -1;
1255
+ removedRowsThisPass = false;
795
1256
 
796
1257
  if (ch.fullReset) {
1258
+ removedRowsThisPass = true;
797
1259
  output.replaceChildren();
798
1260
  rowEls.clear();
799
1261
  renderQueue.clear();
800
1262
  trimMarkerEl = null;
1263
+ for (const el of gapMarkerEls.values()) {
1264
+ el.remove();
1265
+ }
1266
+ gapMarkerEls.clear();
801
1267
  cursorAbs = -1;
802
1268
  } else {
803
1269
  for (const abs of ch.evictedLines) {
804
1270
  const el = rowEls.get(abs);
805
1271
  if (el) {
806
1272
  el.remove();
1273
+ removedRowsThisPass = true;
807
1274
  }
808
1275
  rowEls.delete(abs);
809
1276
  renderQueue.delete(abs);
@@ -843,10 +1310,15 @@ function flushRenderInner(): void {
843
1310
  // before deep scrollback on a large-history alt-exit.
844
1311
  altRendered = false;
845
1312
  altPrevRows = [];
1313
+ removedRowsThisPass = true;
846
1314
  output.replaceChildren();
847
1315
  rowEls.clear();
848
1316
  renderQueue.clear();
849
1317
  trimMarkerEl = null;
1318
+ for (const el of gapMarkerEls.values()) {
1319
+ el.remove();
1320
+ }
1321
+ gapMarkerEls.clear();
850
1322
  queueRowsViewportFirst();
851
1323
  }
852
1324
 
@@ -889,16 +1361,35 @@ function flushRenderInner(): void {
889
1361
  }
890
1362
  inWindow.sort((a, b) => a - b);
891
1363
  belowWindow.sort((a, b) => b - a);
892
- for (const abs of [...inWindow, ...belowWindow]) {
1364
+ // Drain the two lists sequentially under one budget (no concatenated copy:
1365
+ // during a full-backlog drain the spread re-allocated a backlog-sized array
1366
+ // every frame — pure GC churn on the hot path).
1367
+ const drainRow = (abs: number): boolean => {
893
1368
  if (flushDrainedThisPass >= MAX_ROWS_PER_FRAME) {
894
- break;
1369
+ return false;
895
1370
  }
896
1371
  upsertRow(abs);
897
1372
  renderQueue.delete(abs);
898
1373
  flushDrainedThisPass++;
1374
+ return true;
1375
+ };
1376
+ for (const abs of inWindow) {
1377
+ if (!drainRow(abs)) {
1378
+ break;
1379
+ }
1380
+ }
1381
+ for (const abs of belowWindow) {
1382
+ if (!drainRow(abs)) {
1383
+ break;
1384
+ }
899
1385
  }
900
1386
 
901
1387
  updateTrimMarker();
1388
+ // The gap markers are a projection of the store's geometry, so they are
1389
+ // re-derived on every flush rather than maintained incrementally: a page
1390
+ // apply, a browse eviction and a tail trim all move gap edges, and none of
1391
+ // them should have to know a marker exists.
1392
+ updateGapMarkers();
902
1393
 
903
1394
  // More rows pending: keep draining on subsequent frames.
904
1395
  if (renderQueue.size > 0) {
@@ -912,28 +1403,348 @@ function flushRenderInner(): void {
912
1403
  }
913
1404
  }
914
1405
 
915
- // updateTrimMarker shows or hides the "earlier output trimmed" marker as the
916
- // first child of output, driven by the store. It appears when history older
917
- // than the store holds was trimmed — either the client evicted its oldest
918
- // lines at the cap, or the server reported (via resumeAck bounds) that it no
919
- // longer retains history the client was missing on resume. The marker carries
920
- // no data-abs, so insertRowInOrder (which compares numeric data-abs) never
921
- // places a row before it; it stays pinned at the top.
922
- function updateTrimMarker(): void {
923
- if (store.hasTrimmedHistory()) {
924
- if (trimMarkerEl === null) {
925
- trimMarkerEl = document.createElement("div");
926
- trimMarkerEl.className = "term-trim-marker";
927
- trimMarkerEl.setAttribute("role", "status");
928
- trimMarkerEl.setAttribute("aria-label", "earlier output trimmed");
929
- trimMarkerEl.textContent = "earlier output trimmed";
1406
+ // --- Per-view scroll memory (docs/scroll-position-fidelity.md §3) ---
1407
+ //
1408
+ // A reading position is a LINE, not a pixel offset. A tabbed consumer that saved
1409
+ // `scrollTop` and replayed it on re-entry had that write silently CLAMPED,
1410
+ // because a rebuild has only built the live window plus one frame's budget when
1411
+ // the replay lands (301 of up to 5000 rows, ~5100 of ~85,000 px), and the clamp
1412
+ // was never re-attempted. The saved offset also stops meaning the same line as
1413
+ // soon as the tab's content grows while it is backgrounded, which is every tab
1414
+ // whose session kept working. So the memory is an absolute index plus the row's
1415
+ // on-screen offset, and the restore is RE-ASSERTED until the row it names has
1416
+ // actually been built.
1417
+ //
1418
+ // `screenTop` is deliberately a DIFFERENCE (offsetTop - scrollTop), the same
1419
+ // form ReadAnchor uses: rows report offsets in `.term-output`'s space while
1420
+ // scrollTop belongs to `.term`, so any ABSOLUTE pixel value would need the
1421
+ // rowTopInTermWrap conversion and would be one padding off without it. A
1422
+ // difference cancels the space out.
1423
+ export interface ViewMemory {
1424
+ /** Absolute line index of the content row at the viewport top. */
1425
+ abs: number;
1426
+ /** That row's on-screen position: offsetTop minus the scroll offset. */
1427
+ screenTop: number;
1428
+ /** Whether auto-follow was engaged. */
1429
+ following: boolean;
1430
+ }
1431
+
1432
+ // The armed restore. `gen` is the bind generation it belongs to, so a second
1433
+ // switch mid-drain cannot land the first tab's anchor into the second tab's
1434
+ // store. `lastWrote` is the offset this module last left behind: a position that
1435
+ // does NOT match it was moved by the user, which cancels — the library never
1436
+ // fights a gesture, and it establishes that by knowing its own writes rather
1437
+ // than by listening to a signal (`onScrollPosition`) that also fires for the
1438
+ // browser's clamps, including the one the rebuild itself causes.
1439
+ interface PendingRestore {
1440
+ view: ViewMemory;
1441
+ gen: number;
1442
+ lastWrote: number;
1443
+ deadline: number;
1444
+ }
1445
+ let pendingRestore: PendingRestore | null = null;
1446
+ let bindGen = 0;
1447
+ // Wall-clock of the last inbound frame for the bound store. `renderQueue.size
1448
+ // === 0` alone is NOT "the rebuild finished": it reaches zero BETWEEN a resume
1449
+ // batch's replay chunks (see pendingRowCount's contract), so the settle needs
1450
+ // transport quiet too. Both numbers mirror the tabs feature's already-tuned
1451
+ // catch-up cue, which answers the same question for the same reason.
1452
+ let lastInboundMs = 0;
1453
+ const RESTORE_SETTLE_MS = 250;
1454
+ const RESTORE_MAX_MS = 30000;
1455
+ // A position within this many px of what we last wrote is still ours: a
1456
+ // fractional-layout or subpixel-DPR readback is not a user gesture.
1457
+ const RESTORE_OWN_WRITE_EPSILON_PX = 1;
1458
+
1459
+ /**
1460
+ * Capture the current view as per-view scroll memory a consumer can hand back
1461
+ * to `bind`. Returns null when there is nothing meaningful to remember: no
1462
+ * content rows yet, or the alternate screen is active (an alt grid has no
1463
+ * absolute indices worth restoring, and measuring one would overwrite a tab's
1464
+ * real saved position with an alt-screen row).
1465
+ *
1466
+ * Unlike the private read anchor this measures in BOTH follow states — the
1467
+ * follow half is saved alongside it, and a consumer needs the pair.
1468
+ */
1469
+ export function captureViewMemory(): ViewMemory | null {
1470
+ if (store.isAlt()) {
1471
+ return null;
1472
+ }
1473
+ const el = rowAtViewportTop();
1474
+ if (el === null) {
1475
+ return null;
1476
+ }
1477
+ const abs = rowAbs(el);
1478
+ if (abs < 0) {
1479
+ return null;
1480
+ }
1481
+ return {
1482
+ abs,
1483
+ screenTop: el.offsetTop - scroll.currentScrollTop(),
1484
+ following: !scroll.isUserScrolledUp(),
1485
+ };
1486
+ }
1487
+
1488
+ /** Drop any armed restore. Called on (re)init and on every bind. */
1489
+ function clearPendingRestore(): void {
1490
+ pendingRestore = null;
1491
+ }
1492
+
1493
+ // applyPendingRestore re-asserts an armed view restore, and is the reason the
1494
+ // restore is not a one-shot: the anchored row may not be BUILT yet. A rebuild
1495
+ // drains at MAX_ROWS_PER_FRAME, so a reader parked deep in history has their row
1496
+ // materialize several frames after the switch, and a single write at frame 1
1497
+ // would be clamped to the partial content and never retried.
1498
+ //
1499
+ // Returns true when it landed (and disarmed).
1500
+ function applyPendingRestore(): boolean {
1501
+ const p = pendingRestore;
1502
+ if (p === null) {
1503
+ return false;
1504
+ }
1505
+ // A newer bind owns the surface, or indices restarted: the saved anchor
1506
+ // describes content that is no longer here.
1507
+ if (p.gen !== bindGen || fullResetThisPass) {
1508
+ clearPendingRestore();
1509
+ return false;
1510
+ }
1511
+ const now = scroll.currentScrollTop();
1512
+ if (Math.abs(now - p.lastWrote) > RESTORE_OWN_WRITE_EPSILON_PX) {
1513
+ clearPendingRestore(); // the user moved: never fight a gesture
1514
+ return false;
1515
+ }
1516
+ const el = rowEls.get(p.view.abs);
1517
+ if (el === undefined) {
1518
+ // Not built yet, or gone for good. Give up only once the surface has
1519
+ // genuinely settled (queue drained AND transport quiet) or the bound
1520
+ // lapses; until then stay armed and try again next frame.
1521
+ const quiet = renderQueue.size === 0 && Date.now() - lastInboundMs > RESTORE_SETTLE_MS;
1522
+ if (quiet || Date.now() > p.deadline) {
1523
+ clearPendingRestore();
1524
+ }
1525
+ return false;
1526
+ }
1527
+ // Same arithmetic as the read anchor's drift correction, for the same
1528
+ // space-cancelling reason, and idempotent: an already-satisfied restore
1529
+ // measures zero and writes nothing.
1530
+ const drift = el.offsetTop - now - p.view.screenTop;
1531
+ scroll.adjustForContentShift(drift);
1532
+ clearPendingRestore();
1533
+ return true;
1534
+ }
1535
+
1536
+ /**
1537
+ * The viewport's absolute index for the paging layer's live questions: which
1538
+ * rows to exempt from eviction, whether a reader is sitting on cache, which gap
1539
+ * an interaction is approaching. All three ask about NOW, so they get the live
1540
+ * measurement.
1541
+ *
1542
+ * `pendingRestoreAbs` is the separate answer for the one caller that asks a
1543
+ * different question — the resume transition, which asks which rows must
1544
+ * SURVIVE a switch (docs/scroll-position-fidelity.md §7.2).
1545
+ */
1546
+ function viewportAbs(): number {
1547
+ if (!scroll.isUserScrolledUp()) {
1548
+ return store.getWindow().base;
1549
+ }
1550
+ const el = rowAtViewportTop();
1551
+ const abs = el === null ? -1 : rowAbs(el);
1552
+ return abs >= 0 ? abs : store.getWindow().base;
1553
+ }
1554
+
1555
+ /**
1556
+ * The absolute index of an ARMED view restore, or null. Only the resume
1557
+ * transition consults this: during a rebuild the live viewport is a transient —
1558
+ * mid-drain, at a clamped offset — and a reclassify pass that reads it can evict
1559
+ * the very rows the restore is about to bring back. The pending anchor is the
1560
+ * position the user is regaining, which is the one that must survive.
1561
+ */
1562
+ export function pendingRestoreAbs(): number | null {
1563
+ const p = pendingRestore;
1564
+ if (p === null) {
1565
+ return null;
1566
+ }
1567
+ // Expire on READ as well as in the flush. The flush is the only other place
1568
+ // this state is examined, so an idle surface (queue drained, no inbound frames,
1569
+ // no flush scheduled) would otherwise hold an arm indefinitely and keep
1570
+ // answering with a line the reader has long left.
1571
+ if (Date.now() > p.deadline) {
1572
+ clearPendingRestore();
1573
+ return null;
1574
+ }
1575
+ return p.view.abs;
1576
+ }
1577
+
1578
+ /**
1579
+ * The fetch trigger: decide whether to ask for a page, and for which range.
1580
+ *
1581
+ * Called from the post-flush anchor path and from `scroll.ts`'s position seam.
1582
+ * Both are cheap to over-call — every guard below is a pure read, and pacing
1583
+ * makes a spurious run free — which is deliberate: the alternative (trying to
1584
+ * fire it only when needed) is how a trigger ends up never firing on the idle
1585
+ * session that needs it most.
1586
+ */
1587
+ export function maybeFetchHistory(): void {
1588
+ // ALT: no paging while the alternate screen is active. The event paths are
1589
+ // scrollback-UI-only, but the pending-demand timer fires from a CLOCK, so
1590
+ // this guard is the load-bearing one — without it a vim session would fetch
1591
+ // pages nobody can see, and each denial would re-arm the timer for the
1592
+ // session's whole duration (docs/paged-scrollback.md §5.5).
1593
+ if (store.isAlt()) {
1594
+ return;
1595
+ }
1596
+ if (requestHistoryFn === null) {
1597
+ return; // paging not wired by this consumer
1598
+ }
1599
+ const budget = historyBudgetFn === null ? PAGE_SIZE : historyBudgetFn();
1600
+ if (!Number.isInteger(budget) || budget < 1) {
1601
+ return;
1602
+ }
1603
+ const abs = viewportAbs();
1604
+ const edges = store.absentEdgesNear(abs, PREFETCH_THRESHOLD);
1605
+ const gap = edges[0];
1606
+ if (gap === undefined) {
1607
+ return;
1608
+ }
1609
+ // APPROACH-ANCHORED: always fetch the end NEAREST the reader, so a wide gap
1610
+ // heals from the side being read. Fetching a fixed end would land pages up to
1611
+ // (gapWidth - budget) lines away from the viewport, leaving the rows under the
1612
+ // reader blank while the far end filled in.
1613
+ const floor = store.pagingFloorIndex();
1614
+ const fromAbs =
1615
+ abs >= gap.hi
1616
+ ? Math.max(gap.lo, gap.hi - budget, floor) // approaching from BELOW the gap's top
1617
+ : Math.max(gap.lo, floor); // approaching from above: start at the gap's low edge
1618
+ const maxLines = Math.min(budget, gap.hi - fromAbs);
1619
+ if (maxLines < 1) {
1620
+ return;
1621
+ }
1622
+ requestHistoryFn(fromAbs, maxLines);
1623
+ }
1624
+
1625
+ /**
1626
+ * Re-derive the gap markers from the store's geometry. A projection, not a
1627
+ * mutation log: every call recomputes which gaps exist and reconciles the DOM,
1628
+ * so a gap that healed from either edge moves or loses its marker without any
1629
+ * caller having to know which edge changed.
1630
+ */
1631
+ function updateGapMarkers(): void {
1632
+ // Through intervals.ts, which declares itself the single source of gap
1633
+ // geometry — and the store's fetch trigger reads it too. This loop used to be
1634
+ // a second copy of `interiorGaps` with the same bounds and the same predicate,
1635
+ // so any correction to gap derivation (a touching-run case, an off-by-one at a
1636
+ // run edge) would have landed on one of them: the renderer marking a hole the
1637
+ // trigger will not fetch, or omitting one over a hole it will.
1638
+ const gaps = new Map<number, number>(); // low index -> high index
1639
+ for (const gap of interiorGaps(store.retainedRanges())) {
1640
+ gaps.set(gap.lo, gap.hi);
1641
+ }
1642
+ // Remove markers whose gap closed.
1643
+ for (const [lo, el] of [...gapMarkerEls]) {
1644
+ if (!gaps.has(lo)) {
1645
+ el.remove();
1646
+ gapMarkerEls.delete(lo);
930
1647
  }
931
- if (trimMarkerEl.parentElement !== output || output.firstChild !== trimMarkerEl) {
932
- output.insertBefore(trimMarkerEl, output.firstChild);
1648
+ }
1649
+ const floor = store.pagingFloorIndex();
1650
+ for (const [lo, hi] of gaps) {
1651
+ let el = gapMarkerEls.get(lo);
1652
+ if (el === undefined) {
1653
+ el = document.createElement("div");
1654
+ el.className = "term-gap-marker";
1655
+ el.setAttribute("role", "status");
1656
+ gapMarkerEls.set(lo, el);
1657
+ }
1658
+ // Three states, and the IDLE one is the default (§5.4). A gap marker whose
1659
+ // whole reason to exist is "do not read these two regions as contiguous"
1660
+ // cannot fall back to asserting nothing, or the splice it prevents comes
1661
+ // back silently.
1662
+ const condemned = floor >= hi;
1663
+ const label = condemned ? "earlier output trimmed" : "earlier output not loaded";
1664
+ if (el.textContent !== label) {
1665
+ el.textContent = label;
1666
+ el.setAttribute("aria-label", label);
1667
+ }
1668
+ el.dataset["abs"] = String(lo);
1669
+ el.classList.toggle("term-gap-trimmed", condemned);
1670
+ // Position by data-abs order, the same invariant the row inserts keep.
1671
+ const next = firstRowAtOrAfter(hi);
1672
+ if (el.parentElement !== output || el.nextElementSibling !== next) {
1673
+ output.insertBefore(el, next);
1674
+ }
1675
+ }
1676
+ }
1677
+
1678
+ // updateTrimMarker shows, hides and LABELS the top-of-store marker as the first
1679
+ // child of output, driven by the store. It carries no data-abs, so
1680
+ // insertRowInOrder (which compares numeric data-abs) never places a row before
1681
+ // it; it stays pinned at the top.
1682
+ //
1683
+ // Three states, because there are three honest things to say about the history
1684
+ // above what is held (docs/paged-scrollback.md §5.4):
1685
+ //
1686
+ // - NOTHING, when the store holds index 0: there is no history above it.
1687
+ // - "earlier output trimmed", the PERMANENT statement. Without paging declared
1688
+ // — including the pre-ack instant — it is the store's own trim bookkeeping,
1689
+ // the pre-paging behavior and the right one when no fetch exists: the client
1690
+ // evicted those rows itself, which is a fact whatever the server can serve.
1691
+ // With paging declared it is EARNED instead: the paging floor has reached the
1692
+ // oldest held index (the server proved nothing at or below it survives — an
1693
+ // empty frontier reply lands exactly here), or the server's own retained edge
1694
+ // is at or above it.
1695
+ // - "earlier output not loaded", the RECOVERABLE statement, with paging
1696
+ // declared and neither of those proofs in hand. This is the state that was
1697
+ // missing: a bounded resume replay routinely leaves a fetchable frontier, and
1698
+ // the old single predicate either said nothing at all (silently presenting a
1699
+ // partial transcript as the beginning of the session) or said "trimmed" about
1700
+ // history the server still holds and the trigger is about to fetch.
1701
+ //
1702
+ // The frontier is deliberately NOT sourced from gap geometry: its lower edge is
1703
+ // a policy question (what is still worth requesting), so an exhausted frontier
1704
+ // still renders a marker here rather than vanishing when the gap closes.
1705
+ function updateTrimMarker(): void {
1706
+ const label = topMarkerLabel();
1707
+ if (label === null) {
1708
+ if (trimMarkerEl !== null && trimMarkerEl.parentElement === output) {
1709
+ trimMarkerEl.remove();
933
1710
  }
934
- } else if (trimMarkerEl !== null && trimMarkerEl.parentElement === output) {
935
- trimMarkerEl.remove();
1711
+ return;
936
1712
  }
1713
+ if (trimMarkerEl === null) {
1714
+ trimMarkerEl = document.createElement("div");
1715
+ trimMarkerEl.className = "term-trim-marker";
1716
+ trimMarkerEl.setAttribute("role", "status");
1717
+ }
1718
+ if (trimMarkerEl.textContent !== label) {
1719
+ trimMarkerEl.textContent = label;
1720
+ trimMarkerEl.setAttribute("aria-label", label);
1721
+ }
1722
+ // Mirrors the interior gap marker's class, so one rule styles "gone" in both
1723
+ // places and a reader learns the distinction once.
1724
+ trimMarkerEl.classList.toggle("term-gap-trimmed", label === TRIM_LABEL_GONE);
1725
+ if (trimMarkerEl.parentElement !== output || output.firstChild !== trimMarkerEl) {
1726
+ output.insertBefore(trimMarkerEl, output.firstChild);
1727
+ }
1728
+ }
1729
+
1730
+ /** The permanent label: nothing below the oldest held index can be recovered. */
1731
+ const TRIM_LABEL_GONE = "earlier output trimmed";
1732
+ /** The recoverable label: history above exists and a fetch can bring it back. */
1733
+ const TRIM_LABEL_PENDING = "earlier output not loaded";
1734
+
1735
+ /** Which top-of-store statement is true, or null for no marker (§5.4). */
1736
+ function topMarkerLabel(): string | null {
1737
+ const oldest = store.oldestIndex();
1738
+ if (oldest <= 0) {
1739
+ return null; // holds index 0, or holds nothing: no history above it
1740
+ }
1741
+ if (!store.pagingDeclared()) {
1742
+ return store.hasTrimmedHistory() ? TRIM_LABEL_GONE : null;
1743
+ }
1744
+ const serverOldest = store.serverOldestIndex();
1745
+ const condemned =
1746
+ store.pagingFloorIndex() >= oldest || (serverOldest >= 0 && serverOldest >= oldest);
1747
+ return condemned ? TRIM_LABEL_GONE : TRIM_LABEL_PENDING;
937
1748
  }
938
1749
 
939
1750
  // upsertRow builds or updates the DOM row for an absolute index, or removes it
@@ -945,6 +1756,7 @@ function upsertRow(abs: number): void {
945
1756
  const stale = rowEls.get(abs);
946
1757
  if (stale) {
947
1758
  stale.remove();
1759
+ removedRowsThisPass = true;
948
1760
  rowEls.delete(abs);
949
1761
  }
950
1762
  return;
@@ -991,8 +1803,13 @@ let altRendered = false;
991
1803
  // The alt row arrays rendered by the last flush, by grid index. Row identity
992
1804
  // is the store's change signal (applyScreen swaps exactly the changed rows'
993
1805
  // arrays; getAltRows returns the live references), so `prev[y] === rows[y]`
994
- // means row y's DOM is already current. Cleared on alt exit and re-init.
995
- let altPrevRows: readonly (readonly WireRun[])[] = [];
1806
+ // means row y's DOM is already current. A separate MUTABLE container from the
1807
+ // store's own array (which is mutated in place across frames): the full build
1808
+ // snapshots it once, and the reconcile path updates only the entries it
1809
+ // rebuilt — the previous per-frame `rows.slice()` allocated a fresh array on
1810
+ // every alt flush (60fps GC churn under a full-screen TUI) for identical
1811
+ // semantics. Cleared on alt exit and re-init.
1812
+ let altPrevRows: (readonly WireRun[])[] = [];
996
1813
 
997
1814
  function renderAlt(rows: readonly (readonly WireRun[])[]): void {
998
1815
  rowEls.clear();
@@ -1024,8 +1841,9 @@ function renderAlt(rows: readonly (readonly WireRun[])[]): void {
1024
1841
  const div = output.children[y] as HTMLDivElement;
1025
1842
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length === children.length
1026
1843
  div.replaceChildren(...buildRowSpans(rows[y]!));
1844
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- same bound as above
1845
+ altPrevRows[y] = rows[y]!;
1027
1846
  }
1028
- altPrevRows = rows.slice();
1029
1847
  }
1030
1848
 
1031
1849
  /** Pin the viewport to the bottom iff the user is following. The scroll
@@ -1120,6 +1938,14 @@ if (typeof document !== "undefined") {
1120
1938
  * metrics.
1121
1939
  */
1122
1940
  export function updateFontMetrics(): void {
1941
+ // Capture BEFORE the metrics change: a row-height change rescales every
1942
+ // offsetTop, so a holding reader's line moves under them with nothing to
1943
+ // correct it — the read anchor only measures drift across a FLUSH's own
1944
+ // mutations, and a pure restyle may not schedule one at all. `abs` (the line
1945
+ // identity, which is what the reader cares about) is exact regardless of when
1946
+ // this runs; `screenTop` can be up to one row stale if the layout changed
1947
+ // before this call, which is a sub-row error rather than a lost position.
1948
+ const before = captureViewMemory();
1123
1949
  const cs = window.getComputedStyle(termWrap);
1124
1950
  // Refresh the overlay-positioning padding cache from the same computed
1125
1951
  // style (one staleness contract for all cached box metrics).
@@ -1138,6 +1964,31 @@ export function updateFontMetrics(): void {
1138
1964
  defaultSpacing = cellWidth - measuredW;
1139
1965
  output.style.letterSpacing = `${defaultSpacing}px`;
1140
1966
  document.documentElement.style.setProperty("--char-w", `${cellWidth}px`);
1967
+ // A holding reader's line is restored through the same machinery a tab switch
1968
+ // uses: arm it and let the next flush re-assert it. Idempotent when the metrics
1969
+ // did not actually change (the drift measures zero), and a no-op for a
1970
+ // following viewport, whose position the bottom pin already owns.
1971
+ // An armed restore SURVIVES this call, and its baseline has to be refreshed or
1972
+ // it does not: the reflow that provoked the call has already moved scrollTop,
1973
+ // so leaving lastWrote stale makes the next flush read that as a gesture and
1974
+ // cancel the very restore this branch is protecting. Declining to arm and
1975
+ // silently killing the existing arm would be the worst of both.
1976
+ if (pendingRestore !== null) {
1977
+ pendingRestore.lastWrote = scroll.currentScrollTop();
1978
+ }
1979
+ // NEVER over an armed restore. A resize settle calls this (through the
1980
+ // consumer's measurable-size check) and a tab switch reconnects, so the two
1981
+ // routinely overlap: replacing a bind's saved anchor with one measured
1982
+ // mid-rebuild would swap the reader's real position for a transient.
1983
+ if (before !== null && !before.following && pendingRestore === null) {
1984
+ pendingRestore = {
1985
+ view: before,
1986
+ gen: bindGen,
1987
+ lastWrote: scroll.currentScrollTop(),
1988
+ deadline: Date.now() + RESTORE_MAX_MS,
1989
+ };
1990
+ scheduleFlush();
1991
+ }
1141
1992
  }
1142
1993
 
1143
1994
  const MIN_COLS = 20;