@cplieger/web-terminal-engine 5.3.0 → 6.0.1

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
@@ -1,32 +1,21 @@
1
- // Render layer: store-backed, absolute-index DOM rows.
2
- //
3
- // The renderer owns a LineStore (the authoritative client model) and reflects
4
- // it to the DOM. Every terminal line is a `div.term-row` carrying a `data-abs`
5
- // attribute equal to its absolute index; the rows sit in one natively-scrolled
6
- // container in absolute order. There is no separate "live zone" and
7
- // "scrollback": the live window is simply the last `height` absolute indices,
8
- // and a line that scrolls into history just stops being updated. This is what
9
- // removes the live/history reconciliation that caused the duplicate-rows and
10
- // view-jumping bugs.
11
- //
12
- // Decode frames feed the store (handleScreen/handleScroll); a single
13
- // requestAnimationFrame flush drains the store's change set and applies it:
14
- // evicted indices drop their row, dirty indices build/update their row in
15
- // place. The window block always has `height` rows, so scrollHeight only grows
16
- // when real history commits — never oscillating mid-redraw.
1
+ // Render layer: store-backed, absolute-index DOM rows. Every terminal line is a
2
+ // `div.term-row` with a `data-abs` attribute equal to its absolute index, in one
3
+ // natively-scrolled container in absolute order; the live window is simply the
4
+ // last `height` indices, so there is no live/history reconciliation to get
5
+ // wrong. Frames feed the store; one requestAnimationFrame flush drains its
6
+ // change set (evicted indices drop their row, dirty ones build in place). The
7
+ // window block always has `height` rows, so scrollHeight grows only when real
8
+ // history commits.
17
9
 
18
10
  import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
19
- import { LineStore, PAGE_SIZE, PREFETCH_THRESHOLD } from "./store.js";
20
- import * as scroll from "./scroll.js";
21
- import { isReverseVideo } from "./modes.js";
11
+ import { COMPATIBILITY_TAIL_CAP, LineStore, PAGE_SIZE, PREFETCH_THRESHOLD } from "./store.js";
12
+ import type { ScrollController } from "./scroll.js";
13
+ import type { ModeState } from "./modes.js";
22
14
  import { interiorGaps } from "./intervals.js";
23
15
 
24
16
  // --- Width cache (two-tier, xterm.js style) ---
25
17
  const WIDTH_FLAT_SIZE = 256;
26
18
  const WIDTH_FLAT_UNSET = -9999;
27
- const widthFlat = new Float32Array(WIDTH_FLAT_SIZE).fill(WIDTH_FLAT_UNSET);
28
- const widthMap = new Map<string, number>();
29
-
30
19
  const VARIANT_REGULAR = 0;
31
20
  const VARIANT_BOLD = 1;
32
21
  const VARIANT_ITALIC = 2;
@@ -36,2505 +25,2055 @@ const VARIANT_ITALIC = 2;
36
25
  // tab close (only a font change clears it). Cap it: a clear on overflow costs
37
26
  // an occasional re-measure and changes no rendered output.
38
27
  const WIDTH_MAP_MAX = 20_000;
39
- const variantCtx: (CanvasRenderingContext2D | null)[] = [null, null, null, null];
40
- let fontString = "";
41
-
42
- function variantContext(variant: number): CanvasRenderingContext2D {
43
- let ctx = variantCtx[variant];
44
- if (ctx) {
45
- return ctx;
46
- }
47
- const canvas = document.createElement("canvas");
48
- canvas.width = 1;
49
- canvas.height = 1;
50
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 2d context always available on fresh canvas
51
- ctx = canvas.getContext("2d")!;
52
- let f = "";
53
- if (variant & VARIANT_ITALIC) {
54
- f += "italic ";
55
- }
56
- if (variant & VARIANT_BOLD) {
57
- f += "bold ";
58
- }
59
- f += fontString;
60
- ctx.font = f;
61
- variantCtx[variant] = ctx;
62
- return ctx;
63
- }
64
-
65
- function resetVariantContexts(): void {
66
- for (let i = 0; i < variantCtx.length; i++) {
67
- variantCtx[i] = null;
68
- }
69
- }
70
-
71
- function measureChar(ch: string, bold: boolean, italic: boolean): number {
72
- if (!bold && !italic && ch.length === 1) {
73
- const cp = ch.charCodeAt(0);
74
- if (cp < WIDTH_FLAT_SIZE) {
75
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked above
76
- const cached = widthFlat[cp]!;
77
- if (cached !== WIDTH_FLAT_UNSET) {
78
- return cached;
79
- }
80
- const w = variantContext(VARIANT_REGULAR).measureText(ch).width;
81
- widthFlat[cp] = w;
82
- return w;
83
- }
84
- }
85
- const key = (bold ? "B" : "") + (italic ? "I" : "") + ch;
86
- const cached = widthMap.get(key);
87
- if (cached !== undefined) {
88
- return cached;
89
- }
90
- let variant = 0;
91
- if (bold) {
92
- variant |= VARIANT_BOLD;
93
- }
94
- if (italic) {
95
- variant |= VARIANT_ITALIC;
96
- }
97
- const w = variantContext(variant).measureText(ch).width;
98
- if (widthMap.size >= WIDTH_MAP_MAX) {
99
- widthMap.clear();
100
- }
101
- widthMap.set(key, w);
102
- return w;
103
- }
104
28
 
105
- function measureCellWidth(): number {
106
- // Measure using a span appended to termWrap (which already has the
107
- // font applied via CSS). This ensures the web font is used if loaded.
108
- const span = document.createElement("span");
109
- span.style.visibility = "hidden";
110
- span.style.position = "absolute";
111
- span.style.whiteSpace = "pre";
112
- span.textContent = "MMMMMMMMMM";
113
- termWrap.appendChild(span);
114
- const width = span.getBoundingClientRect().width / 10;
115
- termWrap.removeChild(span);
116
- return width;
117
- }
29
+ const MAX_ROWS_PER_FRAME = 300;
30
+ const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
31
+ const MIN_COLS = 20;
32
+ const MIN_ROWS = 5;
118
33
 
119
- // --- State ---
120
- let output: HTMLElement;
121
- let termWrap: HTMLElement;
34
+ const RESTORE_SETTLE_MS = 250;
35
+ const RESTORE_MAX_MS = 30000;
36
+ // A position within this many px of what we last wrote is still ours: a
37
+ // fractional-layout or subpixel-DPR readback is not a user gesture.
38
+ const RESTORE_OWN_WRITE_EPSILON_PX = 1;
122
39
 
123
- // The store the renderer reflects. Module-private by default (a consumer that
124
- // never calls bind() gets one implicit store, the original single-terminal
125
- // behavior). The tabs feature keeps one LineStore per tab and calls bind() on
126
- // switch to point the renderer at the active tab's cache (design section 6).
127
- let store = new LineStore();
128
- // abs index -> its row element. The DOM children of `output` are these
129
- // elements, kept in ascending data-abs order.
130
- const rowEls = new Map<number, HTMLDivElement>();
40
+ const CURSOR_BLINK_MS = 530;
41
+ // While the application hides the cursor (DECTCM — e.g. a full-screen TUI
42
+ // that paints its own cursor cell, like an agent front-end), the fast toggle
43
+ // would only restyle a display:none overlay, so the interval downshifts to
44
+ // this slow re-check: still polling for the cursor's return (a self-healing
45
+ // backstop even if a transition frame were somehow missed — the flush hook is
46
+ // the primary, instant restart), at a fraction of the 530ms blink's wakeups.
47
+ // For agent consumers the hidden-cursor state is the session's steady state.
48
+ const CURSOR_RECHECK_MS = 4000;
49
+ type BlinkMode = "off" | "idle" | "fast";
131
50
 
132
- // The "earlier output trimmed" marker (a non-data-abs first child of output,
133
- // shown when the store reports history older than it holds was trimmed). Kept
134
- // as a module ref so it is reused rather than recreated each flush.
135
- let trimMarkerEl: HTMLDivElement | null = null;
51
+ /** The permanent label: nothing below the oldest held index can be recovered. */
52
+ const TRIM_LABEL_GONE = "earlier output trimmed";
53
+ /** The recoverable label: history above exists and a fetch can bring it back. */
54
+ const TRIM_LABEL_PENDING = "earlier output not loaded";
136
55
 
137
56
  /**
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).
57
+ * A saved reading position in the only terms that survive the content growing
58
+ * underneath it. Opaque to the consumer: read one out of the renderer and hand
59
+ * the same value back to restore it. A memory is only meaningful to the store
60
+ * it was taken from, since `abs` names a line in THAT index space, and a
61
+ * restore is re-asserted until the named row has been built.
149
62
  */
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
-
156
- // Rows awaiting a DOM (re)build, processed in budgeted batches across frames.
157
- // A session restore (kiro-cli's /chat), a post-resize transcript reprint, or a
158
- // `cat bigfile` dumps thousands of lines in one wire frame; building them all
159
- // in a single rAF janks or, on a constrained device, hangs the tab. The store
160
- // still ingests the whole burst at once (it is cheap, pure data); the renderer
161
- // drains this queue at most MAX_ROWS_PER_FRAME per frame and reschedules until
162
- // it is empty, so each frame stays short. The drain is VIEWPORT-FIRST each
163
- // frame (see flushRenderInner): live-window rows build before any scrollback
164
- // backlog, and scrollback drains newest->oldest so the backlog fills upward,
165
- // above the bottom-pinned viewport — offscreen — instead of visibly streaming
166
- // through it. The cursor row is always built regardless of the budget so the
167
- // caret never lags.
168
- const renderQueue = new Set<number>();
169
- const MAX_ROWS_PER_FRAME = 300;
170
-
171
- // Cursor state, refreshed from the store window on each flush. The caret is
172
- // painted by a dedicated overlay element (see positionCursorOverlay), NOT by
173
- // restyling the span at the cursor cell: rows are pure content, so cursor
174
- // motion never rewrites row DOM — which preserves any native selection in the
175
- // old/new cursor rows and deletes the per-keystroke row rebuild entirely
176
- // (judgement finding: changed-row replaceChildren collapsed a row-local
177
- // selection).
178
- let cursorAbs = -1; // absolute index of the row the cursor is on
179
- let cursorCol = 0;
180
- let cursorHidden = false;
181
- let cursorStyleVal = 0; // 0-6: DECSCUSR
182
-
183
- function cursorClassName(): string {
184
- // DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
185
- // 4=steady underline, 5=blinking bar, 6=steady bar
186
- if (cursorStyleVal === 3 || cursorStyleVal === 4) {
187
- return "term-cursor-underline";
188
- }
189
- if (cursorStyleVal === 5 || cursorStyleVal === 6) {
190
- return "term-cursor-bar";
191
- }
192
- return "term-cursor";
193
- }
194
- let cellWidth = 8;
195
- let cellHeight = 17;
196
- let defaultSpacing = 0;
197
- let onCursorMove: (() => void) | null = null;
198
- let pendingFrame: number | undefined;
199
-
200
- // termWrap's padding, cached: the overlay positioners (caret, predicted
201
- // cursor, getCursorPx for the IME view) need it EVERY flush, and a live
202
- // getComputedStyle after the flush's DOM writes forces a style recalc —
203
- // measured at real cost in the render bench (the caret positioner alone was
204
- // ~11% of flush CPU under full-screen churn). Lazily read once per attach and
205
- // refreshed by updateFontMetrics (the same staleness contract as
206
- // cellWidth/cellHeight: a consumer that restyles the terminal calls
207
- // updateFontMetrics, which re-reads both).
208
- let padLeft = 0;
209
- let padTop = 0;
210
- let padValid = false;
211
-
212
- function termPadding(): { padL: number; padT: number } {
213
- if (!padValid) {
214
- const cs = window.getComputedStyle(termWrap);
215
- padLeft = parseFloat(cs.paddingLeft);
216
- padTop = parseFloat(cs.paddingTop);
217
- padValid = true;
218
- }
219
- return { padL: padLeft, padT: padTop };
63
+ export interface ViewMemory {
64
+ /** Absolute line index of the content row at the viewport top. */
65
+ abs: number;
66
+ /** That row's on-screen position: offsetTop minus the scroll offset. */
67
+ screenTop: number;
68
+ /** Whether auto-follow was engaged. */
69
+ following: boolean;
220
70
  }
221
71
 
222
- // Bounded error-path reschedule (l-f28 / d-u4-1). The drain loop deletes a
223
- // queued row only AFTER upsertRow succeeds, so a row whose build throws stays
224
- // queued. flushRender's catch reschedules to finish a partial drain and to
225
- // retry a transient throw (a font/measureText race), but a row that throws
226
- // deterministically would otherwise turn catch -> rAF -> throw into a ~60fps
227
- // busy loop (CPU/battery burn + per-frame console spam) that never stops, even
228
- // on an idle session. `flushDrainedThisPass` records forward progress (queued
229
- // rows actually built this pass; reset at each flushRenderInner entry, and
230
- // visible to the catch because a mid-drain throw leaves it at the count so
231
- // far). `renderNoProgressStreak` counts consecutive passes that threw with zero
232
- // progress; once it passes the cap the catch stops rescheduling and lets the
233
- // next inbound frame retry (the pre-l-f28 behavior for a stuck row).
234
- let flushDrainedThisPass = 0;
235
- let renderNoProgressStreak = 0;
236
- const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
237
- // The stall canary's latch (see the reschedule rule near flushRender). Reset by
238
- // init, and by any flush that ends in a healthy state, so it suppresses a repeat
239
- // of ONE stall rather than every stall for the life of the process.
240
- let unnamedStallReported = false;
241
- // Whether the CURRENT flush performed a full reset (server restart / epoch
242
- // change). Read by restoreReadAnchor after the flush: a re-anchor across a
243
- // reset would match unrelated content from the new index space (R2 review).
244
- let fullResetThisPass = false;
245
- // The ED3 base observed since the last flush, or -1. Set by handleScreen from
246
- // the frame itself; consumed (and reset) by the pass that applies it. Renderer-
247
- // local on purpose: the renderer is the caller of every path that discards a
248
- // REGION rather than trimming the cap, so nothing in the store's change set has
249
- // to grow a field for it (docs/scroll-position-fidelity.md §5).
250
- let discardedBelowPending = -1;
251
- let discardedBelowThisPass = -1;
252
- // Whether THIS pass removed rows from the DOM. Only a pass that did can have
253
- // caused a clamp, and announcing one that did not is unsound on Safari, which
254
- // updates scrollTop PAST the maximum during an overscroll bounce: the settle back
255
- // from a rubber-band is a downward move in value with no content change at all,
256
- // and arming for it would hand the user's own gesture a pass-through.
257
- let removedRowsThisPass = false;
258
-
259
- /**
260
- * Initialize the renderer by attaching it to a pair of DOM elements: the
261
- * scrollable terminal wrapper and the inner output container that receives
262
- * row elements. Must be called once before any handleScreen/handleScroll call.
263
- *
264
- * @param opts.output Inner element that holds row children.
265
- * @param opts.termWrap Outer scroll container.
266
- * @param opts.onCursorMove Optional callback invoked when the cursor moves.
267
- * @param opts.maxLines Optional retained-line cap for the implicit store
268
- * (default 5000) — a HISTORY budget floored at the
269
- * live screen (the store never evicts the current
270
- * window, so a cap at or below the terminal height
271
- * keeps the full screen with no scrollback). Governs
272
- * how many absolute-index lines — and therefore DOM
273
- * rows — the client keeps; a memory-constrained
274
- * consumer (a phone) passes a smaller budget. A
275
- * consumer that manages stores itself (a tabbed
276
- * shell) constructs each `LineStore` with its own cap
277
- * and `bind()`s it AFTER init; init always installs a
278
- * fresh implicit store, so call it before any bind.
279
- * For the batched eviction to help, choose a cap
280
- * comfortably above the terminal height (near or
281
- * below it, eviction degrades to per-line — the
282
- * screen stays correct, but the churn returns).
283
- * Non-positive or non-integer values warn and are
284
- * ignored (the default applies).
285
- */
286
- export function init(opts: {
72
+ /** What `createRenderer` binds to, reads and calls back. */
73
+ export interface RendererOptions {
74
+ /** Inner element that receives the row children. */
287
75
  output: HTMLElement;
76
+ /** Outer scroll container; the overlays, `--char-w` and the blink class live on it. */
288
77
  termWrap: HTMLElement;
78
+ scroll: ScrollController;
79
+ modes: Pick<ModeState, "isReverseVideo">;
80
+ /** Invoked when the cursor moves. */
289
81
  onCursorMove?: () => void;
82
+ /**
83
+ * Retained-line cap for the implicit store (default 5000): a HISTORY budget
84
+ * floored at the live screen, which is never evicted. A non-positive or
85
+ * non-integer value warns and the default applies.
86
+ */
290
87
  maxLines?: number;
291
- /** Ask the transport for a page of history. Wired by the consumer to
292
- * `connection.requestHistory`; absent means paging is not available and the
293
- * controller stays dormant (docs/paged-scrollback.md §5.4). */
88
+ /**
89
+ * Ask the transport for a page of history (`connection.requestHistory`).
90
+ * Absent means paging is not available and the controller stays dormant.
91
+ */
294
92
  requestHistory?: (fromAbs: number, maxLines: number) => boolean;
295
- /** The transport's current adaptive request budget
296
- * (`connection.historyBudget`). Read at FIRE time, and used for both the
297
- * request's length and its anchor: a shrunken length with a full-size anchor
298
- * serves a range that ends far from the reader. */
93
+ /**
94
+ * The transport's current adaptive request budget (`connection.historyBudget`),
95
+ * read at fire time for both the request's length and its anchor.
96
+ */
299
97
  historyBudget?: () => number;
300
- }): void {
301
- output = opts.output;
302
- termWrap = opts.termWrap;
303
- onCursorMove = opts.onCursorMove ?? null;
304
- requestHistoryFn = opts.requestHistory ?? null;
305
- historyBudgetFn = opts.historyBudget ?? null;
306
- // New attach target: the cached padding belongs to the previous termWrap.
307
- padValid = false;
308
- // Fresh attach: ALWAYS install a fresh implicit store, so no cap — a
309
- // consumer-configured one, or a bound per-tab store's — leaks from a
310
- // previous attachment into this one (init is the attachment boundary;
311
- // vitest's non-isolated module reuse relies on the same reset).
312
- const validCap =
313
- opts.maxLines !== undefined && Number.isInteger(opts.maxLines) && opts.maxLines > 0;
314
- if (opts.maxLines !== undefined && !validCap) {
315
- console.warn(`vterm: ignoring invalid maxLines ${String(opts.maxLines)}`);
316
- }
317
- // An absent OR invalid cap is forwarded as absent, which is how the store
318
- // spells "engine's choice"; a value here would be indistinguishable from a
319
- // consumer decision and would pin the tail at the compatibility cap forever.
320
- store = new LineStore(validCap ? opts.maxLines : undefined);
321
- rowEls.clear();
322
- renderQueue.clear();
323
- trimMarkerEl = null;
324
- for (const el of gapMarkerEls.values()) {
325
- el.remove();
326
- }
327
- gapMarkerEls.clear();
328
- // Drop the predicted-cursor + caret overlays (re-created lazily against the
329
- // new termWrap) so re-init starts clean.
330
- if (predCursorEl) {
331
- predCursorEl.remove();
332
- predCursorEl = null;
333
- }
334
- if (cursorEl) {
335
- cursorEl.remove();
336
- cursorEl = null;
337
- }
338
- output.replaceChildren();
339
- cursorAbs = -1;
340
- if (pendingFrame !== undefined) {
341
- cancelAnimationFrame(pendingFrame);
342
- pendingFrame = undefined;
343
- }
344
- flushDrainedThisPass = 0;
345
- renderNoProgressStreak = 0;
346
- // The stall canary latches so it cannot spam, and init is the attachment
347
- // boundary, so the latch resets here with every other piece of pass state. A
348
- // latch that survived would make the canary a once-per-PROCESS report, silent
349
- // for every later attachment and for every genuinely new stall.
350
- unnamedStallReported = false;
351
- // Attachment boundary: a view restore armed against the PREVIOUS surface must
352
- // not survive it, or its timer outlives teardown and writes scrollTop on a
353
- // detached element. Same discipline scroll.init applies to its own arms.
354
- bindGen++;
355
- clearPendingRestore();
356
- discardedBelowPending = -1;
357
- discardedBelowThisPass = -1;
358
- lastInboundMs = 0;
359
- syncCursorBlink();
360
- }
361
-
362
- /**
363
- * Reset internal screen state so the next frame performs a full repaint.
364
- * With the store model this is a full reset (used on server restart): the
365
- * store clears and the next flush wipes and rebuilds the DOM.
366
- */
367
- export function resetScreen(): void {
368
- store.reset();
369
- scheduleFlush();
370
98
  }
371
99
 
372
100
  /**
373
- * Clear all rows (history + window). Used on server restart alongside
374
- * resetScreen; both reset the store, so this is equivalent.
101
+ * The DOM renderer: it owns a `LineStore` of absolute-index lines and reflects
102
+ * it to one `div.term-row` per line inside `termWrap`, so there is no
103
+ * live-zone/scrollback split to reconcile. Usable from construction with
104
+ * fallback cell metrics; `updateFontMetrics` measures the real font.
375
105
  */
376
- export function resetScrollback(): void {
377
- store.reset();
378
- scheduleFlush();
106
+ export interface Renderer {
107
+ /** Reset internal screen state so the next frame performs a full repaint. */
108
+ resetScreen(): void;
109
+ /** Clear all rows (history + window); equivalent to `resetScreen`. */
110
+ resetScrollback(): void;
111
+ /**
112
+ * Bind the renderer to a different store and rebuild the surface from it.
113
+ * `opts.view` is the per-view memory `captureViewMemory()` returned when the
114
+ * consumer last left this store; passing `opts` at all means the caller owns
115
+ * the view, and a null view is "no memory", whose follow state is the tail.
116
+ */
117
+ bind(next: LineStore, opts?: { view?: ViewMemory | null }): void;
118
+ /** The store the renderer is currently bound to; a fresh detached store once disposed. */
119
+ boundStore(): LineStore;
120
+ /** Wipe the DOM and rebuild it from the current store, viewport-first and budgeted. */
121
+ rebuild(): void;
122
+ /**
123
+ * Highest absolute line index the client holds, or -1 if empty. NOT the resume
124
+ * `haveThrough`; that is `getReplayBoundary`.
125
+ */
126
+ getHighestIndex(): number;
127
+ /**
128
+ * The resume `haveThrough`: the highest index this client will not ask the
129
+ * server to re-send. Differs from `getHighestIndex` by the provisional rows
130
+ * the application drew and the server has not committed.
131
+ */
132
+ getReplayBoundary(): number;
133
+ /** Record the server's retained-history bounds from a resumeAck. */
134
+ noteResumeBounds(committed: number, oldest: number): void;
135
+ /**
136
+ * Apply a correlated history page; when the reply proved history is gone,
137
+ * raise the paging floor so nothing below it is requested again.
138
+ */
139
+ handleHistoryReply(msg: ScrollMessage, raiseFloorTo: number | null): void;
140
+ /** Run the store's single resume-ack transition, supplying the viewport the store cannot see. */
141
+ applyResumeTransition(ack: {
142
+ epochChanged: boolean;
143
+ committed: number | null;
144
+ serverOldest: number | null;
145
+ paging: boolean;
146
+ sentHaveThrough: number;
147
+ sentReplayMax: number | null;
148
+ }): void;
149
+ /** Record the window of a history request going out. */
150
+ noteSolicited(fromAbs: number, end: number): void;
151
+ /** Release the in-flight window (reply applied, timed out, socket gone). */
152
+ clearSolicited(): void;
153
+ /** Drop the browse cache; the consumer owns the TTL and the visibility state. */
154
+ dropBrowseCache(pageVisible: boolean): void;
155
+ /** When the browse cache was last created or refreshed, for a consumer's TTL. */
156
+ lastBrowseActivityMs(): number;
157
+ /** Lines currently held as disposable browse cache. */
158
+ browseCacheSize(): number;
159
+ /** The resume replay bound to send: residency minus the window about to arrive anyway. */
160
+ replayMaxForResume(): number;
161
+ /**
162
+ * Rows queued for a DOM (re)build but not yet built. A render-side measure
163
+ * that reaches zero between replay chunks, so not a restore-complete signal.
164
+ */
165
+ pendingRowCount(): number;
166
+ /** Apply a `ScreenMessage` to the store and schedule a flush. */
167
+ handleScreen(msg: ScreenMessage): void;
168
+ /** Apply a `ScrollMessage` to the store and schedule a flush. */
169
+ handleScroll(msg: ScrollMessage): void;
170
+ /**
171
+ * The renderer's half of a scroll-position event: re-evaluate the paging
172
+ * trigger, then resume a drain that stopped with rows still queued.
173
+ */
174
+ handleScrollPosition(): void;
175
+ /**
176
+ * Capture the current view as per-view scroll memory a consumer can hand back
177
+ * to `bind`; null when there are no content rows or the alternate screen is active.
178
+ */
179
+ captureViewMemory(): ViewMemory | null;
180
+ /** The absolute index of an ARMED view restore, or null. */
181
+ pendingRestoreAbs(): number | null;
182
+ /** The fetch trigger: decide whether to ask for a page, and for which range. Cheap to over-call. */
183
+ maybeFetchHistory(): void;
184
+ /**
185
+ * Re-measure the cell width/height from the rendered DOM. Call after any font
186
+ * or zoom change so subsequent `computeSize()` and `getCursorPx()` use fresh
187
+ * metrics.
188
+ */
189
+ updateFontMetrics(): void;
190
+ /** The integer (cols, rows) the terminal element can fit, clamped to 20x5; `{0,0}` once disposed. */
191
+ computeSize(): { cols: number; rows: number };
192
+ /**
193
+ * The grid currently ON SCREEN (rows from the last frame, 0 before the first),
194
+ * for `MouseInputHandler.gridSize`; `{0,0}` once disposed.
195
+ */
196
+ gridSize(): { cols: number; rows: number };
197
+ /** The cursor's pixel position in `termWrap`'s coordinate space plus the cell height. */
198
+ getCursorPx(): { left: number; top: number; cellH: number };
199
+ /** The measured pixel size of one cell; the 8x17 fallback before `updateFontMetrics`. */
200
+ cellSize(): { width: number; height: number };
201
+ /** Show or hide the predicted-cursor overlay at window-relative (row, col). */
202
+ setPredictedCursor(row: number, col: number, active: boolean): void;
203
+ /** Apply or remove the DECSCNM reverse-video class on `termWrap`. */
204
+ updateReverseVideo(): void;
205
+ /**
206
+ * Release every timer, listener, element and callback the renderer holds and
207
+ * clear its DOM; every method stays callable and answers its empty value.
208
+ */
209
+ dispose(): void;
379
210
  }
380
211
 
381
212
  /**
382
- * Bind the renderer to a different store and rebuild the surface from it. The
383
- * tabs feature calls this on every switch to point the one renderer at the
384
- * active tab's cached LineStore (design sections 5, 6, 8). The DOM is wiped and
385
- * repainted viewport-first from the new store; this is local, so the last-known
386
- * screen paints without a network round-trip.
387
- *
388
- * `opts.view` is the per-view scroll memory `captureViewMemory()` returned when
389
- * the consumer last left this store, and passing it makes the swap ATOMIC. Both
390
- * halves matter and they land at different times on purpose:
391
- *
392
- * - the FOLLOW half is adopted SYNCHRONOUSLY, before the wipe, so the very
393
- * first flush's bottom pin is gated on the INCOMING view's state. The
394
- * follow flag is global (one per kernel), so without this the first frame
395
- * after a switch is gated on the state of the tab the user just LEFT —
396
- * binding a following tab while holding in another rendered the cached
397
- * screen above the viewport and left a black gap until a touch re-engaged
398
- * follow. It is applied through restoreView at the CURRENT offset, which
399
- * sets the flag without moving the position.
400
- * - the POSITION half is armed and re-asserted across the rebuild's frames
401
- * (see applyPendingRestore), because the row it names is usually not built
402
- * yet. A view left FOLLOWING arms nothing: the per-flush bottom pin already
403
- * is the correct answer, which keeps the common path free of any restore.
213
+ * Creates a renderer over `opts.output` inside `opts.termWrap`. Throws, holding
214
+ * nothing, when a listener or timer cannot be acquired.
404
215
  */
405
- export function bind(next: LineStore, opts?: { view?: ViewMemory | null }): void {
406
- const view = opts?.view ?? null;
407
- // Passing `opts` at all is a statement that this caller owns the view. A NULL
408
- // view is therefore not "no opinion" but "no memory" — a tab never visited, or
409
- // one left on the alternate screen, where there is no absolute index worth
410
- // remembering — and the right follow state for no memory is the tail. Without
411
- // this, those tabs inherited the OUTGOING tab's follow flag, which is the
412
- // stale-global-flag bug this seam exists to close, just narrowed to the tabs
413
- // that have nothing saved. A caller that passes no `opts` keeps the pre-3.7
414
- // behavior and its follow state is left alone.
415
- if (opts !== undefined) {
416
- scroll.restoreView({
417
- top: scroll.currentScrollTop(),
418
- following: view === null ? true : view.following,
419
- });
216
+ export function createRenderer(opts: RendererOptions): Renderer {
217
+ // Invariant: `scroll` and `modes` are read by member call at each call site, never as captured methods.
218
+ const { scroll, modes } = opts;
219
+ const validCap =
220
+ opts.maxLines !== undefined && Number.isInteger(opts.maxLines) && opts.maxLines > 0;
221
+ if (opts.maxLines !== undefined && !validCap) {
222
+ console.warn(`vterm: ignoring invalid maxLines ${String(opts.maxLines)}`);
420
223
  }
421
- // The OUTGOING store cannot have a request in flight: the socket that issued it
422
- // is being switched away with it. Closing its solicited window here is what
423
- // stops it stranding open, because the transport's own `clearSolicited` lands on
424
- // whichever store is bound WHEN IT FIRES — and the order of `bind` against the
425
- // consumer's `setSession`/reconnect teardown is the consumer's choice, which
426
- // neither module constrains. Bound after the switch, that clear closes the
427
- // INCOMING store's (empty) window and leaves the outgoing one permanently open:
428
- // a standing exemption from apply-line guard 2 for that index range, with no
429
- // socket and no timer left to close it, so any later frame in that range can
430
- // resurrect an evicted row and be classified as browse cache. The sibling
431
- // pendingRestore arm already had a generation guard for the same hazard; this
432
- // one had none.
433
- store.clearSolicited();
434
- store = next;
435
- // A new bind invalidates any restore armed for the previous one: cancel, then
436
- // arm, one slot, so a second switch mid-drain cannot land the first tab's
437
- // anchor into this store.
438
- bindGen++;
439
- clearPendingRestore();
440
- rebuild();
441
- if (view !== null && !view.following) {
442
- pendingRestore = {
443
- view,
444
- gen: bindGen,
445
- lastWrote: scroll.currentScrollTop(),
446
- deadline: Date.now() + RESTORE_MAX_MS,
447
- };
224
+ let disposed = false;
225
+ const widthFlat = new Float32Array(WIDTH_FLAT_SIZE).fill(WIDTH_FLAT_UNSET);
226
+ const widthMap = new Map<string, number>();
227
+ const variantCtx: (CanvasRenderingContext2D | null)[] = [null, null, null, null];
228
+ let fontString = "";
229
+
230
+ function variantContext(variant: number): CanvasRenderingContext2D {
231
+ let ctx = variantCtx[variant];
232
+ if (ctx) {
233
+ return ctx;
234
+ }
235
+ const canvas = document.createElement("canvas");
236
+ canvas.width = 1;
237
+ canvas.height = 1;
238
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 2d context always available on fresh canvas
239
+ ctx = canvas.getContext("2d")!;
240
+ let f = "";
241
+ if (variant & VARIANT_ITALIC) {
242
+ f += "italic ";
243
+ }
244
+ if (variant & VARIANT_BOLD) {
245
+ f += "bold ";
246
+ }
247
+ f += fontString;
248
+ ctx.font = f;
249
+ variantCtx[variant] = ctx;
250
+ return ctx;
448
251
  }
449
- }
450
252
 
451
- /**
452
- * The store the renderer is currently bound to. Exposed so the shell can feed
453
- * decoded frames into the active tab's store and read its resume bounds.
454
- */
455
- export function boundStore(): LineStore {
456
- return store;
457
- }
253
+ function resetVariantContexts(): void {
254
+ for (let i = 0; i < variantCtx.length; i++) {
255
+ variantCtx[i] = null;
256
+ }
257
+ }
458
258
 
459
- /**
460
- * Queue every retained line for building, viewport-first: the live window rows
461
- * (what the user sees) in ascending order first, then scrollback newest->oldest
462
- * so rows adjacent to the viewport fill before deep history. Iterates the
463
- * retained key set (forEachLine), NOT the integer range [oldest, highest], so a
464
- * sparse store (a frame whose base jumped far from a retained index) never
465
- * freezes the drain. Shared by the two wipe-and-rebuild-from-store paths
466
- * (rebuild() and the alt-exit branch in flushRenderInner) so both order rows
467
- * identically; the per-frame budget then spreads a large backlog across frames
468
- * without janking.
469
- */
470
- function queueRowsViewportFirst(): void {
471
- const winBase = store.getWindow().base;
472
- const inWindow: number[] = [];
473
- const belowWindow: number[] = [];
474
- store.forEachLine((abs) => {
475
- if (abs >= winBase) {
476
- inWindow.push(abs);
477
- } else {
478
- belowWindow.push(abs);
259
+ function measureChar(ch: string, bold: boolean, italic: boolean): number {
260
+ if (!bold && !italic && ch.length === 1) {
261
+ const cp = ch.charCodeAt(0);
262
+ if (cp < WIDTH_FLAT_SIZE) {
263
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked above
264
+ const cached = widthFlat[cp]!;
265
+ if (cached !== WIDTH_FLAT_UNSET) {
266
+ return cached;
267
+ }
268
+ const w = variantContext(VARIANT_REGULAR).measureText(ch).width;
269
+ widthFlat[cp] = w;
270
+ return w;
271
+ }
272
+ }
273
+ const key = (bold ? "B" : "") + (italic ? "I" : "") + ch;
274
+ const cached = widthMap.get(key);
275
+ if (cached !== undefined) {
276
+ return cached;
277
+ }
278
+ let variant = 0;
279
+ if (bold) {
280
+ variant |= VARIANT_BOLD;
281
+ }
282
+ if (italic) {
283
+ variant |= VARIANT_ITALIC;
479
284
  }
480
- });
481
- for (const abs of inWindow) {
482
- renderQueue.add(abs);
285
+ const w = variantContext(variant).measureText(ch).width;
286
+ if (widthMap.size >= WIDTH_MAP_MAX) {
287
+ widthMap.clear();
288
+ }
289
+ widthMap.set(key, w);
290
+ return w;
483
291
  }
484
- for (let i = belowWindow.length - 1; i >= 0; i--) {
485
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index in range
486
- renderQueue.add(belowWindow[i]!);
292
+
293
+ // Measured against termWrap so the web font applied by CSS is what is measured.
294
+ function measureCellWidth(el: HTMLElement): number {
295
+ const span = document.createElement("span");
296
+ span.style.visibility = "hidden";
297
+ span.style.position = "absolute";
298
+ span.style.whiteSpace = "pre";
299
+ span.textContent = "MMMMMMMMMM";
300
+ el.appendChild(span);
301
+ const width = span.getBoundingClientRect().width / 10;
302
+ el.removeChild(span);
303
+ return width;
487
304
  }
488
- }
489
305
 
490
- /**
491
- * Wipe the DOM and rebuild it from the current store, viewport-first and
492
- * budgeted. The live window (what the user sees) builds first, then scrollback
493
- * from newest to oldest so rows adjacent to the viewport fill in before deep
494
- * history; the existing per-frame budget spreads a large backlog across frames
495
- * so the switch never janks. Used by bind(); also safe to call directly to
496
- * force a full repaint of the current store.
497
- *
498
- * The wipe covers content space in full: the rows AND the four overlays anchored
499
- * into it (see collapseContentSpaceOverlays). Leaving the overlays behind left
500
- * the container a measured 81081px of scroll range over zero rows of content,
501
- * which is a black pane the bottom pin refuses to correct.
502
- */
503
- export function rebuild(): void {
504
- // The wipe is the largest content shrink this module performs, and it happens
505
- // OUTSIDE a flush (bind calls it synchronously), so the flush's own
506
- // announcement cannot cover it. Unannounced, the clamp it causes falls back to
507
- // the epsilon — exactly the inference §4 exists to stop relying on.
508
- const scrollTopBeforeWipe = scroll.currentScrollTop();
509
- output.replaceChildren();
510
- rowEls.clear();
511
- renderQueue.clear();
512
- trimMarkerEl = null;
513
- for (const el of gapMarkerEls.values()) {
514
- el.remove();
515
- }
516
- gapMarkerEls.clear();
517
- cursorAbs = -1;
518
- altRendered = false;
519
- altPrevRows = [];
520
- // Fresh surface: a stale give-up streak from a prior store must not deny the
521
- // rebuilt surface its full transient-retry budget.
522
- renderNoProgressStreak = 0;
523
- // The rows are not the only thing in content space. Collapse the overlays
524
- // with them, BEFORE the offset below is read.
525
- collapseContentSpaceOverlays();
526
- // Alt screen paints from the ephemeral grid in the flush, so it needs no
527
- // absolute-index queueing here.
528
- if (!store.isAlt()) {
529
- queueRowsViewportFirst();
530
- }
531
- scroll.noteContentShrink(scrollTopBeforeWipe);
532
- scheduleFlush();
533
- }
306
+ // --- State --- (the nullable fields are null once disposed)
307
+ let output: HTMLElement | null = opts.output;
308
+ let termWrap: HTMLElement | null = opts.termWrap;
534
309
 
535
- /**
536
- * Drop every CONTENT-SPACE overlay's hold on the scroll container, as part of a
537
- * wipe. Four elements sit INSIDE the container carrying a `top` in content
538
- * coordinates — the caret, the predicted cursor, the IME view and the consumer's
539
- * hidden textarea — so each one holds the container's scrollable overflow at the
540
- * offset it was last placed at. `output.replaceChildren()` removes the rows and
541
- * leaves all four.
542
- *
543
- * Measured on a 4769-row tab in Chromium: after the wipe the scroller had ZERO
544
- * rows of content and an 81081px scroll range, held by the caret and the
545
- * textarea still sitting at 81064px, with the viewport parked at 80281px over
546
- * nothing. Two consequences, and the second is why this is not cosmetic:
547
- *
548
- * - The reader sees that empty region, which paints the terminal's background:
549
- * a black pane, and then a partial row clipped to the top edge once the
550
- * drain's growing block reaches the parked offset.
551
- * - `stickToBottom` is DISARMED. It measures `distanceFromBottom()` against
552
- * the phantom height, reads 0 — already at the bottom — and pins nothing, so
553
- * the invariant that would otherwise rescue the view refuses to act for
554
- * exactly as long as the phantom height survives.
555
- *
556
- * The renderer heals all four at the END of the first flush
557
- * (`positionCursorOverlay` plus `onCursorMove`), so the window is normally one
558
- * frame and invisible. It stops being one frame whenever that tail does not
559
- * run on time: a long frame (the wipe tears down thousands of rows), a throw
560
- * mid-drain (the catch skips the tail and the bounded give-up can stop
561
- * rescheduling), or a full reset arriving with the switch's reconnect. The
562
- * reported symptom is a scroll tick clearing it, which fits: a scroll re-enters
563
- * the built block, and the block sits at the TOP of the phantom range.
564
- *
565
- * Collapsing here also makes the wipe's clamp SYNCHRONOUS, which two callers
566
- * need. `noteContentShrink` arms only when it observes the position actually
567
- * move, so a deferred clamp left the wipe — the largest shrink this module
568
- * performs — with nothing to announce. And `bind` records
569
- * `pendingRestore.lastWrote` from the same offset, so a deferred clamp
570
- * (measured: 80281 -> 4346) then read as a 76000px foreign write and threw away
571
- * the reading position the switch was restoring.
572
- */
573
- function collapseContentSpaceOverlays(): void {
574
- // `cursorAbs` is -1 by now, which is the state positionCursorOverlay already
575
- // hides for; go through it rather than restating the class name here.
576
- positionCursorOverlay(undefined);
577
- // The outgoing session's prediction cannot describe the incoming screen
578
- // either. The consumer re-pushes it from the `onCursorMove` below, which is
579
- // its call to make and cannot re-inflate anything: `rowEls` is empty, so
580
- // setPredictedCursor's own fallback places it at the content origin.
581
- predCursorEl?.classList.remove("visible");
582
- // The IME view and the hidden textarea are the CONSUMER's, reachable only
583
- // through the cursor seam — and after a wipe the cursor genuinely has no row,
584
- // so the seam is accurate rather than borrowed. `getCursorPx` reports the
585
- // content origin while `rowEls` is empty, so the handler moves them there.
586
- onCursorMove?.();
587
- }
310
+ // An absent OR invalid cap is forwarded as absent, which is how the store
311
+ // spells "engine's choice"; a value here would be indistinguishable from a
312
+ // consumer decision and would pin the tail at the compatibility cap forever.
313
+ let store: LineStore | null = new LineStore(validCap ? opts.maxLines : undefined);
314
+ // abs index -> its row element, kept in ascending data-abs order in `output`.
315
+ const rowEls = new Map<number, HTMLDivElement>();
316
+
317
+ // The "earlier output trimmed" marker, a data-abs-less first child of output.
318
+ let trimMarkerEl: HTMLDivElement | null = null;
319
+
320
+ // Per-gap "earlier output not loaded" markers, keyed by the gap's LOW index.
321
+ // Each carries that index as `data-abs` so `insertRowInOrder` and the
322
+ // read-anchor binary searches keep their monotonic-`data-abs` invariant; an
323
+ // INTERIOR marker cannot be data-abs-less the way the top trim marker is. A
324
+ // marker is a PROJECTION of the store's gap geometry, re-derived whenever
325
+ // either edge moves and removed when the gap closes (docs/paged-scrollback.md §5.4).
326
+ const gapMarkerEls = new Map<number, HTMLDivElement>();
327
+
328
+ let requestHistoryFn: ((fromAbs: number, maxLines: number) => boolean) | null =
329
+ opts.requestHistory ?? null;
330
+ let historyBudgetFn: (() => number) | null = opts.historyBudget ?? null;
331
+
332
+ // Rows awaiting a DOM (re)build, drained at most MAX_ROWS_PER_FRAME per frame:
333
+ // a session restore or `cat bigfile` dumps thousands of lines in one frame,
334
+ // and building them in one rAF hangs a constrained device. The drain is
335
+ // VIEWPORT-FIRST (live-window rows, then scrollback newest->oldest so the
336
+ // backlog fills upward, offscreen above the pinned viewport), and the cursor
337
+ // row is always built so the caret never lags.
338
+ const renderQueue = new Set<number>();
339
+
340
+ // The caret is a dedicated overlay (positionCursorOverlay), NOT a restyled
341
+ // span: rows are pure content, so cursor motion never rewrites row DOM and a
342
+ // native selection in the old or new cursor row survives.
343
+ let cursorAbs = -1; // absolute index of the row the cursor is on
344
+ let cursorCol = 0;
345
+ let cursorHidden = false;
346
+ let cursorStyleVal = 0; // 0-6: DECSCUSR
347
+
348
+ function cursorClassName(): string {
349
+ // DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
350
+ // 4=steady underline, 5=blinking bar, 6=steady bar
351
+ if (cursorStyleVal === 3 || cursorStyleVal === 4) {
352
+ return "term-cursor-underline";
353
+ }
354
+ if (cursorStyleVal === 5 || cursorStyleVal === 6) {
355
+ return "term-cursor-bar";
356
+ }
357
+ return "term-cursor";
358
+ }
359
+ let cellWidth = 8;
360
+ let cellHeight = 17;
361
+ let defaultSpacing = 0;
362
+ let onCursorMove: (() => void) | null = opts.onCursorMove ?? null;
363
+ let pendingFrame: number | undefined;
364
+
365
+ // termWrap's padding, cached: the overlay positioners need it EVERY flush,
366
+ // and a live getComputedStyle after the flush's DOM writes forces a style
367
+ // recalc (the caret positioner alone measured ~11% of flush CPU). Refreshed
368
+ // by updateFontMetrics, the same staleness contract as cellWidth/cellHeight.
369
+ let padLeft = 0;
370
+ let padTop = 0;
371
+ let padValid = false;
372
+
373
+ function termPadding(): { padL: number; padT: number } {
374
+ if (!padValid && termWrap) {
375
+ const cs = window.getComputedStyle(termWrap);
376
+ padLeft = parseFloat(cs.paddingLeft);
377
+ padTop = parseFloat(cs.paddingTop);
378
+ padValid = true;
379
+ }
380
+ return { padL: padLeft, padT: padTop };
381
+ }
382
+
383
+ // Bounded error-path reschedule. A row whose build throws stays queued, and
384
+ // flushRender's catch reschedules to retry a transient throw (a measureText
385
+ // race); a deterministic throw would turn that into a ~60fps busy loop.
386
+ // `flushDrainedThisPass` records rows built this pass (visible to the catch
387
+ // mid-drain); once `renderNoProgressStreak` passes the cap the catch stops
388
+ // rescheduling and lets the next inbound frame retry.
389
+ let flushDrainedThisPass = 0;
390
+ let renderNoProgressStreak = 0;
391
+ // The stall canary's latch, reset by any healthy flush so it suppresses a
392
+ // repeat of ONE stall rather than every stall for the life of the instance.
393
+ let unnamedStallReported = false;
394
+ // Whether the CURRENT flush performed a full reset. Read by restoreReadAnchor:
395
+ // a re-anchor across a reset would match unrelated content from the new index space.
396
+ let fullResetThisPass = false;
397
+ // The ED3 base observed since the last flush, or -1; consumed by the pass that
398
+ // applies it. Renderer-local because the renderer calls every path that
399
+ // discards a REGION rather than trimming the cap (docs/scroll-position-fidelity.md §5).
400
+ let discardedBelowPending = -1;
401
+ let discardedBelowThisPass = -1;
402
+ // Whether THIS pass removed rows. Announcing a shrink for a pass that did not
403
+ // is unsound on Safari, which moves scrollTop PAST the maximum during an
404
+ // overscroll bounce: the settle back is a downward move with no content
405
+ // change, and arming for it would hand the user's own gesture a pass-through.
406
+ let removedRowsThisPass = false;
407
+
408
+ function resetScreen(): void {
409
+ if (!store) {
410
+ return;
411
+ }
412
+ store.reset();
413
+ scheduleFlush();
414
+ }
588
415
 
589
- /**
590
- * Highest absolute line index the client holds, or -1 if empty. Exposed so a
591
- * consumer can ask how far this store's content reaches; it is NOT the resume
592
- * `haveThrough` (that is getReplayBoundary below, and the difference is a bug
593
- * when the two are confused).
594
- */
595
- export function getHighestIndex(): number {
596
- return store.highestIndex();
597
- }
416
+ function resetScrollback(): void {
417
+ if (!store) {
418
+ return;
419
+ }
420
+ store.reset();
421
+ scheduleFlush();
422
+ }
598
423
 
599
- /**
600
- * The resume `haveThrough`: the highest index this client will not ask the
601
- * server to re-send. Wire this to Callbacks.getHaveThrough, never
602
- * getHighestIndex.
603
- *
604
- * The two differ by whatever the store holds provisionally — screen rows the
605
- * application drew and the server has not committed at those indices — and
606
- * claiming those on the wire is what leaves a stale copy of the last screen
607
- * parked in scrollback after a reattach. See LineStore.replayBoundary.
608
- */
609
- export function getReplayBoundary(): number {
610
- return store.replayBoundary();
611
- }
424
+ // The FOLLOW half of the view is adopted SYNCHRONOUSLY, before the wipe, so
425
+ // the first flush's bottom pin is gated on the INCOMING view rather than the
426
+ // tab just left. The POSITION half is armed and re-asserted across the
427
+ // rebuild's frames (applyPendingRestore) because the row it names is usually
428
+ // not built yet; a FOLLOWING view arms nothing, the per-flush pin answers.
429
+ function bind(next: LineStore, bindOpts?: { view?: ViewMemory | null }): void {
430
+ if (!store) {
431
+ return;
432
+ }
433
+ const view = bindOpts?.view ?? null;
434
+ // A NULL view under `bindOpts` is "no memory", whose follow state is the
435
+ // tail; otherwise such a tab inherits the OUTGOING tab's follow flag.
436
+ if (bindOpts !== undefined) {
437
+ scroll.restoreView({
438
+ top: scroll.currentScrollTop(),
439
+ following: view === null ? true : view.following,
440
+ });
441
+ }
442
+ // The transport's own `clearSolicited` lands on whichever store is bound
443
+ // WHEN IT FIRES, and the order of `bind` against the consumer's teardown is
444
+ // the consumer's choice. Closed here, or the outgoing store's window strands
445
+ // open with no socket or timer to close it, and any later frame in that
446
+ // range can resurrect an evicted row and be classified as browse cache.
447
+ store.clearSolicited();
448
+ store = next;
449
+ // Cancel, then arm, one slot: a second switch mid-drain must not land the
450
+ // first tab's anchor into this store.
451
+ bindGen++;
452
+ clearPendingRestore();
453
+ rebuild();
454
+ if (view !== null && !view.following) {
455
+ pendingRestore = {
456
+ view,
457
+ gen: bindGen,
458
+ lastWrote: scroll.currentScrollTop(),
459
+ deadline: Date.now() + RESTORE_MAX_MS,
460
+ };
461
+ }
462
+ }
612
463
 
613
- /**
614
- * Record the server's retained-history bounds from a resumeAck (committed =
615
- * one past the newest retained, oldest = oldest retained absolute index). The
616
- * store uses these to tell a genuine history trim (the server evicted lines
617
- * the client was missing) from a still-loading state, which drives the
618
- * "earlier output trimmed" marker. Resync guard 8.2.2.
619
- */
620
- export function noteResumeBounds(committed: number, oldest: number): void {
621
- store.noteResumeBounds(committed, oldest);
622
- scheduleFlush();
623
- }
464
+ function boundStore(): LineStore {
465
+ return store ?? new LineStore();
466
+ }
624
467
 
625
- // --- demand-paged scrollback: the consumer's seams (docs/paged-scrollback.md) ---
468
+ // Queue every retained line viewport-first: the live window ascending, then
469
+ // scrollback newest->oldest. Iterates the retained key set, NOT the integer
470
+ // range [oldest, highest], so a sparse store (a frame whose base jumped far
471
+ // from a retained index) never freezes the drain.
472
+ function queueRowsViewportFirst(): void {
473
+ if (!store) {
474
+ return;
475
+ }
476
+ const winBase = store.getWindow().base;
477
+ const inWindow: number[] = [];
478
+ const belowWindow: number[] = [];
479
+ store.forEachLine((abs) => {
480
+ if (abs >= winBase) {
481
+ inWindow.push(abs);
482
+ } else {
483
+ belowWindow.push(abs);
484
+ }
485
+ });
486
+ for (const abs of inWindow) {
487
+ renderQueue.add(abs);
488
+ }
489
+ for (let i = belowWindow.length - 1; i >= 0; i--) {
490
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index in range
491
+ renderQueue.add(belowWindow[i]!);
492
+ }
493
+ }
626
494
 
627
- /**
628
- * Apply a correlated history page and, when the reply proved history is gone,
629
- * raise the paging floor so nothing below it is requested again.
630
- *
631
- * Wired to `connection`'s `onHistoryReply`. The viewport index is supplied here
632
- * rather than by the caller because the renderer is the only layer that knows
633
- * it, and the store's eviction needs it to decide which end of the cache is
634
- * safe to drop.
635
- */
636
- export function handleHistoryReply(msg: ScrollMessage, raiseFloorTo: number | null): void {
637
- if (raiseFloorTo !== null) {
638
- store.raisePagingFloor(raiseFloorTo);
495
+ function rebuild(): void {
496
+ // The wipe is the largest content shrink this module performs and happens
497
+ // OUTSIDE a flush (bind calls it synchronously), so it announces its own
498
+ // clamp; unannounced, the clamp falls back to the epsilon inference.
499
+ if (!store || !output) {
500
+ return;
501
+ }
502
+ const scrollTopBeforeWipe = scroll.currentScrollTop();
503
+ output.replaceChildren();
504
+ rowEls.clear();
505
+ renderQueue.clear();
506
+ trimMarkerEl = null;
507
+ for (const el of gapMarkerEls.values()) {
508
+ el.remove();
509
+ }
510
+ gapMarkerEls.clear();
511
+ cursorAbs = -1;
512
+ altRendered = false;
513
+ altPrevRows = [];
514
+ // A stale give-up streak must not deny the rebuilt surface its retry budget.
515
+ renderNoProgressStreak = 0;
516
+ // The overlays share content space with the rows; collapse them BEFORE the
517
+ // offset below is read.
518
+ collapseContentSpaceOverlays();
519
+ // Alt screen paints from the ephemeral grid in the flush.
520
+ if (!store.isAlt()) {
521
+ queueRowsViewportFirst();
522
+ }
523
+ scroll.noteContentShrink(scrollTopBeforeWipe);
524
+ scheduleFlush();
639
525
  }
640
- store.applyHistoryScroll(msg, viewportAbs());
641
- scheduleFlush();
642
- }
643
526
 
644
- /**
645
- * Run the store's single resume-ack transition, supplying the viewport the
646
- * store cannot see. Wired to `connection`'s `onResumeTransition`.
647
- */
648
- export function applyResumeTransition(ack: {
649
- epochChanged: boolean;
650
- committed: number | null;
651
- serverOldest: number | null;
652
- paging: boolean;
653
- sentHaveThrough: number;
654
- sentReplayMax: number | null;
655
- }): void {
656
- // A tab switch RECONNECTS (connection.setSession -> reconnectNow), so this
657
- // transition routinely runs while the surface is mid-rebuild — at a clamped
658
- // offset, over rows that are still being built. The live measurement there is
659
- // a transient that belongs to neither the outgoing nor the incoming view, and
660
- // the store's reclassify pass uses it to decide which rows survive: reading
661
- // the transient can evict the very rows an armed restore is about to bring
662
- // back. An armed restore names the position the user is REGAINING, so it is
663
- // the answer to "which rows must survive" (docs/scroll-position-fidelity.md
664
- // §7.2, §7.3). Every other viewportAbs consumer asks about NOW and keeps the
665
- // live value.
666
- const pending = pendingRestoreAbs();
667
- // The store no longer infers whether the reader is following; this layer knows.
668
- // `isUserScrolledUp()` is the one authoritative source, and an ARMED RESTORE
669
- // overrides it outright: mid-rebuild the browser has clamped the offset, so the
670
- // live flag describes the transient rather than the view the reader is about to
671
- // regain — and a restore is armed only for a position in history.
672
- const pendingRestoreArmed = pending !== null;
673
- store.applyResumeAck({
674
- ...ack,
675
- viewportAbs: pending ?? viewportAbs(),
676
- following: !pendingRestoreArmed && !scroll.isUserScrolledUp(),
677
- });
678
- scheduleFlush();
679
- }
527
+ // Four elements INSIDE the scroll container carry a `top` in content
528
+ // coordinates (the caret, the predicted cursor, the IME view, the consumer's
529
+ // hidden textarea) and hold its scrollable overflow after the rows are gone:
530
+ // measured, an 81081px range over zero rows, a black pane with `stickToBottom`
531
+ // reading "already at the bottom". Collapsing them here also makes the wipe's
532
+ // clamp SYNCHRONOUS, which `noteContentShrink` (arms only on an observed move)
533
+ // and `bind` (records `pendingRestore.lastWrote` from the same offset) need.
534
+ function collapseContentSpaceOverlays(): void {
535
+ // `cursorAbs` is -1 by now, the state positionCursorOverlay hides for.
536
+ positionCursorOverlay(undefined);
537
+ predCursorEl?.classList.remove("visible");
538
+ // The IME view and the textarea are the CONSUMER's, reachable through the
539
+ // cursor seam; `getCursorPx` reports the content origin while `rowEls` is empty.
540
+ onCursorMove?.();
541
+ }
680
542
 
681
- /** Record the window of a request going out (the store port's half). */
682
- export function noteSolicited(fromAbs: number, end: number): void {
683
- store.noteSolicited(fromAbs, end);
684
- }
543
+ function getHighestIndex(): number {
544
+ return store ? store.highestIndex() : -1;
545
+ }
685
546
 
686
- /** Release the in-flight window (reply applied, timed out, socket gone). */
687
- export function clearSolicited(): void {
688
- store.clearSolicited();
689
- }
547
+ // Claiming provisional rows on the wire is what leaves a stale copy of the last
548
+ // screen parked in scrollback after a reattach. See LineStore.replayBoundary.
549
+ function getReplayBoundary(): number {
550
+ return store ? store.replayBoundary() : -1;
551
+ }
690
552
 
691
- /**
692
- * Drop the browse cache, supplying the viewport the skip rule needs. The
693
- * consumer owns the TTL and the visibility state; the engine has no notion of
694
- * tabs or page visibility.
695
- */
696
- export function dropBrowseCache(pageVisible: boolean): void {
697
- store.dropBrowseCache(viewportAbs(), pageVisible);
698
- scheduleFlush();
699
- }
553
+ function noteResumeBounds(committed: number, oldest: number): void {
554
+ if (!store) {
555
+ return;
556
+ }
557
+ store.noteResumeBounds(committed, oldest);
558
+ scheduleFlush();
559
+ }
700
560
 
701
- /** When the browse cache was last created or refreshed, for a consumer's TTL. */
702
- export function lastBrowseActivityMs(): number {
703
- return store.lastBrowseActivityMs();
704
- }
561
+ // --- demand-paged scrollback: the consumer's seams (docs/paged-scrollback.md) ---
705
562
 
706
- /** Lines currently held as disposable browse cache (diagnostics, tests). */
707
- export function browseCacheSize(): number {
708
- return store.browseCacheSize();
709
- }
563
+ // The viewport index is supplied here rather than by the caller because the
564
+ // renderer is the only layer that knows it, and the store's eviction needs it
565
+ // to decide which end of the cache is safe to drop.
566
+ function handleHistoryReply(msg: ScrollMessage, raiseFloorTo: number | null): void {
567
+ if (!store) {
568
+ return;
569
+ }
570
+ if (raiseFloorTo !== null) {
571
+ store.raisePagingFloor(raiseFloorTo);
572
+ }
573
+ store.applyHistoryScroll(msg, viewportAbs());
574
+ scheduleFlush();
575
+ }
710
576
 
711
- /**
712
- * The resume replay bound to send: the client's own residency minus the window
713
- * it is about to be sent anyway, so an attach does not download rows the cap
714
- * would immediately trim. Wired to `connection`'s `getReplayMax`.
715
- */
716
- export function replayMaxForResume(): number {
717
- return Math.max(1, store.tailCap() - store.getWindow().height);
718
- }
577
+ function applyResumeTransition(ack: {
578
+ epochChanged: boolean;
579
+ committed: number | null;
580
+ serverOldest: number | null;
581
+ paging: boolean;
582
+ sentHaveThrough: number;
583
+ sentReplayMax: number | null;
584
+ }): void {
585
+ if (!store) {
586
+ return;
587
+ }
588
+ // A tab switch reconnects, so this transition routinely runs mid-rebuild at
589
+ // a clamped offset over rows still being built. The store's reclassify pass
590
+ // uses the viewport to decide which rows survive, and the live transient
591
+ // would evict the rows an armed restore is about to bring back; the restore
592
+ // names the position the user is REGAINING, so it wins, and it also
593
+ // overrides the live follow flag (docs/scroll-position-fidelity.md §7.2).
594
+ const pending = pendingRestoreAbs();
595
+ const pendingRestoreArmed = pending !== null;
596
+ store.applyResumeAck({
597
+ ...ack,
598
+ viewportAbs: pending ?? viewportAbs(),
599
+ following: !pendingRestoreArmed && !scroll.isUserScrolledUp(),
600
+ });
601
+ scheduleFlush();
602
+ }
719
603
 
720
- /**
721
- * How many rows are queued for a DOM (re)build but not yet built. Non-zero
722
- * means the surface is still materializing content the store already holds: a
723
- * resume replay, a wipe-and-rebuild after a store swap, or any burst larger
724
- * than one frame's build budget.
725
- *
726
- * Exposed so a consumer can surface a "still catching up" affordance during a
727
- * large restore. It is a RENDER-side measure, not a transport one: it reaches
728
- * zero between the server's replay chunks, so on its own it is not a
729
- * restore-complete signal. Pair it with the resumeAck bounds (the store's
730
- * highestIndex reaching the reported `committed`) to tell "this frame's backlog
731
- * is drained" from "the restore has finished arriving".
732
- */
733
- export function pendingRowCount(): number {
734
- return renderQueue.size;
735
- }
604
+ function noteSolicited(fromAbs: number, end: number): void {
605
+ store?.noteSolicited(fromAbs, end);
606
+ }
736
607
 
737
- // --- Color helpers ---
738
- function colorHex(c: number | undefined): string | null {
739
- if (c === undefined || c < 0) {
740
- return null;
608
+ function clearSolicited(): void {
609
+ store?.clearSolicited();
741
610
  }
742
- return "#" + c.toString(16).padStart(6, "0");
743
- }
744
611
 
745
- // --- URL detection (xterm.js addon-web-links pattern) ---
746
- const URL_RE = /(https?|HTTPS?):\/\/[^\s"'!*(){}|\\^<>`]*[^\s"':,.!?{}|\\^~[\]`()<>]/g;
747
-
748
- function linkifySpans(
749
- spans: (HTMLSpanElement | HTMLAnchorElement)[],
750
- ): (HTMLSpanElement | HTMLAnchorElement)[] {
751
- const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
752
- for (const span of spans) {
753
- // Pass anchors through untouched. A span may already be an <a> from an
754
- // OSC 8 hyperlink emitted by the application (see buildRowSpans). The
755
- // app-provided href is authoritative and takes precedence over heuristic
756
- // autolinking — re-scanning it with URL_RE would rebuild the link from
757
- // the *visible* text, which for a URL that wraps across rows is only a
758
- // fragment. That truncates the href and defeats the terminal's
759
- // clickable-across-line-wraps behavior. Skip; only autolink plain text.
760
- if (span.tagName === "A") {
761
- out.push(span);
762
- continue;
763
- }
764
- const text = span.textContent;
765
- URL_RE.lastIndex = 0;
766
- let match: RegExpExecArray | null;
767
- let last = 0;
768
- let found = false;
769
- while ((match = URL_RE.exec(text)) !== null) {
770
- found = true;
771
- if (match.index > last) {
772
- const pre = span.cloneNode(false) as HTMLSpanElement;
773
- pre.textContent = text.slice(last, match.index);
774
- out.push(pre);
775
- }
776
- const a = document.createElement("a");
777
- a.href = match[0];
778
- a.target = "_blank";
779
- a.rel = "noopener noreferrer";
780
- // Auto-detected bare URLs are tightly scoped to the matched URL text
781
- // (never a padded/bordered region), so `.term-autolink` keeps a persistent
782
- // underline for discoverability. OSC 8 hyperlinks (buildRowSpans below) get
783
- // only `.term-link`, which the UI underlines on hover — an app can attach a
784
- // single OSC 8 link to a whole region (e.g. a URL wrapping inside a table
785
- // cell, where the link stays open across the cell padding/borders), and a
786
- // persistent underline would then bleed across the cell/row.
787
- a.className = "term-link term-autolink";
788
- a.textContent = match[0];
789
- // Copy inline styles from the source span property-by-property, never
790
- // via cssText: a cssText assignment is a string-PARSED style write, the
791
- // one kind a style-src CSP reasons about, and this was the renderer's
792
- // only such write. With it gone, every style write in the render path
793
- // is an individually-assigned property (unambiguously ungoverned by
794
- // style-src), which keeps the consumers' CSP-hardening story clean.
795
- for (let i = 0; i < span.style.length; i++) {
796
- const prop = span.style.item(i);
797
- a.style.setProperty(
798
- prop,
799
- span.style.getPropertyValue(prop),
800
- span.style.getPropertyPriority(prop),
801
- );
802
- }
803
- out.push(a);
804
- last = match.index + match[0].length;
612
+ function dropBrowseCache(pageVisible: boolean): void {
613
+ if (!store) {
614
+ return;
805
615
  }
806
- if (!found) {
807
- out.push(span);
808
- } else if (last < text.length) {
809
- const post = span.cloneNode(false) as HTMLSpanElement;
810
- post.textContent = text.slice(last);
811
- out.push(post);
616
+ store.dropBrowseCache(viewportAbs(), pageVisible);
617
+ scheduleFlush();
618
+ }
619
+
620
+ function lastBrowseActivityMs(): number {
621
+ return store ? store.lastBrowseActivityMs() : 0;
622
+ }
623
+
624
+ function browseCacheSize(): number {
625
+ return store ? store.browseCacheSize() : 0;
626
+ }
627
+
628
+ // Residency minus the window about to be sent anyway, so an attach does not
629
+ // download rows the cap would immediately trim.
630
+ function replayMaxForResume(): number {
631
+ if (!store) {
632
+ return COMPATIBILITY_TAIL_CAP;
812
633
  }
634
+ return Math.max(1, store.tailCap() - store.getWindow().height);
813
635
  }
814
- return out;
815
- }
816
636
 
817
- // A hyperlink run is "link text" only if it has at least one glyph that is not
818
- // whitespace and not a box-drawing (U+2500–U+257F) or block-element
819
- // (U+2580–U+259F) character. Terminal table structure (borders `│`, padding,
820
- // empty columns, margins) is made of exactly those, and an app may keep an OSC 8
821
- // hyperlink open across it while a URL wraps; such decorative runs are not
822
- // anchored, so the link decoration hugs the actual text instead of bleeding
823
- // across the cell/row.
824
- function runHasLinkText(spans: (HTMLSpanElement | HTMLAnchorElement)[]): boolean {
825
- for (const s of spans) {
826
- for (const ch of s.textContent) {
827
- if (/\s/.test(ch)) {
828
- continue;
829
- }
830
- const cp = ch.codePointAt(0) ?? 0;
831
- if (cp >= 0x2500 && cp <= 0x259f) {
832
- continue;
833
- }
834
- return true;
637
+ function pendingRowCount(): number {
638
+ return renderQueue.size;
639
+ }
640
+
641
+ // --- Color helpers ---
642
+ function colorHex(c: number | undefined): string | null {
643
+ if (c === undefined || c < 0) {
644
+ return null;
835
645
  }
646
+ return "#" + c.toString(16).padStart(6, "0");
836
647
  }
837
- return false;
838
- }
839
648
 
840
- // --- Build row DOM ---
841
- function buildRowSpans(runs: readonly WireRun[]): (HTMLSpanElement | HTMLAnchorElement)[] {
842
- const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
843
- for (const run of runs) {
844
- if (!run.t) {
845
- continue;
846
- }
847
- const runStartIdx = out.length;
848
- const attrs = run.a ?? 0;
849
- const isBold = (attrs & 1) !== 0;
850
- const isItalic = (attrs & 2) !== 0;
851
- const isUnderline = (attrs & 4) !== 0;
852
- const isInverse = (attrs & 8) !== 0;
853
- const isStrike = (attrs & 16) !== 0;
854
- const isDim = (attrs & 32) !== 0;
855
- const isHidden = (attrs & 64) !== 0;
856
- const isBlink = (attrs & 128) !== 0;
857
- const isOverline = (attrs & 256) !== 0;
858
- const isDoubleUnderline = (attrs & 512) !== 0;
859
-
860
- // Server swaps FG/BG for inverse in wire.go, but when both are
861
- // default (-1) the swap is a no-op. Detect inverse + defaults and
862
- // apply theme-inverted colors so the inverted space is visible.
863
- let fg = colorHex(run.f);
864
- let bg = colorHex(run.b);
865
- if (isInverse && fg === null && bg === null) {
866
- fg = "var(--bg)";
867
- bg = "var(--text)";
868
- }
869
- const ucColor = colorHex(run.uc);
870
-
871
- const applyStyle = (span: HTMLSpanElement, spacing: number): void => {
872
- if (isHidden) {
873
- span.style.visibility = "hidden";
874
- }
875
- if (fg !== null) {
876
- span.style.color = fg;
877
- }
878
- if (bg !== null) {
879
- span.style.background = bg;
880
- }
881
- if (isBold) {
882
- span.style.fontWeight = "bold";
883
- }
884
- if (isItalic) {
885
- span.style.fontStyle = "italic";
886
- }
887
- if (isDim) {
888
- span.style.opacity = ".5";
889
- }
890
- // Build text-decoration combining all line types.
891
- const decoLines: string[] = [];
892
- if (isDoubleUnderline) {
893
- decoLines.push("underline");
894
- } else if (isUnderline) {
895
- decoLines.push("underline");
896
- }
897
- if (isOverline) {
898
- decoLines.push("overline");
899
- }
900
- if (isStrike) {
901
- decoLines.push("line-through");
649
+ // --- URL detection (xterm.js addon-web-links pattern) ---
650
+ const URL_RE = /(https?|HTTPS?):\/\/[^\s"'!*(){}|\\^<>`]*[^\s"':,.!?{}|\\^~[\]`()<>]/g;
651
+
652
+ function linkifySpans(
653
+ spans: (HTMLSpanElement | HTMLAnchorElement)[],
654
+ ): (HTMLSpanElement | HTMLAnchorElement)[] {
655
+ const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
656
+ for (const span of spans) {
657
+ // An OSC 8 anchor's href is authoritative: re-scanning its VISIBLE text
658
+ // would rebuild a URL that wraps across rows from a fragment.
659
+ if (span.tagName === "A") {
660
+ out.push(span);
661
+ continue;
902
662
  }
903
- if (decoLines.length > 0) {
904
- let deco = decoLines.join(" ");
905
- if (isDoubleUnderline) {
906
- deco += " double";
663
+ const text = span.textContent;
664
+ URL_RE.lastIndex = 0;
665
+ let match: RegExpExecArray | null;
666
+ let last = 0;
667
+ let found = false;
668
+ while ((match = URL_RE.exec(text)) !== null) {
669
+ found = true;
670
+ if (match.index > last) {
671
+ const pre = span.cloneNode(false) as HTMLSpanElement;
672
+ pre.textContent = text.slice(last, match.index);
673
+ out.push(pre);
907
674
  }
908
- span.style.textDecoration = deco;
909
- }
910
- if (ucColor !== null) {
911
- span.style.textDecorationColor = ucColor;
675
+ const a = document.createElement("a");
676
+ a.href = match[0];
677
+ a.target = "_blank";
678
+ a.rel = "noopener noreferrer";
679
+ // `.term-autolink` keeps a persistent underline because the match is
680
+ // scoped to the URL text; an OSC 8 link can span a padded region (a URL
681
+ // wrapping inside a table cell), so it gets only the hover underline.
682
+ a.className = "term-link term-autolink";
683
+ a.textContent = match[0];
684
+ // Property-by-property, never cssText: a cssText assignment is a
685
+ // string-PARSED style write, the one kind a style-src CSP governs.
686
+ for (let i = 0; i < span.style.length; i++) {
687
+ const prop = span.style.item(i);
688
+ a.style.setProperty(
689
+ prop,
690
+ span.style.getPropertyValue(prop),
691
+ span.style.getPropertyPriority(prop),
692
+ );
693
+ }
694
+ out.push(a);
695
+ last = match.index + match[0].length;
912
696
  }
913
- if (spacing !== defaultSpacing) {
914
- span.style.letterSpacing = `${spacing}px`;
697
+ if (!found) {
698
+ out.push(span);
699
+ } else if (last < text.length) {
700
+ const post = span.cloneNode(false) as HTMLSpanElement;
701
+ post.textContent = text.slice(last);
702
+ out.push(post);
915
703
  }
916
- if (isBlink) {
917
- span.classList.add("term-blink");
704
+ }
705
+ return out;
706
+ }
707
+
708
+ // A hyperlink run is "link text" only if it has a glyph that is not whitespace
709
+ // and not box-drawing or block-element (U+2500–U+259F): an app may keep an
710
+ // OSC 8 link open across table borders and padding while a URL wraps, and
711
+ // anchoring those cells would bleed the underline across the cell and row.
712
+ function runHasLinkText(spans: (HTMLSpanElement | HTMLAnchorElement)[]): boolean {
713
+ for (const s of spans) {
714
+ for (const ch of s.textContent) {
715
+ if (/\s/.test(ch)) {
716
+ continue;
717
+ }
718
+ const cp = ch.codePointAt(0) ?? 0;
719
+ if (cp >= 0x2500 && cp <= 0x259f) {
720
+ continue;
721
+ }
722
+ return true;
918
723
  }
919
- };
724
+ }
725
+ return false;
726
+ }
920
727
 
921
- let prevSpacing: number | null = null;
922
- let buffer = "";
923
- const flush = (): void => {
924
- if (buffer.length === 0) {
925
- return;
926
- }
927
- const span = document.createElement("span");
928
- span.textContent = buffer;
929
- applyStyle(span, prevSpacing ?? 0);
930
- out.push(span);
931
- buffer = "";
932
- };
933
- for (const ch of run.t) {
934
- if (ch === "\uFFFF") {
935
- // Wide-char continuation placeholder: mark previous span as double-width.
936
- // Flush any buffered text first so the wide char is in its own span.
937
- flush();
938
- if (out.length > 0) {
939
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
940
- const prev = out[out.length - 1]!;
941
-
942
- const prevText = prev.textContent;
943
- if (prevText.length > 0) {
944
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-misused-spread -- terminal text is ASCII/CJK, safe to spread; .at(-1) guaranteed by length check
945
- const lastChar = [...prevText].at(-1)!;
946
- const w = measureChar(lastChar, isBold, isItalic);
947
- prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
948
- }
949
- }
950
- // The spacer occupies the wide char's second cell. (Column arithmetic
951
- // for the caret lives in glyphAt, which mirrors this advance rule —
952
- // the engine reports cursor_col in true cell coordinates, a wide
953
- // glyph moving curX by 2.)
728
+ // --- Build row DOM ---
729
+ function buildRowSpans(runs: readonly WireRun[]): (HTMLSpanElement | HTMLAnchorElement)[] {
730
+ const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
731
+ for (const run of runs) {
732
+ if (!run.t) {
954
733
  continue;
955
734
  }
956
- const w = measureChar(ch, isBold, isItalic);
957
- const spacing = cellWidth - w;
958
- if (prevSpacing === null) {
959
- prevSpacing = spacing;
960
- } else if (spacing !== prevSpacing) {
961
- flush();
962
- prevSpacing = spacing;
735
+ const runStartIdx = out.length;
736
+ const attrs = run.a ?? 0;
737
+ const isBold = (attrs & 1) !== 0;
738
+ const isItalic = (attrs & 2) !== 0;
739
+ const isUnderline = (attrs & 4) !== 0;
740
+ const isInverse = (attrs & 8) !== 0;
741
+ const isStrike = (attrs & 16) !== 0;
742
+ const isDim = (attrs & 32) !== 0;
743
+ const isHidden = (attrs & 64) !== 0;
744
+ const isBlink = (attrs & 128) !== 0;
745
+ const isOverline = (attrs & 256) !== 0;
746
+ const isDoubleUnderline = (attrs & 512) !== 0;
747
+
748
+ // Server swaps FG/BG for inverse in wire.go, but when both are
749
+ // default (-1) the swap is a no-op. Detect inverse + defaults and
750
+ // apply theme-inverted colors so the inverted space is visible.
751
+ let fg = colorHex(run.f);
752
+ let bg = colorHex(run.b);
753
+ if (isInverse && fg === null && bg === null) {
754
+ fg = "var(--bg)";
755
+ bg = "var(--text)";
963
756
  }
964
- buffer += ch;
965
- }
966
- flush();
967
- // Wrap this run's spans in an <a> when it has a hyperlink URL — but only if
968
- // the run actually contains link *text*. An OSC 8 hyperlink can stay open
969
- // across a whole region the app never meant as the clickable "link": a URL
970
- // that wraps inside a table cell keeps the link open across the cell padding,
971
- // the borders `│` and the empty adjacent column. Anchoring those decorative
972
- // cells makes the link's decoration (the underline) bleed across the cell and,
973
- // on wrap, the full row. Skip them so the anchor — and its underline, at rest
974
- // or on hover — hugs the visible link text (they stay as plain, non-clickable
975
- // spans; the wrapped URL's text runs each still carry the full href).
976
- const href = run.u && /^https?:\/\//i.test(run.u) ? run.u : null;
977
- const runSpans = out.splice(runStartIdx);
978
- if (href && runHasLinkText(runSpans)) {
979
- const a = document.createElement("a");
980
- a.href = href;
981
- a.target = "_blank";
982
- a.rel = "noopener noreferrer";
983
- // Server-stamped autolink (attr bit 1024, vt.AttrAutolink): a bare URL
984
- // the server detected — joined across soft-wrap continuations, so every
985
- // row segment carries the FULL href. Styled like the client's own
986
- // autolinks (persistent underline; the anchor hugs exactly the URL
987
- // text). An app-provided OSC 8 hyperlink keeps the hover-only base
988
- // `.term-link` (it may span decorative cells).
989
- a.className = (attrs & 1024) !== 0 ? "term-link term-autolink" : "term-link";
990
- for (const s of runSpans) {
991
- a.appendChild(s);
992
- }
993
- out.push(a);
994
- } else {
995
- for (const s of runSpans) {
996
- out.push(s);
757
+ const ucColor = colorHex(run.uc);
758
+
759
+ const applyStyle = (span: HTMLSpanElement, spacing: number): void => {
760
+ if (isHidden) {
761
+ span.style.visibility = "hidden";
762
+ }
763
+ if (fg !== null) {
764
+ span.style.color = fg;
765
+ }
766
+ if (bg !== null) {
767
+ span.style.background = bg;
768
+ }
769
+ if (isBold) {
770
+ span.style.fontWeight = "bold";
771
+ }
772
+ if (isItalic) {
773
+ span.style.fontStyle = "italic";
774
+ }
775
+ if (isDim) {
776
+ span.style.opacity = ".5";
777
+ }
778
+ // Build text-decoration combining all line types.
779
+ const decoLines: string[] = [];
780
+ if (isDoubleUnderline) {
781
+ decoLines.push("underline");
782
+ } else if (isUnderline) {
783
+ decoLines.push("underline");
784
+ }
785
+ if (isOverline) {
786
+ decoLines.push("overline");
787
+ }
788
+ if (isStrike) {
789
+ decoLines.push("line-through");
790
+ }
791
+ if (decoLines.length > 0) {
792
+ let deco = decoLines.join(" ");
793
+ if (isDoubleUnderline) {
794
+ deco += " double";
795
+ }
796
+ span.style.textDecoration = deco;
797
+ }
798
+ if (ucColor !== null) {
799
+ span.style.textDecorationColor = ucColor;
800
+ }
801
+ if (spacing !== defaultSpacing) {
802
+ span.style.letterSpacing = `${spacing}px`;
803
+ }
804
+ if (isBlink) {
805
+ span.classList.add("term-blink");
806
+ }
807
+ };
808
+
809
+ let prevSpacing: number | null = null;
810
+ let buffer = "";
811
+ const flush = (): void => {
812
+ if (buffer.length === 0) {
813
+ return;
814
+ }
815
+ const span = document.createElement("span");
816
+ span.textContent = buffer;
817
+ applyStyle(span, prevSpacing ?? 0);
818
+ out.push(span);
819
+ buffer = "";
820
+ };
821
+ for (const ch of run.t) {
822
+ if (ch === "\uFFFF") {
823
+ // Wide-char continuation placeholder: mark previous span as double-width.
824
+ // Flush any buffered text first so the wide char is in its own span.
825
+ flush();
826
+ if (out.length > 0) {
827
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
828
+ const prev = out[out.length - 1]!;
829
+
830
+ const prevText = prev.textContent;
831
+ if (prevText.length > 0) {
832
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-misused-spread -- terminal text is ASCII/CJK, safe to spread; .at(-1) guaranteed by length check
833
+ const lastChar = [...prevText].at(-1)!;
834
+ const w = measureChar(lastChar, isBold, isItalic);
835
+ prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
836
+ }
837
+ }
838
+ // The spacer occupies the wide char's second cell. (Column arithmetic
839
+ // for the caret lives in glyphAt, which mirrors this advance rule —
840
+ // the engine reports cursor_col in true cell coordinates, a wide
841
+ // glyph moving curX by 2.)
842
+ continue;
843
+ }
844
+ const w = measureChar(ch, isBold, isItalic);
845
+ const spacing = cellWidth - w;
846
+ if (prevSpacing === null) {
847
+ prevSpacing = spacing;
848
+ } else if (spacing !== prevSpacing) {
849
+ flush();
850
+ prevSpacing = spacing;
851
+ }
852
+ buffer += ch;
853
+ }
854
+ flush();
855
+ // Decorative cells inside an open OSC 8 link stay plain spans (see
856
+ // runHasLinkText); every text run of a wrapped URL still carries the full href.
857
+ const href = run.u && /^https?:\/\//i.test(run.u) ? run.u : null;
858
+ const runSpans = out.splice(runStartIdx);
859
+ if (href && runHasLinkText(runSpans)) {
860
+ const a = document.createElement("a");
861
+ a.href = href;
862
+ a.target = "_blank";
863
+ a.rel = "noopener noreferrer";
864
+ // Attr bit 1024 (vt.AttrAutolink) is a server-detected bare URL, styled
865
+ // like the client's own autolinks; an OSC 8 link keeps the hover-only base.
866
+ a.className = (attrs & 1024) !== 0 ? "term-link term-autolink" : "term-link";
867
+ for (const s of runSpans) {
868
+ a.appendChild(s);
869
+ }
870
+ out.push(a);
871
+ } else {
872
+ for (const s of runSpans) {
873
+ out.push(s);
874
+ }
997
875
  }
998
876
  }
877
+ if (out.length === 0) {
878
+ const span = document.createElement("span");
879
+ span.textContent = "\u00a0";
880
+ out.push(span);
881
+ }
882
+ return linkifySpans(out);
999
883
  }
1000
- if (out.length === 0) {
1001
- const span = document.createElement("span");
1002
- span.textContent = "\u00a0";
1003
- out.push(span);
1004
- }
1005
- return linkifySpans(out);
1006
- }
1007
-
1008
- // --- Frame handling: feed the store, then flush to DOM ---
1009
-
1010
- /**
1011
- * Apply a `ScreenMessage`: update the store's window + changed rows, then
1012
- * schedule a render flush. The store handles merging changed rows by absolute
1013
- * index, so no client-side frame coalescing is needed.
1014
- */
1015
- export function handleScreen(msg: ScreenMessage): void {
1016
- lastInboundMs = Date.now();
1017
- if (msg.scrollbackCleared) {
1018
- // ED3: the application discarded its saved lines, so everything the store
1019
- // holds below this base is about to go. Recorded HERE, at the one place that
1020
- // sees the frame before the store consumes it, so restoreReadAnchor can tell
1021
- // a region DISCARD from an ordinary cap trim — the two need opposite
1022
- // recoveries and only the frame knows which happened
1023
- // (docs/scroll-position-fidelity.md §5). Max, because several frames can
1024
- // land between flushes.
1025
- discardedBelowPending = Math.max(discardedBelowPending, msg.base);
1026
- }
1027
- store.applyScreen(msg);
1028
- scheduleFlush();
1029
- }
1030
884
 
1031
- /**
1032
- * Apply a `ScrollMessage`: commit history lines into the store by absolute
1033
- * index, then schedule a render flush.
1034
- */
1035
- export function handleScroll(msg: ScrollMessage): void {
1036
- lastInboundMs = Date.now();
1037
- store.applyScroll(msg);
1038
- scheduleFlush();
1039
- }
885
+ // --- Frame handling: feed the store, then flush to DOM ---
1040
886
 
1041
- function scheduleFlush(): void {
1042
- if (pendingFrame !== undefined) {
1043
- return;
887
+ function handleScreen(msg: ScreenMessage): void {
888
+ if (!store) {
889
+ return;
890
+ }
891
+ lastInboundMs = Date.now();
892
+ if (msg.scrollbackCleared) {
893
+ // ED3. Recorded at the one place that sees the frame before the store
894
+ // consumes it, so restoreReadAnchor can tell a region DISCARD from a cap
895
+ // trim; the two need opposite recoveries (docs/scroll-position-fidelity.md §5).
896
+ // Max, because several frames can land between flushes.
897
+ discardedBelowPending = Math.max(discardedBelowPending, msg.base);
898
+ }
899
+ store.applyScreen(msg);
900
+ scheduleFlush();
1044
901
  }
1045
- pendingFrame = requestAnimationFrame(flushRender);
1046
- }
1047
902
 
1048
- /**
1049
- * The renderer's half of a scroll-position event. Wire this to
1050
- * `scroll.init({ onScrollPosition })` and nothing else: it is the ONE hook the
1051
- * renderer needs from a position change, so a consumer cannot half-wire it.
1052
- *
1053
- * Two jobs, in this order. Re-evaluate the demand-paging trigger, because the
1054
- * reader may have approached a gap. Then finish a drain that stopped with rows
1055
- * still queued, which is reachable on an idle session: the bounded error path in
1056
- * `flushRender`'s catch deliberately stops rescheduling, and no inbound frame
1057
- * arrives to restart it.
1058
- *
1059
- * It replaced a pair of exported calls (`maybeFetchHistory` plus a public
1060
- * `resumeDrain`) because that pair was a silent-omission trap of exactly the
1061
- * shape the paged-scrollback docs warn about: a consumer upgrading the engine and
1062
- * keeping its old one-line wiring got paging and no drain recovery, with no type
1063
- * error and no runtime complaint. `maybeFetchHistory` stays exported for the
1064
- * transport's own retry path, which is a different event.
1065
- */
1066
- export function handleScrollPosition(): void {
1067
- maybeFetchHistory();
1068
- resumeDrain();
1069
- }
1070
-
1071
- /**
1072
- * Resume a drain that still has rows queued. Private: reached only through
1073
- * `handleScrollPosition`, so there is one wiring obligation rather than two.
1074
- *
1075
- * Three refusals, and each one is load-bearing rather than defensive:
1076
- *
1077
- * **An empty queue.** `flushRender` runs `applyPendingRestore`,
1078
- * `restoreReadAnchor` and `stickToBottomIfFollowing` unconditionally; only the
1079
- * drain is queue-gated. So a flush scheduled from a scroll handler would run the
1080
- * position invariants at moments the renderer never previously flushed at, and
1081
- * because `atBottom` tolerates `BOTTOM_TOLERANCE_PX` while `stickToBottom` pins
1082
- * on any non-zero gap, a downward scroll landing just short of the tail on an
1083
- * IDLE session would snap to the bottom one frame later. It would also expire an
1084
- * armed view restore early through `RESTORE_OWN_WRITE_EPSILON_PX`.
1085
- *
1086
- * **Alt screen.** Alt is a NAMED suspension of the drain (see the reschedule rule
1087
- * below), and a resumption path that ignores it is not a resumption path. The
1088
- * drain would survive anyway, because `flushRenderInner`'s alt branch returns
1089
- * before the drain, but the flush around it would still call `renderAlt` (which
1090
- * replaces the whole output subtree, destroying an in-alt selection) and run all
1091
- * three position invariants. Leaning on the body of a function to make a
1092
- * scheduling decision safe is how the next edit to that body becomes a bug. Alt
1093
- * exit re-queues everything through `queueRowsViewportFirst`, so refusing costs
1094
- * nothing.
1095
- *
1096
- * **A give-up already retried.** The catch's give-up exists so a deterministically
1097
- * throwing row cannot become a 60fps loop. Retrying it from a scroll event would
1098
- * move that loop outside the module rather than remove it: a flick is one scroll
1099
- * event per frame, and `scroll.ts`'s position callback fires for the browser's own
1100
- * clamps too, so a stuck row would log two errors per frame for the length of a
1101
- * gesture. One retry per give-up is enough to serve the case this function exists
1102
- * for (the reader scrolls, the surface completes) without reopening the loop the
1103
- * cap closed. The retry is re-earned by anything that clears the streak: real
1104
- * forward progress, an empty queue, a rebuild, or a re-attach. NOT by the give-up
1105
- * log — a retry that throws gives up again, and re-arming there would restore the
1106
- * per-gesture loop one indirection further out.
1107
- *
1108
- * Residual, accepted: `scroll.ts` fires its position seam before its own
1109
- * announced-shrink early return, so a programmatic clamp (a cap eviction) can spend
1110
- * the retry with no user action. The exposure is small because any successful flush
1111
- * re-arms it, and the alternative (a time bound) adds a clock to a path whose whole
1112
- * purpose is to be cheap.
1113
- */
1114
- function resumeDrain(): void {
1115
- if (renderQueue.size === 0 || store.isAlt()) {
1116
- return;
1117
- }
1118
- // A frame is already coming, so there is nothing to resume and nothing to spend.
1119
- // Without this the retry is burned in a state where it buys NOTHING: the catch
1120
- // reaches `streak === MAX` from its own progress-less branch and schedules a frame
1121
- // on the way out, so for one frame the streak is at the cap with a flush pending.
1122
- // A scroll landing there would set the streak past the cap while `scheduleFlush`
1123
- // no-ops on the occupied slot, and the real give-up one frame later would then
1124
- // find its one retry already gone. The spend must be gated on the give-up having
1125
- // HAPPENED, and an empty frame slot is what distinguishes that.
1126
- if (pendingFrame !== undefined) {
1127
- return;
1128
- }
1129
- if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
1130
- if (renderNoProgressStreak > MAX_RENDER_NO_PROGRESS_RETRIES) {
903
+ function handleScroll(msg: ScrollMessage): void {
904
+ if (!store) {
1131
905
  return;
1132
906
  }
1133
- // Spend the one retry by carrying it in the streak itself, rather than in a
1134
- // second flag beside it. A flag was tried and it leaked: it had to be cleared
1135
- // at every site that clears the streak, it was cleared at three of five, and
1136
- // the two misses (`rebuild`, and a clean pass) are the common ones — so one
1137
- // give-up plus one scroll disabled the resume for the rest of the attachment,
1138
- // invisibly, because that state presents to the canary as a plain give-up. With
1139
- // the count carrying it there is nothing to keep in sync: every existing
1140
- // `renderNoProgressStreak = 0` re-arms the retry by construction.
1141
- renderNoProgressStreak = MAX_RENDER_NO_PROGRESS_RETRIES + 1;
1142
- }
1143
- scheduleFlush();
1144
- }
1145
-
1146
- // --- Read-position anchoring (manual scroll anchoring) ---
1147
- //
1148
- // A flush can change the content height ABOVE the reading position: rows evicted
1149
- // from the top of history once the retention cap is reached, and the trim marker
1150
- // appearing. Every such change moves whatever the user is reading, unless the
1151
- // viewport is shifted by the same amount.
1152
- //
1153
- // Chrome and Firefox do that natively (scroll anchoring, `overflow-anchor`).
1154
- // WebKit does not implement it at all, so on Safari — including the iPad, where
1155
- // this UI mostly runs — a user scrolled up to read had their position dragged
1156
- // one line per evicted row for as long as output kept arriving.
1157
- //
1158
- // The anchor is the first row at or below the viewport top, found by binary
1159
- // search over the container's children: they are in document order, so their
1160
- // offsetTop is monotonic. ~13 reads for a full 5000-row buffer, and only while
1161
- // the user is actually scrolled up — a following viewport takes this path not at
1162
- // all, because the bottom pin owns its position.
1163
- interface ReadAnchor {
1164
- el: HTMLElement;
1165
- /** The anchor row's absolute index, so an eviction that removes the element
1166
- * itself (batched cap eviction frees up to a whole batch at once) can
1167
- * re-resolve to the nearest surviving row instead of giving up. */
1168
- abs: number;
1169
- /** Where the row sat ON SCREEN: offsetTop minus the scroll offset. Measuring
1170
- * the screen position rather than the container offset is what makes the
1171
- * correction idempotent — see restoreReadAnchor. */
1172
- screenTop: number;
1173
- }
1174
-
1175
- // rowAtViewportTop returns the first CONTENT row at or below the viewport top —
1176
- // the ONE row-selection primitive every "where is the reader" question resolves
1177
- // through: the read anchor, the paging trigger's viewportAbs, and the per-view
1178
- // memory a tabbed consumer saves. One definition because two would drift, and
1179
- // they would drift exactly during a rebuild, which is when the answer matters
1180
- // most (docs/scroll-position-fidelity.md §7.2).
1181
- //
1182
- // Children are in document order with monotonic offsetTop, so this is a binary
1183
- // search: ~13 reads for a full 5000-row buffer. Non-content children are NEVER a
1184
- // reading position and are skipped: a marker is an annotation ABOUT a hole, so
1185
- // holding one in place would pin the annotation rather than the text the reader
1186
- // is looking at.
1187
- //
1188
- // "Content row" is decided by IDENTITY in rowEls, not by the presence of a
1189
- // data-abs attribute. A gap marker carries one — updateGapMarkers sets it to the
1190
- // gap's LOW index so insertRowInOrder keeps the container monotonic — so an
1191
- // attribute test skips only the trim marker and happily returns a gap marker as
1192
- // a reading position. Its index is the first ABSENT line, which rowEls can never
1193
- // resolve, so a restore anchored there could never land and a viewportAbs built
1194
- // on it would name a line the store does not hold.
1195
- function isContentRow(el: HTMLElement): boolean {
1196
- const abs = rowAbs(el);
1197
- return abs >= 0 && rowEls.get(abs) === el;
1198
- }
907
+ lastInboundMs = Date.now();
908
+ store.applyScroll(msg);
909
+ scheduleFlush();
910
+ }
1199
911
 
1200
- function rowAtViewportTop(): HTMLElement | null {
1201
- const kids = output.children;
1202
- if (kids.length === 0) {
1203
- return null;
1204
- }
1205
- const offset = scroll.currentScrollTop();
1206
- let lo = 0;
1207
- let hi = kids.length - 1;
1208
- let found: HTMLElement | null = null;
1209
- while (lo <= hi) {
1210
- const mid = (lo + hi) >> 1;
1211
- const el = kids[mid] as HTMLElement;
1212
- if (el.offsetTop >= offset) {
1213
- found = el;
1214
- hi = mid - 1;
1215
- } else {
1216
- lo = mid + 1;
912
+ function scheduleFlush(): void {
913
+ if (pendingFrame !== undefined) {
914
+ return;
915
+ }
916
+ pendingFrame = requestAnimationFrame(flushRender);
917
+ }
918
+
919
+ // One hook rather than two, because a consumer wiring `maybeFetchHistory` and
920
+ // not a public `resumeDrain` would get paging and no drain recovery with no
921
+ // type error. The resume is reachable on an idle session: the bounded error
922
+ // path stops rescheduling, and no inbound frame arrives to restart it.
923
+ function handleScrollPosition(): void {
924
+ maybeFetchHistory();
925
+ resumeDrain();
926
+ }
927
+
928
+ // Three refusals, each load-bearing. An EMPTY QUEUE: the flush runs the
929
+ // position invariants unconditionally, so a flush from a scroll handler would
930
+ // snap a downward scroll landing just short of the tail to the bottom one
931
+ // frame later. ALT SCREEN: a named suspension of the drain; alt exit re-queues
932
+ // everything. A GIVE-UP ALREADY RETRIED: a flick is one scroll event per
933
+ // frame, so retrying a deterministically throwing row per event would move
934
+ // the 60fps loop outside the module; one retry per give-up, re-earned by
935
+ // whatever clears the streak, never by the give-up log itself.
936
+ function resumeDrain(): void {
937
+ if (!store || renderQueue.size === 0 || store.isAlt()) {
938
+ return;
939
+ }
940
+ // The catch reaches `streak === MAX` from its progress-less branch and
941
+ // schedules a frame on the way out, so for one frame the streak sits at the
942
+ // cap with a flush pending; a scroll landing there would burn the retry
943
+ // while `scheduleFlush` no-ops. An empty frame slot is what proves the
944
+ // give-up HAPPENED.
945
+ if (pendingFrame !== undefined) {
946
+ return;
947
+ }
948
+ if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
949
+ if (renderNoProgressStreak > MAX_RENDER_NO_PROGRESS_RETRIES) {
950
+ return;
951
+ }
952
+ // The retry is carried in the streak itself rather than a second flag,
953
+ // so every `renderNoProgressStreak = 0` re-arms it by construction; a flag
954
+ // had to be cleared at every such site and was missed at the common ones.
955
+ renderNoProgressStreak = MAX_RENDER_NO_PROGRESS_RETRIES + 1;
1217
956
  }
957
+ scheduleFlush();
1218
958
  }
1219
- // Walk past any markers the search landed on. The bound is O(gaps + 1), not 1:
1220
- // two gap markers CAN be adjacent siblings, and can be the last two children —
1221
- // measured once predictReplayJump retires the window, since emptyWindow()'s
1222
- // base of 0 makes the drain ascending. Cheap either way (a handful of gaps at
1223
- // most), but do not assume a single step.
1224
- while (found !== null && !isContentRow(found)) {
1225
- found = found.nextElementSibling as HTMLElement | null;
959
+
960
+ // --- Read-position anchoring (manual scroll anchoring) ---
961
+ // A flush can change the content height ABOVE the reading position (top-of-
962
+ // history eviction at the cap, the trim marker appearing). WebKit has no
963
+ // `overflow-anchor`, so on Safari a scrolled-up reader was dragged one line per
964
+ // evicted row. The anchor is the first content row at or below the viewport
965
+ // top; a following viewport never takes this path, the bottom pin owns it.
966
+ interface ReadAnchor {
967
+ el: HTMLElement;
968
+ /** The anchor row's index, so an eviction of the element itself re-resolves to the nearest survivor. */
969
+ abs: number;
970
+ /** Where the row sat ON SCREEN (offsetTop minus scroll offset), which makes the correction idempotent. */
971
+ screenTop: number;
972
+ }
973
+
974
+ // "Content row" is decided by IDENTITY in rowEls, not by a data-abs attribute:
975
+ // a gap marker carries its gap's LOW index (so insertRowInOrder stays
976
+ // monotonic), and an attribute test would return it as a reading position
977
+ // naming a line the store does not hold.
978
+ function isContentRow(el: HTMLElement): boolean {
979
+ const abs = rowAbs(el);
980
+ return abs >= 0 && rowEls.get(abs) === el;
981
+ }
982
+
983
+ // The ONE row-selection primitive every "where is the reader" question
984
+ // resolves through (the read anchor, the paging trigger, the per-view memory):
985
+ // two definitions would drift exactly during a rebuild, when the answer
986
+ // matters most (docs/scroll-position-fidelity.md §7.2). Binary search, since
987
+ // the children are in document order with monotonic offsetTop.
988
+ function rowAtViewportTop(): HTMLElement | null {
989
+ if (!output) {
990
+ return null;
991
+ }
992
+ const kids = output.children;
993
+ if (kids.length === 0) {
994
+ return null;
995
+ }
996
+ const offset = scroll.currentScrollTop();
997
+ let lo = 0;
998
+ let hi = kids.length - 1;
999
+ let found: HTMLElement | null = null;
1000
+ while (lo <= hi) {
1001
+ const mid = (lo + hi) >> 1;
1002
+ const el = kids[mid] as HTMLElement;
1003
+ if (el.offsetTop >= offset) {
1004
+ found = el;
1005
+ hi = mid - 1;
1006
+ } else {
1007
+ lo = mid + 1;
1008
+ }
1009
+ }
1010
+ // Walk past markers. The bound is O(gaps + 1), not 1: two gap markers CAN
1011
+ // be adjacent siblings once predictReplayJump retires the window.
1012
+ while (found !== null && !isContentRow(found)) {
1013
+ found = found.nextElementSibling as HTMLElement | null;
1014
+ }
1015
+ return found;
1226
1016
  }
1227
- return found;
1228
- }
1229
1017
 
1230
- function captureReadAnchor(): ReadAnchor | null {
1231
- if (!scroll.isUserScrolledUp()) {
1232
- return null; // following: stickToBottom owns the position
1233
- }
1234
- const el = rowAtViewportTop();
1235
- if (el === null) {
1236
- // Nothing at or below the viewport top. Reaching this with a container that
1237
- // CLAMPS scrollTop needs the viewport to be shorter than a single row, so it
1238
- // is effectively unreachable in a browser; the previous last-row fallback
1239
- // was reachable mainly under a test fixture whose scrollTop setter did not
1240
- // clamp. Standing down is the right answer either way: using the TAIL as a
1241
- // proxy reading position is what turned a large content shrink into a
1242
- // tail-drag (docs/scroll-position-fidelity.md §1.2).
1243
- return null;
1244
- }
1245
- return { el, abs: rowAbs(el), screenTop: el.offsetTop - scroll.currentScrollTop() };
1246
- }
1018
+ function captureReadAnchor(): ReadAnchor | null {
1019
+ if (!scroll.isUserScrolledUp()) {
1020
+ return null; // following: stickToBottom owns the position
1021
+ }
1022
+ const el = rowAtViewportTop();
1023
+ if (el === null) {
1024
+ // Stand down rather than fall back to the tail: a tail proxy is what
1025
+ // turned a large content shrink into a tail-drag (docs/scroll-position-fidelity.md §1.2).
1026
+ return null;
1027
+ }
1028
+ return { el, abs: rowAbs(el), screenTop: el.offsetTop - scroll.currentScrollTop() };
1029
+ }
1247
1030
 
1248
- // firstRowAtOrAfter returns the first output child whose absolute index is
1249
- // >= abs — the nearest surviving CONTENT at or below a lost reading position.
1250
- // Binary search over the children, mirroring rowAtViewportTop: they are kept in
1251
- // ascending data-abs order.
1252
- //
1253
- // Markers are skipped by identity (isContentRow), not by reading -1 from a
1254
- // missing data-abs. That older property held only while the trim marker was the
1255
- // single non-row child; a per-gap marker carries its gap's LOW index, so an
1256
- // attribute test would return the marker as the "nearest surviving row" and the
1257
- // anchor would pin an annotation about a hole to the reader's screen position.
1258
- // (~13 reads at the 5000-row cap; this runs inside the eviction flush, the
1259
- // frame's most expensive pass already.)
1260
- function firstRowAtOrAfter(abs: number): HTMLElement | null {
1261
- const kids = output.children;
1262
- let lo = 0;
1263
- let hi = kids.length - 1;
1264
- let found: HTMLElement | null = null;
1265
- while (lo <= hi) {
1266
- const mid = (lo + hi) >> 1;
1267
- const el = kids[mid] as HTMLElement;
1268
- if (rowAbs(el) >= abs) {
1269
- found = el;
1270
- hi = mid - 1;
1271
- } else {
1272
- lo = mid + 1;
1031
+ // The nearest surviving CONTENT row at or below a lost reading position, by
1032
+ // binary search over the ascending-data-abs children; markers are skipped by
1033
+ // identity (isContentRow).
1034
+ function firstRowAtOrAfter(abs: number): HTMLElement | null {
1035
+ if (!output) {
1036
+ return null;
1037
+ }
1038
+ const kids = output.children;
1039
+ let lo = 0;
1040
+ let hi = kids.length - 1;
1041
+ let found: HTMLElement | null = null;
1042
+ while (lo <= hi) {
1043
+ const mid = (lo + hi) >> 1;
1044
+ const el = kids[mid] as HTMLElement;
1045
+ if (rowAbs(el) >= abs) {
1046
+ found = el;
1047
+ hi = mid - 1;
1048
+ } else {
1049
+ lo = mid + 1;
1050
+ }
1051
+ }
1052
+ while (found !== null && !isContentRow(found)) {
1053
+ found = found.nextElementSibling as HTMLElement | null;
1273
1054
  }
1055
+ return found;
1274
1056
  }
1275
- while (found !== null && !isContentRow(found)) {
1276
- found = found.nextElementSibling as HTMLElement | null;
1057
+
1058
+ function restoreReadAnchor(anchor: ReadAnchor | null): void {
1059
+ if (anchor === null) {
1060
+ return; // following: stickToBottom owns the position
1061
+ }
1062
+ let el: HTMLElement | null = anchor.el;
1063
+ if (el.parentElement !== output) {
1064
+ if (fullResetThisPass) {
1065
+ // Indices restarted from 0, so a child with a matching data-abs is
1066
+ // UNRELATED content; the fresh session's own follow state owns the viewport.
1067
+ return;
1068
+ }
1069
+ // Batched cap eviction frees up to a whole batch in one pass, so a reader
1070
+ // parked near the buffer top loses the anchored ELEMENT while unread rows
1071
+ // survive below it; hold the nearest survivor at the anchor's screen position.
1072
+ el = firstRowAtOrAfter(anchor.abs);
1073
+ if (el === null) {
1074
+ return; // nothing survives (reset/clear): nothing to hold
1075
+ }
1076
+ // A region DISCARD (ED3), not a cap trim: nothing surviving is guaranteed
1077
+ // ADJACENT to what the reader saw. An inline TUI that reprints on resize
1078
+ // brings the same text back at new indices, and holding the survivor at
1079
+ // the old screen position is the "random jump on resize" symptom
1080
+ // (docs/scroll-position-fidelity.md §1.2, §5). The test is the ANCHOR's
1081
+ // index only: requiring the SURVIVOR above the base too never held, since
1082
+ // the reprint re-delivers lines below the base in the same frame.
1083
+ if (discardedBelowThisPass >= 0 && anchor.abs < discardedBelowThisPass) {
1084
+ return;
1085
+ }
1086
+ }
1087
+ // Correct by how far the row DRIFTED ON SCREEN, not by the content delta:
1088
+ // Chrome and Firefox anchor natively, so their offsetTop change comes with
1089
+ // a matching scrollTop change and this measures zero; correcting the content
1090
+ // delta there would double-compensate. On Safari the drift is the whole delta.
1091
+ const drift = el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
1092
+ scroll.adjustForContentShift(drift);
1277
1093
  }
1278
- return found;
1279
- }
1280
1094
 
1281
- function restoreReadAnchor(anchor: ReadAnchor | null): void {
1282
- if (anchor === null) {
1283
- return; // following: stickToBottom owns the position
1284
- }
1285
- let el: HTMLElement | null = anchor.el;
1286
- if (el.parentElement !== output) {
1287
- if (fullResetThisPass) {
1288
- // Server restart / epoch change: absolute indices restarted from 0, so
1289
- // a child with a matching data-abs is UNRELATED content from the new
1290
- // session — re-anchoring on it would pull an arbitrary row to the old
1291
- // reading position. Stand down; the fresh session's own follow state
1292
- // owns the viewport.
1095
+ function flushRender(): void {
1096
+ pendingFrame = undefined;
1097
+ // Read BEFORE any mutation, twice over: the anchor needs the pre-mutation
1098
+ // screen position, and noteContentShrink needs the pre-mutation offset to tell
1099
+ // whether this pass's row removals actually moved the viewport.
1100
+ const scrollTopBefore = scroll.currentScrollTop();
1101
+ const anchor = captureReadAnchor();
1102
+ try {
1103
+ flushRenderInner();
1104
+ // Clean pass: give a later transient error its full retry budget again.
1105
+ renderNoProgressStreak = 0;
1106
+ } catch (err) {
1107
+ console.error("vterm: render error", err);
1108
+ // The throw skipped flushRenderInner's own reschedule, so finish the drain
1109
+ // here, BOUNDED: reschedule while the pass made progress or the
1110
+ // no-progress streak is under the cap, then stop and let the next inbound
1111
+ // frame retry.
1112
+ if (renderQueue.size === 0) {
1113
+ renderNoProgressStreak = 0;
1114
+ } else if (flushDrainedThisPass > 0) {
1115
+ renderNoProgressStreak = 0;
1116
+ scheduleFlush();
1117
+ } else if (renderNoProgressStreak < MAX_RENDER_NO_PROGRESS_RETRIES) {
1118
+ renderNoProgressStreak++;
1119
+ scheduleFlush();
1120
+ } else {
1121
+ console.error("vterm: giving up render retry after repeated no-progress errors");
1122
+ }
1123
+ }
1124
+ // Announce the shrink BEFORE any write of our own, or the comparison
1125
+ // measures our write instead of the browser's clamp; then reconcile an
1126
+ // out-of-range offset before the invariants below measure the geometry.
1127
+ if (removedRowsThisPass) {
1128
+ scroll.noteContentShrink(scrollTopBefore);
1129
+ scroll.reconcileScrollRange();
1130
+ }
1131
+ // Three position invariants in this order: an armed restore OWNS the
1132
+ // position, the read anchor holds the line on screen, the bottom pin last.
1133
+ // The anchor is skipped ONLY in the frame the restore LANDED: captured before
1134
+ // this frame's mutations, its drift would measure the restore's own write and
1135
+ // correct it straight back out. Skipping it while merely ARMED would
1136
+ // reintroduce the WebKit slide for every frame of a multi-frame rebuild.
1137
+ const restoreLanded = applyPendingRestore();
1138
+ if (!restoreLanded) {
1139
+ restoreReadAnchor(anchor);
1140
+ }
1141
+ // Single auto-follow invariant, applied after every DOM mutation.
1142
+ stickToBottomIfFollowing();
1143
+ // Absorb every write this frame made — ours and the browser's native scroll
1144
+ // anchoring — into the restore's baseline, so only a move that happens BETWEEN
1145
+ // frames (a real gesture) reads as one.
1146
+ if (pendingRestore !== null) {
1147
+ pendingRestore.lastWrote = scroll.currentScrollTop();
1148
+ }
1149
+ // The post-flush trigger. The reader's position relative to the store's gaps
1150
+ // can change without any scroll event — a tail trim moves the frontier up
1151
+ // under a stationary reader, and a byte-short page leaves a fresh sub-gap
1152
+ // beside them — so the flush is the other place the trigger must run
1153
+ // (docs/paged-scrollback.md §5.4).
1154
+ maybeFetchHistory();
1155
+ reportUnnamedDrainStall();
1156
+ }
1157
+
1158
+ // After a flush, owed rows are in one of four states: nothing owed, a frame
1159
+ // scheduled, suspended on the alt screen (renderAlt replaces the whole output
1160
+ // subtree; alt exit re-queues everything), or stopped and logged by the bounded
1161
+ // error path. Anything else is a stall nobody owns: `scheduleFlush` is a single
1162
+ // slot, so a non-empty queue with an empty slot drops every later request
1163
+ // silently. A future suspension point must be named here.
1164
+ function reportUnnamedDrainStall(): void {
1165
+ if (!store || renderQueue.size === 0 || pendingFrame !== undefined || store.isAlt()) {
1166
+ unnamedStallReported = false;
1293
1167
  return;
1294
1168
  }
1295
- // The anchor row itself was evicted out from under the reader: batched
1296
- // cap eviction frees up to a whole batch (256 at the default cap) in one
1297
- // pass, so a reader parked within a batch of the buffer top loses the
1298
- // anchored ELEMENT while rows they had not read yet survive below it.
1299
- // Re-resolve to the first surviving row at or after the anchored index
1300
- // and hold THAT at the anchor's screen position — the view resumes at
1301
- // the nearest surviving content instead of silently jumping past up to
1302
- // a batch of unread lines (WebKit has no native anchoring to catch it).
1303
- el = firstRowAtOrAfter(anchor.abs);
1304
- if (el === null) {
1305
- return; // nothing survives (reset/clear): nothing to hold
1306
- }
1307
- // A region DISCARD, not a cap trim: the application erased its saved lines
1308
- // (ED3), so nothing surviving is guaranteed ADJACENT to what the reader was
1309
- // looking at. On an inline TUI that reprints on resize the same text comes
1310
- // straight back at new indices, so the nearest surviving row is unrelated
1311
- // content — and holding it at the reader's screen position is the "random jump
1312
- // on resize" symptom. A cap trim is the opposite case and keeps the re-resolve
1313
- // above: it removes the oldest contiguous run, so the survivor really is the
1314
- // reader's neighbour (docs/scroll-position-fidelity.md §1.2, §5).
1315
- //
1316
- // The test is the ANCHOR's index. An earlier version also required the
1317
- // SURVIVOR to sit at or above the discard base, which never held for the shape
1318
- // this exists for: the reprint re-delivers lines below that base in the same
1319
- // frame, so the survivor looked adjacent and the correction fired anyway.
1320
- //
1321
- // The remaining `anchor.abs <` clause is a GUARD, not a live path, and the
1322
- // reachability argument is worth recording because it is not obvious. For a
1323
- // single well-formed frame it cannot fire: `msg.base` is both the discard bound
1324
- // and the new window base, so an anchor at or above it sits inside the live
1325
- // window, which cap eviction protects and `truncateBelowWindow` only trims
1326
- // above. Across several frames in one batch (ED3 at a low base, the window
1327
- // settling high) the anchor's ELEMENT normally survives instead — the store
1328
- // coalesces an evict-then-reapply of the same index, so `upsertRow` reuses the
1329
- // element and this whole branch is skipped, leaving the ordinary drift
1330
- // correction to handle the history the discard removed above the reader, which
1331
- // is right. Measured: that path applies a legitimate -1700px correction for
1332
- // 100 discarded rows, and it is NOT this branch.
1333
- if (discardedBelowThisPass >= 0 && anchor.abs < discardedBelowThisPass) {
1169
+ if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
1170
+ // Named and already logged by the catch; not healthy, so the latch stays.
1334
1171
  return;
1335
1172
  }
1173
+ if (unnamedStallReported) {
1174
+ return;
1175
+ }
1176
+ unnamedStallReported = true;
1177
+ console.warn(
1178
+ `vterm: ${String(renderQueue.size)} rows queued with no scheduled frame and no named suspension`,
1179
+ );
1336
1180
  }
1337
- // Correct by how far the row DRIFTED ON SCREEN, not by how much the content
1338
- // above it changed. The two differ exactly when the browser already did this
1339
- // itself: Chrome and Firefox have native scroll anchoring, so their offsetTop
1340
- // change comes with a matching scrollTop change and the screen position is
1341
- // already right — this then measures zero drift and does nothing. Correcting
1342
- // the content delta instead would double-compensate there and throw the view
1343
- // the other way. On Safari, which has no native anchoring, the drift is the
1344
- // whole content delta and this is the only thing that fixes it.
1345
- const drift = el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
1346
- scroll.adjustForContentShift(drift);
1347
- }
1348
1181
 
1349
- function flushRender(): void {
1350
- pendingFrame = undefined;
1351
- // Read BEFORE any mutation, twice over: the anchor needs the pre-mutation
1352
- // screen position, and noteContentShrink needs the pre-mutation offset to tell
1353
- // whether this pass's row removals actually moved the viewport.
1354
- const scrollTopBefore = scroll.currentScrollTop();
1355
- const anchor = captureReadAnchor();
1356
- try {
1357
- flushRenderInner();
1358
- // Clean pass: give a later transient error its full retry budget again.
1359
- renderNoProgressStreak = 0;
1360
- } catch (err) {
1361
- console.error("vterm: render error", err);
1362
- // flushRenderInner threw mid-drain, skipping its own end-of-body
1363
- // "if (renderQueue.size > 0) scheduleFlush()" reschedule, so rows still
1364
- // queued would strand until the next external scheduleFlush() (a new
1365
- // frame). Reschedule here to finish the drain and to retry a transient
1366
- // throw (l-f28) -- but BOUND it. The drain loop deletes a row only after
1367
- // upsertRow succeeds, so a row that throws deterministically stays queued;
1368
- // an unconditional catch -> rAF -> throw reschedule is then a ~60fps busy
1369
- // loop that never stops, even on an idle session. Reschedule while the pass
1370
- // made forward progress (the backlog is shrinking) or the consecutive
1371
- // no-progress streak is under the cap (covers a transient font/measureText
1372
- // race that clears within a frame or two); once passes throw with zero
1373
- // progress past the cap, stop and let the next inbound frame (drainChanges)
1374
- // retry -- the pre-l-f28 behavior for a permanently stuck row.
1375
- if (renderQueue.size === 0) {
1376
- renderNoProgressStreak = 0;
1377
- } else if (flushDrainedThisPass > 0) {
1378
- renderNoProgressStreak = 0;
1379
- scheduleFlush();
1380
- } else if (renderNoProgressStreak < MAX_RENDER_NO_PROGRESS_RETRIES) {
1381
- renderNoProgressStreak++;
1382
- scheduleFlush();
1182
+ function flushRenderInner(): void {
1183
+ flushDrainedThisPass = 0;
1184
+ if (!store || !output) {
1185
+ return;
1186
+ }
1187
+ const ch = store.drainChanges();
1188
+ fullResetThisPass = ch.fullReset;
1189
+ discardedBelowThisPass = discardedBelowPending;
1190
+ discardedBelowPending = -1;
1191
+ removedRowsThisPass = false;
1192
+
1193
+ if (ch.fullReset) {
1194
+ removedRowsThisPass = true;
1195
+ output.replaceChildren();
1196
+ rowEls.clear();
1197
+ renderQueue.clear();
1198
+ trimMarkerEl = null;
1199
+ for (const el of gapMarkerEls.values()) {
1200
+ el.remove();
1201
+ }
1202
+ gapMarkerEls.clear();
1203
+ cursorAbs = -1;
1204
+ // Collapse the overlays HERE rather than in the flush tail, so the shrink
1205
+ // this pass announces measures the real height and not a phantom one.
1206
+ collapseContentSpaceOverlays();
1383
1207
  } else {
1384
- console.error("vterm: giving up render retry after repeated no-progress errors");
1385
- }
1386
- }
1387
- // Tell the scroll controller whether this pass's row removals moved the
1388
- // viewport. Must run BEFORE any write of our own below, or the comparison
1389
- // measures our write instead of the browser's clamp. Announcing the clamp is
1390
- // what keeps a content shrink from reading as "the user scrolled up" and
1391
- // silently switching auto-follow off (scroll.ts's header; §1.3).
1392
- if (removedRowsThisPass) {
1393
- scroll.noteContentShrink(scrollTopBefore);
1394
- // And correct the offset when the container left it past the end of the
1395
- // shrunken content instead of reconciling it (scroll.ts's header: the third
1396
- // arithmetic state). Before the three invariants below, so they measure a
1397
- // geometry the container agrees with rather than one it is still holding an
1398
- // impossible offset into.
1399
- scroll.reconcileScrollRange();
1400
- }
1401
- // Three position invariants, applied after every DOM mutation and in this
1402
- // order. An armed view restore goes first because it OWNS the position while
1403
- // it is armed; the read anchor then holds whatever line is on screen; the
1404
- // bottom pin last, and only if following.
1405
- //
1406
- // The anchor is skipped ONLY in the frame the restore actually LANDED, and
1407
- // that exception is load-bearing rather than tidy: the anchor was captured
1408
- // BEFORE this frame's mutations, so once the restore has authoritatively moved
1409
- // the viewport, the anchor's drift measures the restore's own write and
1410
- // corrects it straight back out — the restore and the anchor cancel and the
1411
- // reader stays at the top. Skipping it while merely ARMED would be wrong for
1412
- // the opposite reason: a rebuild spans many frames, and suppressing the anchor
1413
- // across all of them reintroduces the WebKit read-position slide for every one.
1414
- const restoreLanded = applyPendingRestore();
1415
- if (!restoreLanded) {
1416
- restoreReadAnchor(anchor);
1417
- }
1418
- // Single auto-follow invariant, applied after every DOM mutation.
1419
- stickToBottomIfFollowing();
1420
- // Absorb every write this frame made — ours and the browser's native scroll
1421
- // anchoring — into the restore's baseline, so only a move that happens BETWEEN
1422
- // frames (a real gesture) reads as one.
1423
- if (pendingRestore !== null) {
1424
- pendingRestore.lastWrote = scroll.currentScrollTop();
1425
- }
1426
- // The post-flush trigger. The reader's position relative to the store's gaps
1427
- // can change without any scroll event — a tail trim moves the frontier up
1428
- // under a stationary reader, and a byte-short page leaves a fresh sub-gap
1429
- // beside them — so the flush is the other place the trigger must run
1430
- // (docs/paged-scrollback.md §5.4).
1431
- maybeFetchHistory();
1432
- reportUnnamedDrainStall();
1433
- }
1208
+ for (const abs of ch.evictedLines) {
1209
+ const el = rowEls.get(abs);
1210
+ if (el) {
1211
+ el.remove();
1212
+ removedRowsThisPass = true;
1213
+ }
1214
+ rowEls.delete(abs);
1215
+ renderQueue.delete(abs);
1216
+ }
1217
+ }
1434
1218
 
1435
- // --- The reschedule rule ---
1436
- //
1437
- // After a flush, owed rows are in exactly one of four states. The set is
1438
- // exhaustive by construction here rather than by review of each return site,
1439
- // which is what a queue left non-empty with an empty `pendingFrame` slot costs:
1440
- // `scheduleFlush` is a single slot, so every later request from every source is
1441
- // silently dropped and nothing drains.
1442
- //
1443
- // 1. nothing owed - the queue is empty.
1444
- // 2. scheduled - a frame will drain it.
1445
- // 3. suspended, named edge - alt screen. `renderAlt` replaces the whole output
1446
- // subtree, so continuing the drain would inject
1447
- // main-buffer rows into the alt grid; the alt-exit
1448
- // branch re-queues everything via
1449
- // queueRowsViewportFirst, so nothing is lost. The
1450
- // resumption edge is alt exit. A future third
1451
- // suspension point must declare its edge here.
1452
- // 4. stopped and reported - the bounded error path gave up after
1453
- // MAX_RENDER_NO_PROGRESS_RETRIES and logged. It is a
1454
- // guard on the process, so it refuses rather than
1455
- // looping; the next inbound frame retries, or one
1456
- // scroll does (resumeDrain, once per give-up).
1457
- //
1458
- // Anything else is a stall nobody owns. Reported once PER EPISODE: the latch
1459
- // clears as soon as a flush ends in a healthy state, so a repeat of one stall
1460
- // stays quiet while a genuinely new stall later is still reported. Log spam would
1461
- // be its own defect, and so would a canary that goes deaf after the first report.
1462
- function reportUnnamedDrainStall(): void {
1463
- if (renderQueue.size === 0 || pendingFrame !== undefined || store.isAlt()) {
1464
- // A healthy end: the episode (if any) is over, so re-arm the canary.
1465
- unnamedStallReported = false;
1466
- return;
1467
- }
1468
- if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
1469
- // Named and already logged by the catch. Not a healthy state, so the latch is
1470
- // left as it is rather than re-armed.
1471
- return;
1472
- }
1473
- if (unnamedStallReported) {
1474
- return;
1475
- }
1476
- unnamedStallReported = true;
1477
- console.warn(
1478
- `vterm: ${String(renderQueue.size)} rows queued with no scheduled frame and no named suspension`,
1479
- );
1480
- }
1219
+ const win = store.getWindow();
1220
+ const newCursorAbs = win.base + win.cursorRow;
1221
+ cursorAbs = newCursorAbs;
1222
+ cursorCol = win.cursorCol;
1223
+ cursorHidden = win.cursorHidden;
1224
+ cursorStyleVal = win.cursorStyle;
1225
+ blinkEnabled = win.cursorBlink;
1226
+ syncCursorBlink();
1481
1227
 
1482
- function flushRenderInner(): void {
1483
- // Forward-progress accounting for the bounded error-path reschedule. Reset at
1484
- // entry, incremented per drained row below; a mid-drain throw leaves it at
1485
- // the count-so-far for flushRender's catch to read.
1486
- flushDrainedThisPass = 0;
1487
- const ch = store.drainChanges();
1488
- fullResetThisPass = ch.fullReset;
1489
- discardedBelowThisPass = discardedBelowPending;
1490
- discardedBelowPending = -1;
1491
- removedRowsThisPass = false;
1492
-
1493
- if (ch.fullReset) {
1494
- removedRowsThisPass = true;
1495
- output.replaceChildren();
1496
- rowEls.clear();
1497
- renderQueue.clear();
1498
- trimMarkerEl = null;
1499
- for (const el of gapMarkerEls.values()) {
1500
- el.remove();
1228
+ // Skipping the dirtyLines queueing is safe: alt exit rebuilds from the
1229
+ // store, repainting any line dirtied during the alt session.
1230
+ if (store.isAlt()) {
1231
+ const altRows = store.getAltRows();
1232
+ renderAlt(altRows);
1233
+ positionCursorOverlay(altRows[win.cursorRow]);
1234
+ if (onCursorMove) {
1235
+ onCursorMove();
1236
+ }
1237
+ return;
1501
1238
  }
1502
- gapMarkerEls.clear();
1503
- cursorAbs = -1;
1504
- // The rows are not the only thing in content space, and this wipe is as
1505
- // total as rebuild()'s. Collapse the four overlays with them HERE rather
1506
- // than relying on the tail's positionCursorOverlay/onCursorMove, so the
1507
- // shrink this pass announces is the real one: while an overlay still holds
1508
- // the container's scrollable overflow at its old offset, both the
1509
- // announcement and the range correction measure a phantom height and read
1510
- // "already at the bottom" over zero rows of content. See
1511
- // collapseContentSpaceOverlays for the measurement, and note that the tail
1512
- // is exactly what does not run on the three paths listed there.
1513
- collapseContentSpaceOverlays();
1514
- } else {
1515
- for (const abs of ch.evictedLines) {
1516
- const el = rowEls.get(abs);
1517
- if (el) {
1239
+ if (altRendered) {
1240
+ // Just exited alt: drop the ephemeral rows and rebuild from the store.
1241
+ altRendered = false;
1242
+ altPrevRows = [];
1243
+ removedRowsThisPass = true;
1244
+ output.replaceChildren();
1245
+ rowEls.clear();
1246
+ renderQueue.clear();
1247
+ trimMarkerEl = null;
1248
+ for (const el of gapMarkerEls.values()) {
1518
1249
  el.remove();
1519
- removedRowsThisPass = true;
1520
1250
  }
1521
- rowEls.delete(abs);
1522
- renderQueue.delete(abs);
1251
+ gapMarkerEls.clear();
1252
+ queueRowsViewportFirst();
1523
1253
  }
1524
- }
1525
1254
 
1526
- const win = store.getWindow();
1527
- const newCursorAbs = win.base + win.cursorRow;
1528
- cursorAbs = newCursorAbs;
1529
- cursorCol = win.cursorCol;
1530
- cursorHidden = win.cursorHidden;
1531
- cursorStyleVal = win.cursorStyle;
1532
- blinkEnabled = win.cursorBlink;
1533
- // Reconcile the blink interval with the fresh cursor state (blink mode and
1534
- // DECTCM visibility may both have changed this frame); no-op when the
1535
- // resulting mode is unchanged.
1536
- syncCursorBlink();
1537
-
1538
- // Skips the dirtyLines queueing below — safe: main-buffer rows never
1539
- // repaint while alt is active, and alt-exit rebuilds via
1540
- // queueRowsViewportFirst, repainting any line dirtied during the alt session.
1541
- if (store.isAlt()) {
1542
- const altRows = store.getAltRows();
1543
- renderAlt(altRows);
1544
- positionCursorOverlay(altRows[win.cursorRow]);
1545
- if (onCursorMove) {
1546
- onCursorMove();
1547
- }
1548
- return;
1549
- }
1550
- if (altRendered) {
1551
- // Just exited alt: drop the ephemeral rows and rebuild from the store,
1552
- // viewport-first (shared with rebuild()) so the visible viewport fills
1553
- // before deep scrollback on a large-history alt-exit.
1554
- altRendered = false;
1555
- altPrevRows = [];
1556
- removedRowsThisPass = true;
1557
- output.replaceChildren();
1558
- rowEls.clear();
1559
- renderQueue.clear();
1560
- trimMarkerEl = null;
1561
- for (const el of gapMarkerEls.values()) {
1562
- el.remove();
1255
+ for (const abs of ch.dirtyLines) {
1256
+ renderQueue.add(abs);
1563
1257
  }
1564
- gapMarkerEls.clear();
1565
- queueRowsViewportFirst();
1566
- }
1567
-
1568
- for (const abs of ch.dirtyLines) {
1569
- renderQueue.add(abs);
1570
- }
1571
-
1572
- // The cursor's row is built regardless of the budget — the caret overlay
1573
- // positions off the row element's offsetTop, so a huge backlog must never
1574
- // leave it floating over a not-yet-built row. Content-driven only: cursor
1575
- // MOTION no longer touches row DOM (the overlay carries the caret), so a
1576
- // selection anywhere — including on the cursor row — survives typing.
1577
- if (renderQueue.has(newCursorAbs) || !rowEls.has(newCursorAbs)) {
1578
- upsertRow(newCursorAbs);
1579
- renderQueue.delete(newCursorAbs);
1580
- }
1581
-
1582
- // Drain up to MAX_ROWS_PER_FRAME queued rows this frame, viewport-first;
1583
- // the rest carry over to the next frame (scheduled below) so one big burst
1584
- // never blocks paint. Live-window rows build first (ascending), then the
1585
- // scrollback backlog newest->oldest, so the backlog fills upward above the
1586
- // bottom-pinned viewport — offscreen. A Set's insertion order did the
1587
- // opposite under load: a multi-thousand-row backlog (a resume replay,
1588
- // kiro-cli's post-resize transcript reprint, `cat bigfile`) queued ahead of
1589
- // freshly-dirtied window rows, so the visible screen either churned through
1590
- // history or froze (only the force-built cursor row moving) for seconds on
1591
- // a slow device while the backlog drained. flushDrainedThisPass doubles as
1592
- // the per-frame budget counter and the forward-progress signal the
1593
- // error-path reschedule reads (it was reset to 0 at entry and nothing
1594
- // between there and here touches it).
1595
- const inWindow: number[] = [];
1596
- const belowWindow: number[] = [];
1597
- for (const abs of renderQueue) {
1598
- if (abs >= win.base) {
1599
- inWindow.push(abs);
1600
- } else {
1601
- belowWindow.push(abs);
1258
+
1259
+ // The cursor's row is built regardless of the budget: the caret overlay
1260
+ // positions off the row element's offsetTop, so a backlog must never leave
1261
+ // it floating over a not-yet-built row.
1262
+ if (renderQueue.has(newCursorAbs) || !rowEls.has(newCursorAbs)) {
1263
+ upsertRow(newCursorAbs);
1264
+ renderQueue.delete(newCursorAbs);
1602
1265
  }
1603
- }
1604
- inWindow.sort((a, b) => a - b);
1605
- belowWindow.sort((a, b) => b - a);
1606
- // Drain the two lists sequentially under one budget (no concatenated copy:
1607
- // during a full-backlog drain the spread re-allocated a backlog-sized array
1608
- // every frame — pure GC churn on the hot path).
1609
- const drainRow = (abs: number): boolean => {
1610
- if (flushDrainedThisPass >= MAX_ROWS_PER_FRAME) {
1611
- return false;
1266
+
1267
+ // Viewport-first under one budget: a Set's insertion order queued a
1268
+ // multi-thousand-row backlog ahead of freshly dirtied window rows, so the
1269
+ // visible screen churned through history or froze for seconds on a slow
1270
+ // device. flushDrainedThisPass doubles as the budget counter and the
1271
+ // forward-progress signal the error-path reschedule reads.
1272
+ const inWindow: number[] = [];
1273
+ const belowWindow: number[] = [];
1274
+ for (const abs of renderQueue) {
1275
+ if (abs >= win.base) {
1276
+ inWindow.push(abs);
1277
+ } else {
1278
+ belowWindow.push(abs);
1279
+ }
1612
1280
  }
1613
- upsertRow(abs);
1614
- renderQueue.delete(abs);
1615
- flushDrainedThisPass++;
1616
- return true;
1617
- };
1618
- for (const abs of inWindow) {
1619
- if (!drainRow(abs)) {
1620
- break;
1281
+ inWindow.sort((a, b) => a - b);
1282
+ belowWindow.sort((a, b) => b - a);
1283
+ // No concatenated copy: a spread re-allocated a backlog-sized array per frame.
1284
+ const drainRow = (abs: number): boolean => {
1285
+ if (flushDrainedThisPass >= MAX_ROWS_PER_FRAME) {
1286
+ return false;
1287
+ }
1288
+ upsertRow(abs);
1289
+ renderQueue.delete(abs);
1290
+ flushDrainedThisPass++;
1291
+ return true;
1292
+ };
1293
+ for (const abs of inWindow) {
1294
+ if (!drainRow(abs)) {
1295
+ break;
1296
+ }
1621
1297
  }
1622
- }
1623
- for (const abs of belowWindow) {
1624
- if (!drainRow(abs)) {
1625
- break;
1298
+ for (const abs of belowWindow) {
1299
+ if (!drainRow(abs)) {
1300
+ break;
1301
+ }
1626
1302
  }
1627
- }
1628
1303
 
1629
- updateTrimMarker();
1630
- // The gap markers are a projection of the store's geometry, so they are
1631
- // re-derived on every flush rather than maintained incrementally: a page
1632
- // apply, a browse eviction and a tail trim all move gap edges, and none of
1633
- // them should have to know a marker exists.
1634
- updateGapMarkers();
1304
+ updateTrimMarker();
1305
+ // Re-derived every flush rather than maintained incrementally: a page apply,
1306
+ // a browse eviction and a tail trim all move gap edges.
1307
+ updateGapMarkers();
1635
1308
 
1636
- // More rows pending: keep draining on subsequent frames.
1637
- if (renderQueue.size > 0) {
1638
- scheduleFlush();
1639
- }
1309
+ if (renderQueue.size > 0) {
1310
+ scheduleFlush();
1311
+ }
1640
1312
 
1641
- positionCursorOverlay(store.getLine(cursorAbs));
1313
+ positionCursorOverlay(store.getLine(cursorAbs));
1642
1314
 
1643
- if (onCursorMove) {
1644
- onCursorMove();
1315
+ if (onCursorMove) {
1316
+ onCursorMove();
1317
+ }
1645
1318
  }
1646
- }
1647
-
1648
- // --- Per-view scroll memory (docs/scroll-position-fidelity.md §3) ---
1649
- //
1650
- // A reading position is a LINE, not a pixel offset. A tabbed consumer that saved
1651
- // `scrollTop` and replayed it on re-entry had that write silently CLAMPED,
1652
- // because a rebuild has only built the live window plus one frame's budget when
1653
- // the replay lands (301 of up to 5000 rows, ~5100 of ~85,000 px), and the clamp
1654
- // was never re-attempted. The saved offset also stops meaning the same line as
1655
- // soon as the tab's content grows while it is backgrounded, which is every tab
1656
- // whose session kept working. So the memory is an absolute index plus the row's
1657
- // on-screen offset, and the restore is RE-ASSERTED until the row it names has
1658
- // actually been built.
1659
- //
1660
- // `screenTop` is deliberately a DIFFERENCE (offsetTop - scrollTop), the same
1661
- // form ReadAnchor uses: rows report offsets in `.term-output`'s space while
1662
- // scrollTop belongs to `.term`, so any ABSOLUTE pixel value would need the
1663
- // rowTopInTermWrap conversion and would be one padding off without it. A
1664
- // difference cancels the space out.
1665
-
1666
- /**
1667
- * A saved reading position, in the only terms that survive the content growing
1668
- * underneath it: which line the reader had at the top of the viewport, where on
1669
- * screen that row sat, and whether they were following the tail.
1670
- *
1671
- * Effectively opaque to the consumer — read one out of the renderer and hand the
1672
- * same value back to restore it; the fields are documented because JSR publishes
1673
- * them, not because a caller should compute one. A memory is only meaningful to
1674
- * the store it was taken from, since `abs` names a line in THAT index space, and
1675
- * a restore is re-asserted until the named row has been built rather than
1676
- * applied once.
1677
- */
1678
- export interface ViewMemory {
1679
- /** Absolute line index of the content row at the viewport top. */
1680
- abs: number;
1681
- /** That row's on-screen position: offsetTop minus the scroll offset. */
1682
- screenTop: number;
1683
- /** Whether auto-follow was engaged. */
1684
- following: boolean;
1685
- }
1686
-
1687
- // The armed restore. `gen` is the bind generation it belongs to, so a second
1688
- // switch mid-drain cannot land the first tab's anchor into the second tab's
1689
- // store. `lastWrote` is the offset this module last left behind: a position that
1690
- // does NOT match it was moved by the user, which cancels — the library never
1691
- // fights a gesture, and it establishes that by knowing its own writes rather
1692
- // than by listening to a signal (`onScrollPosition`) that also fires for the
1693
- // browser's clamps, including the one the rebuild itself causes.
1694
- interface PendingRestore {
1695
- view: ViewMemory;
1696
- gen: number;
1697
- lastWrote: number;
1698
- deadline: number;
1699
- }
1700
- let pendingRestore: PendingRestore | null = null;
1701
- let bindGen = 0;
1702
- // Wall-clock of the last inbound frame for the bound store. `renderQueue.size
1703
- // === 0` alone is NOT "the rebuild finished": it reaches zero BETWEEN a resume
1704
- // batch's replay chunks (see pendingRowCount's contract), so the settle needs
1705
- // transport quiet too. Both numbers mirror the tabs feature's already-tuned
1706
- // catch-up cue, which answers the same question for the same reason.
1707
- let lastInboundMs = 0;
1708
- const RESTORE_SETTLE_MS = 250;
1709
- const RESTORE_MAX_MS = 30000;
1710
- // A position within this many px of what we last wrote is still ours: a
1711
- // fractional-layout or subpixel-DPR readback is not a user gesture.
1712
- const RESTORE_OWN_WRITE_EPSILON_PX = 1;
1713
1319
 
1714
- /**
1715
- * Capture the current view as per-view scroll memory a consumer can hand back
1716
- * to `bind`. Returns null when there is nothing meaningful to remember: no
1717
- * content rows yet, or the alternate screen is active (an alt grid has no
1718
- * absolute indices worth restoring, and measuring one would overwrite a tab's
1719
- * real saved position with an alt-screen row).
1720
- *
1721
- * Unlike the private read anchor this measures in BOTH follow states — the
1722
- * follow half is saved alongside it, and a consumer needs the pair.
1723
- */
1724
- export function captureViewMemory(): ViewMemory | null {
1725
- if (store.isAlt()) {
1726
- return null;
1727
- }
1728
- const el = rowAtViewportTop();
1729
- if (el === null) {
1730
- return null;
1731
- }
1732
- const abs = rowAbs(el);
1733
- if (abs < 0) {
1734
- return null;
1320
+ // --- Per-view scroll memory (docs/scroll-position-fidelity.md §3) ---
1321
+ // A reading position is a LINE, not a pixel offset: a replayed `scrollTop` is
1322
+ // silently CLAMPED while a rebuild has built only the live window plus one
1323
+ // frame's budget, and it stops meaning the same line once the content grows in
1324
+ // the background. `screenTop` is a DIFFERENCE (offsetTop - scrollTop) because
1325
+ // rows report offsets in `.term-output`'s space while scrollTop belongs to
1326
+ // `.term`; an absolute value would be one padding off.
1327
+
1328
+ // `gen` is the bind generation, so a second switch mid-drain cannot land the
1329
+ // first tab's anchor into the second tab's store. `lastWrote` is the offset
1330
+ // this module last left behind: a position that does NOT match it was moved by
1331
+ // the user, which cancels. Knowing its own writes is how the library never
1332
+ // fights a gesture; `onScrollPosition` also fires for the browser's clamps.
1333
+ interface PendingRestore {
1334
+ view: ViewMemory;
1335
+ gen: number;
1336
+ lastWrote: number;
1337
+ deadline: number;
1338
+ }
1339
+ let pendingRestore: PendingRestore | null = null;
1340
+ let bindGen = 0;
1341
+ // Wall-clock of the last inbound frame. `renderQueue.size === 0` alone is NOT
1342
+ // "the rebuild finished": it reaches zero BETWEEN a resume batch's replay
1343
+ // chunks, so the settle needs transport quiet too.
1344
+ let lastInboundMs = 0;
1345
+
1346
+ // Null on the alternate screen: an alt grid has no absolute indices worth
1347
+ // restoring, and measuring one would overwrite a tab's real saved position.
1348
+ function captureViewMemory(): ViewMemory | null {
1349
+ if (!store || store.isAlt()) {
1350
+ return null;
1351
+ }
1352
+ const el = rowAtViewportTop();
1353
+ if (el === null) {
1354
+ return null;
1355
+ }
1356
+ const abs = rowAbs(el);
1357
+ if (abs < 0) {
1358
+ return null;
1359
+ }
1360
+ return {
1361
+ abs,
1362
+ screenTop: el.offsetTop - scroll.currentScrollTop(),
1363
+ following: !scroll.isUserScrolledUp(),
1364
+ };
1735
1365
  }
1736
- return {
1737
- abs,
1738
- screenTop: el.offsetTop - scroll.currentScrollTop(),
1739
- following: !scroll.isUserScrolledUp(),
1740
- };
1741
- }
1742
-
1743
- /** Drop any armed restore. Called on (re)init and on every bind. */
1744
- function clearPendingRestore(): void {
1745
- pendingRestore = null;
1746
- }
1747
1366
 
1748
- // applyPendingRestore re-asserts an armed view restore, and is the reason the
1749
- // restore is not a one-shot: the anchored row may not be BUILT yet. A rebuild
1750
- // drains at MAX_ROWS_PER_FRAME, so a reader parked deep in history has their row
1751
- // materialize several frames after the switch, and a single write at frame 1
1752
- // would be clamped to the partial content and never retried.
1753
- //
1754
- // Returns true when it landed (and disarmed).
1755
- function applyPendingRestore(): boolean {
1756
- const p = pendingRestore;
1757
- if (p === null) {
1758
- return false;
1367
+ /** Drop any armed restore. Called on every bind. */
1368
+ function clearPendingRestore(): void {
1369
+ pendingRestore = null;
1759
1370
  }
1760
- // A newer bind owns the surface, or indices restarted: the saved anchor
1761
- // describes content that is no longer here.
1762
- if (p.gen !== bindGen || fullResetThisPass) {
1371
+
1372
+ // Re-asserts an armed view restore, because the anchored row may not be BUILT
1373
+ // yet: a reader parked deep in history has their row materialize several
1374
+ // frames after the switch. Returns true when it landed (and disarmed).
1375
+ function applyPendingRestore(): boolean {
1376
+ const p = pendingRestore;
1377
+ if (p === null) {
1378
+ return false;
1379
+ }
1380
+ // A newer bind owns the surface, or indices restarted: the saved anchor
1381
+ // describes content that is no longer here.
1382
+ if (p.gen !== bindGen || fullResetThisPass) {
1383
+ clearPendingRestore();
1384
+ return false;
1385
+ }
1386
+ const now = scroll.currentScrollTop();
1387
+ if (Math.abs(now - p.lastWrote) > RESTORE_OWN_WRITE_EPSILON_PX) {
1388
+ clearPendingRestore(); // the user moved: never fight a gesture
1389
+ return false;
1390
+ }
1391
+ const el = rowEls.get(p.view.abs);
1392
+ if (el === undefined) {
1393
+ // Not built yet, or gone for good. Give up only once the surface has
1394
+ // genuinely settled (queue drained AND transport quiet) or the bound
1395
+ // lapses; until then stay armed and try again next frame.
1396
+ const quiet = renderQueue.size === 0 && Date.now() - lastInboundMs > RESTORE_SETTLE_MS;
1397
+ if (quiet || Date.now() > p.deadline) {
1398
+ clearPendingRestore();
1399
+ }
1400
+ return false;
1401
+ }
1402
+ // Same arithmetic as the read anchor's drift correction, for the same
1403
+ // space-cancelling reason, and idempotent: an already-satisfied restore
1404
+ // measures zero and writes nothing.
1405
+ const drift = el.offsetTop - now - p.view.screenTop;
1406
+ scroll.adjustForContentShift(drift);
1763
1407
  clearPendingRestore();
1764
- return false;
1408
+ return true;
1765
1409
  }
1766
- const now = scroll.currentScrollTop();
1767
- if (Math.abs(now - p.lastWrote) > RESTORE_OWN_WRITE_EPSILON_PX) {
1768
- clearPendingRestore(); // the user moved: never fight a gesture
1769
- return false;
1410
+
1411
+ // The viewport's absolute index for the paging layer's questions about NOW;
1412
+ // `pendingRestoreAbs` answers the resume transition, which asks which rows
1413
+ // must SURVIVE a switch (docs/scroll-position-fidelity.md §7.2).
1414
+ function viewportAbs(): number {
1415
+ if (!store) {
1416
+ return 0;
1417
+ }
1418
+ if (!scroll.isUserScrolledUp()) {
1419
+ return store.getWindow().base;
1420
+ }
1421
+ const el = rowAtViewportTop();
1422
+ const abs = el === null ? -1 : rowAbs(el);
1423
+ return abs >= 0 ? abs : store.getWindow().base;
1770
1424
  }
1771
- const el = rowEls.get(p.view.abs);
1772
- if (el === undefined) {
1773
- // Not built yet, or gone for good. Give up only once the surface has
1774
- // genuinely settled (queue drained AND transport quiet) or the bound
1775
- // lapses; until then stay armed and try again next frame.
1776
- const quiet = renderQueue.size === 0 && Date.now() - lastInboundMs > RESTORE_SETTLE_MS;
1777
- if (quiet || Date.now() > p.deadline) {
1425
+
1426
+ function pendingRestoreAbs(): number | null {
1427
+ const p = pendingRestore;
1428
+ if (p === null) {
1429
+ return null;
1430
+ }
1431
+ // Expire on READ as well as in the flush: an idle surface schedules no
1432
+ // flush and would otherwise hold an arm indefinitely.
1433
+ if (Date.now() > p.deadline) {
1778
1434
  clearPendingRestore();
1435
+ return null;
1779
1436
  }
1780
- return false;
1437
+ return p.view.abs;
1781
1438
  }
1782
- // Same arithmetic as the read anchor's drift correction, for the same
1783
- // space-cancelling reason, and idempotent: an already-satisfied restore
1784
- // measures zero and writes nothing.
1785
- const drift = el.offsetTop - now - p.view.screenTop;
1786
- scroll.adjustForContentShift(drift);
1787
- clearPendingRestore();
1788
- return true;
1789
- }
1790
1439
 
1791
- /**
1792
- * The viewport's absolute index for the paging layer's live questions: which
1793
- * rows to exempt from eviction, whether a reader is sitting on cache, which gap
1794
- * an interaction is approaching. All three ask about NOW, so they get the live
1795
- * measurement.
1796
- *
1797
- * `pendingRestoreAbs` is the separate answer for the one caller that asks a
1798
- * different question — the resume transition, which asks which rows must
1799
- * SURVIVE a switch (docs/scroll-position-fidelity.md §7.2).
1800
- */
1801
- function viewportAbs(): number {
1802
- if (!scroll.isUserScrolledUp()) {
1803
- return store.getWindow().base;
1440
+ // Every guard below is a pure read and pacing makes a spurious run free, which
1441
+ // is deliberate: firing it only when needed is how a trigger ends up never
1442
+ // firing on the idle session that needs it most.
1443
+ function maybeFetchHistory(): void {
1444
+ // The pending-demand timer fires from a CLOCK, so this alt guard is the
1445
+ // load-bearing one: without it a vim session would fetch pages nobody can
1446
+ // see, and each denial would re-arm the timer (docs/paged-scrollback.md §5.5).
1447
+ if (!store || store.isAlt()) {
1448
+ return;
1449
+ }
1450
+ if (requestHistoryFn === null) {
1451
+ return; // paging not wired by this consumer
1452
+ }
1453
+ const budget = historyBudgetFn === null ? PAGE_SIZE : historyBudgetFn();
1454
+ if (!Number.isInteger(budget) || budget < 1) {
1455
+ return;
1456
+ }
1457
+ const abs = viewportAbs();
1458
+ const edges = store.absentEdgesNear(abs, PREFETCH_THRESHOLD);
1459
+ const gap = edges[0];
1460
+ if (gap === undefined) {
1461
+ return;
1462
+ }
1463
+ // APPROACH-ANCHORED: always fetch the end NEAREST the reader, so a wide gap
1464
+ // heals from the side being read. Fetching a fixed end would land pages up to
1465
+ // (gapWidth - budget) lines away from the viewport, leaving the rows under the
1466
+ // reader blank while the far end filled in.
1467
+ const floor = store.pagingFloorIndex();
1468
+ const fromAbs =
1469
+ abs >= gap.hi
1470
+ ? Math.max(gap.lo, gap.hi - budget, floor) // approaching from BELOW the gap's top
1471
+ : Math.max(gap.lo, floor); // approaching from above: start at the gap's low edge
1472
+ const maxLines = Math.min(budget, gap.hi - fromAbs);
1473
+ if (maxLines < 1) {
1474
+ return;
1475
+ }
1476
+ requestHistoryFn(fromAbs, maxLines);
1804
1477
  }
1805
- const el = rowAtViewportTop();
1806
- const abs = el === null ? -1 : rowAbs(el);
1807
- return abs >= 0 ? abs : store.getWindow().base;
1808
- }
1809
1478
 
1810
- /**
1811
- * The absolute index of an ARMED view restore, or null. Only the resume
1812
- * transition consults this: during a rebuild the live viewport is a transient —
1813
- * mid-drain, at a clamped offset — and a reclassify pass that reads it can evict
1814
- * the very rows the restore is about to bring back. The pending anchor is the
1815
- * position the user is regaining, which is the one that must survive.
1816
- */
1817
- export function pendingRestoreAbs(): number | null {
1818
- const p = pendingRestore;
1819
- if (p === null) {
1820
- return null;
1821
- }
1822
- // Expire on READ as well as in the flush. The flush is the only other place
1823
- // this state is examined, so an idle surface (queue drained, no inbound frames,
1824
- // no flush scheduled) would otherwise hold an arm indefinitely and keep
1825
- // answering with a line the reader has long left.
1826
- if (Date.now() > p.deadline) {
1827
- clearPendingRestore();
1828
- return null;
1479
+ // Re-derive the gap markers from the store's geometry, through intervals.ts,
1480
+ // the single source of gap geometry the fetch trigger reads too: a second
1481
+ // derivation here would have the renderer marking a hole the trigger will not
1482
+ // fetch, or omitting one over a hole it will.
1483
+ function updateGapMarkers(): void {
1484
+ if (!store || !output) {
1485
+ return;
1486
+ }
1487
+ const gaps = new Map<number, number>(); // low index -> high index
1488
+ for (const gap of interiorGaps(store.retainedRanges())) {
1489
+ gaps.set(gap.lo, gap.hi);
1490
+ }
1491
+ // Remove markers whose gap closed.
1492
+ for (const [lo, el] of [...gapMarkerEls]) {
1493
+ if (!gaps.has(lo)) {
1494
+ el.remove();
1495
+ gapMarkerEls.delete(lo);
1496
+ }
1497
+ }
1498
+ const floor = store.pagingFloorIndex();
1499
+ for (const [lo, hi] of gaps) {
1500
+ let el = gapMarkerEls.get(lo);
1501
+ if (el === undefined) {
1502
+ el = document.createElement("div");
1503
+ el.className = "term-gap-marker";
1504
+ el.setAttribute("role", "status");
1505
+ gapMarkerEls.set(lo, el);
1506
+ }
1507
+ // A gap marker exists to say "do not read these two regions as
1508
+ // contiguous", so it cannot fall back to asserting nothing.
1509
+ const condemned = floor >= hi;
1510
+ const label = condemned ? "earlier output trimmed" : "earlier output not loaded";
1511
+ if (el.textContent !== label) {
1512
+ el.textContent = label;
1513
+ el.setAttribute("aria-label", label);
1514
+ }
1515
+ el.dataset["abs"] = String(lo);
1516
+ el.classList.toggle("term-gap-trimmed", condemned);
1517
+ // Position by data-abs order, the same invariant the row inserts keep.
1518
+ const next = firstRowAtOrAfter(hi);
1519
+ if (el.parentElement !== output || el.nextElementSibling !== next) {
1520
+ output.insertBefore(el, next);
1521
+ }
1522
+ }
1829
1523
  }
1830
- return p.view.abs;
1831
- }
1832
1524
 
1833
- /**
1834
- * The fetch trigger: decide whether to ask for a page, and for which range.
1835
- *
1836
- * Called from the post-flush anchor path and from `scroll.ts`'s position seam.
1837
- * Both are cheap to over-call — every guard below is a pure read, and pacing
1838
- * makes a spurious run free — which is deliberate: the alternative (trying to
1839
- * fire it only when needed) is how a trigger ends up never firing on the idle
1840
- * session that needs it most.
1841
- */
1842
- export function maybeFetchHistory(): void {
1843
- // ALT: no paging while the alternate screen is active. The event paths are
1844
- // scrollback-UI-only, but the pending-demand timer fires from a CLOCK, so
1845
- // this guard is the load-bearing one — without it a vim session would fetch
1846
- // pages nobody can see, and each denial would re-arm the timer for the
1847
- // session's whole duration (docs/paged-scrollback.md §5.5).
1848
- if (store.isAlt()) {
1849
- return;
1850
- }
1851
- if (requestHistoryFn === null) {
1852
- return; // paging not wired by this consumer
1853
- }
1854
- const budget = historyBudgetFn === null ? PAGE_SIZE : historyBudgetFn();
1855
- if (!Number.isInteger(budget) || budget < 1) {
1856
- return;
1857
- }
1858
- const abs = viewportAbs();
1859
- const edges = store.absentEdgesNear(abs, PREFETCH_THRESHOLD);
1860
- const gap = edges[0];
1861
- if (gap === undefined) {
1862
- return;
1863
- }
1864
- // APPROACH-ANCHORED: always fetch the end NEAREST the reader, so a wide gap
1865
- // heals from the side being read. Fetching a fixed end would land pages up to
1866
- // (gapWidth - budget) lines away from the viewport, leaving the rows under the
1867
- // reader blank while the far end filled in.
1868
- const floor = store.pagingFloorIndex();
1869
- const fromAbs =
1870
- abs >= gap.hi
1871
- ? Math.max(gap.lo, gap.hi - budget, floor) // approaching from BELOW the gap's top
1872
- : Math.max(gap.lo, floor); // approaching from above: start at the gap's low edge
1873
- const maxLines = Math.min(budget, gap.hi - fromAbs);
1874
- if (maxLines < 1) {
1875
- return;
1876
- }
1877
- requestHistoryFn(fromAbs, maxLines);
1878
- }
1525
+ // The top-of-store marker carries no data-abs, so insertRowInOrder never
1526
+ // places a row before it. Its frontier is deliberately NOT sourced from gap
1527
+ // geometry: the lower edge is a policy question (what is still worth
1528
+ // requesting), so an exhausted frontier still renders a marker rather than
1529
+ // vanishing when the gap closes (docs/paged-scrollback.md §5.4).
1530
+ function updateTrimMarker(): void {
1531
+ if (!output) {
1532
+ return;
1533
+ }
1534
+ const label = topMarkerLabel();
1535
+ if (label === null) {
1536
+ if (trimMarkerEl !== null && trimMarkerEl.parentElement === output) {
1537
+ trimMarkerEl.remove();
1538
+ }
1539
+ return;
1540
+ }
1541
+ if (trimMarkerEl === null) {
1542
+ trimMarkerEl = document.createElement("div");
1543
+ trimMarkerEl.className = "term-trim-marker";
1544
+ trimMarkerEl.setAttribute("role", "status");
1545
+ }
1546
+ if (trimMarkerEl.textContent !== label) {
1547
+ trimMarkerEl.textContent = label;
1548
+ trimMarkerEl.setAttribute("aria-label", label);
1549
+ }
1550
+ // The interior gap marker's class, so one rule styles "gone" in both places.
1551
+ trimMarkerEl.classList.toggle("term-gap-trimmed", label === TRIM_LABEL_GONE);
1552
+ if (trimMarkerEl.parentElement !== output || output.firstChild !== trimMarkerEl) {
1553
+ output.insertBefore(trimMarkerEl, output.firstChild);
1554
+ }
1555
+ }
1879
1556
 
1880
- /**
1881
- * Re-derive the gap markers from the store's geometry. A projection, not a
1882
- * mutation log: every call recomputes which gaps exist and reconciles the DOM,
1883
- * so a gap that healed from either edge moves or loses its marker without any
1884
- * caller having to know which edge changed.
1885
- */
1886
- function updateGapMarkers(): void {
1887
- // Through intervals.ts, which declares itself the single source of gap
1888
- // geometry — and the store's fetch trigger reads it too. This loop used to be
1889
- // a second copy of `interiorGaps` with the same bounds and the same predicate,
1890
- // so any correction to gap derivation (a touching-run case, an off-by-one at a
1891
- // run edge) would have landed on one of them: the renderer marking a hole the
1892
- // trigger will not fetch, or omitting one over a hole it will.
1893
- const gaps = new Map<number, number>(); // low index -> high index
1894
- for (const gap of interiorGaps(store.retainedRanges())) {
1895
- gaps.set(gap.lo, gap.hi);
1896
- }
1897
- // Remove markers whose gap closed.
1898
- for (const [lo, el] of [...gapMarkerEls]) {
1899
- if (!gaps.has(lo)) {
1900
- el.remove();
1901
- gapMarkerEls.delete(lo);
1557
+ // Three honest statements about the history above what is held: nothing when
1558
+ // index 0 is held; "trimmed" when the client evicted the rows itself with no
1559
+ // paging, or when paging proved nothing below survives; "not loaded" when
1560
+ // paging is declared and neither proof is in hand (docs/paged-scrollback.md §5.4).
1561
+ function topMarkerLabel(): string | null {
1562
+ if (!store) {
1563
+ return null;
1564
+ }
1565
+ const oldest = store.oldestIndex();
1566
+ if (oldest <= 0) {
1567
+ return null; // holds index 0, or holds nothing: no history above it
1568
+ }
1569
+ if (!store.pagingDeclared()) {
1570
+ return store.hasTrimmedHistory() ? TRIM_LABEL_GONE : null;
1902
1571
  }
1572
+ const serverOldest = store.serverOldestIndex();
1573
+ const condemned =
1574
+ store.pagingFloorIndex() >= oldest || (serverOldest >= 0 && serverOldest >= oldest);
1575
+ return condemned ? TRIM_LABEL_GONE : TRIM_LABEL_PENDING;
1903
1576
  }
1904
- const floor = store.pagingFloorIndex();
1905
- for (const [lo, hi] of gaps) {
1906
- let el = gapMarkerEls.get(lo);
1577
+
1578
+ // upsertRow builds or updates the DOM row for an absolute index, or removes it
1579
+ // if the store no longer holds it. New rows are inserted in ascending data-abs
1580
+ // order.
1581
+ function upsertRow(abs: number): void {
1582
+ if (!store) {
1583
+ return;
1584
+ }
1585
+ const runs = store.getLine(abs);
1586
+ if (runs === undefined) {
1587
+ const stale = rowEls.get(abs);
1588
+ if (stale) {
1589
+ stale.remove();
1590
+ removedRowsThisPass = true;
1591
+ rowEls.delete(abs);
1592
+ }
1593
+ return;
1594
+ }
1595
+ const spans = buildRowSpans(runs);
1596
+ let el = rowEls.get(abs);
1907
1597
  if (el === undefined) {
1908
1598
  el = document.createElement("div");
1909
- el.className = "term-gap-marker";
1910
- el.setAttribute("role", "status");
1911
- gapMarkerEls.set(lo, el);
1912
- }
1913
- // Three states, and the IDLE one is the default (§5.4). A gap marker whose
1914
- // whole reason to exist is "do not read these two regions as contiguous"
1915
- // cannot fall back to asserting nothing, or the splice it prevents comes
1916
- // back silently.
1917
- const condemned = floor >= hi;
1918
- const label = condemned ? "earlier output trimmed" : "earlier output not loaded";
1919
- if (el.textContent !== label) {
1920
- el.textContent = label;
1921
- el.setAttribute("aria-label", label);
1922
- }
1923
- el.dataset["abs"] = String(lo);
1924
- el.classList.toggle("term-gap-trimmed", condemned);
1925
- // Position by data-abs order, the same invariant the row inserts keep.
1926
- const next = firstRowAtOrAfter(hi);
1927
- if (el.parentElement !== output || el.nextElementSibling !== next) {
1928
- output.insertBefore(el, next);
1599
+ el.className = "term-row";
1600
+ el.dataset["abs"] = String(abs);
1601
+ el.replaceChildren(...spans);
1602
+ insertRowInOrder(el, abs);
1603
+ rowEls.set(abs, el);
1604
+ } else {
1605
+ el.replaceChildren(...spans);
1929
1606
  }
1930
1607
  }
1931
- }
1932
1608
 
1933
- // updateTrimMarker shows, hides and LABELS the top-of-store marker as the first
1934
- // child of output, driven by the store. It carries no data-abs, so
1935
- // insertRowInOrder (which compares numeric data-abs) never places a row before
1936
- // it; it stays pinned at the top.
1937
- //
1938
- // Three states, because there are three honest things to say about the history
1939
- // above what is held (docs/paged-scrollback.md §5.4):
1940
- //
1941
- // - NOTHING, when the store holds index 0: there is no history above it.
1942
- // - "earlier output trimmed", the PERMANENT statement. Without paging declared
1943
- // — including the pre-ack instant — it is the store's own trim bookkeeping,
1944
- // the pre-paging behavior and the right one when no fetch exists: the client
1945
- // evicted those rows itself, which is a fact whatever the server can serve.
1946
- // With paging declared it is EARNED instead: the paging floor has reached the
1947
- // oldest held index (the server proved nothing at or below it survives — an
1948
- // empty frontier reply lands exactly here), or the server's own retained edge
1949
- // is at or above it.
1950
- // - "earlier output not loaded", the RECOVERABLE statement, with paging
1951
- // declared and neither of those proofs in hand. This is the state that was
1952
- // missing: a bounded resume replay routinely leaves a fetchable frontier, and
1953
- // the old single predicate either said nothing at all (silently presenting a
1954
- // partial transcript as the beginning of the session) or said "trimmed" about
1955
- // history the server still holds and the trigger is about to fetch.
1956
- //
1957
- // The frontier is deliberately NOT sourced from gap geometry: its lower edge is
1958
- // a policy question (what is still worth requesting), so an exhausted frontier
1959
- // still renders a marker here rather than vanishing when the gap closes.
1960
- function updateTrimMarker(): void {
1961
- const label = topMarkerLabel();
1962
- if (label === null) {
1963
- if (trimMarkerEl !== null && trimMarkerEl.parentElement === output) {
1964
- trimMarkerEl.remove();
1965
- }
1966
- return;
1967
- }
1968
- if (trimMarkerEl === null) {
1969
- trimMarkerEl = document.createElement("div");
1970
- trimMarkerEl.className = "term-trim-marker";
1971
- trimMarkerEl.setAttribute("role", "status");
1972
- }
1973
- if (trimMarkerEl.textContent !== label) {
1974
- trimMarkerEl.textContent = label;
1975
- trimMarkerEl.setAttribute("aria-label", label);
1976
- }
1977
- // Mirrors the interior gap marker's class, so one rule styles "gone" in both
1978
- // places and a reader learns the distinction once.
1979
- trimMarkerEl.classList.toggle("term-gap-trimmed", label === TRIM_LABEL_GONE);
1980
- if (trimMarkerEl.parentElement !== output || output.firstChild !== trimMarkerEl) {
1981
- output.insertBefore(trimMarkerEl, output.firstChild);
1609
+ // insertRowInOrder places a freshly-created row element among output's
1610
+ // children so they stay in ascending data-abs order. The common case (a new
1611
+ // highest index) is an O(1) append; out-of-order inserts scan for the slot.
1612
+ function insertRowInOrder(el: HTMLDivElement, abs: number): void {
1613
+ if (!output) {
1614
+ return;
1615
+ }
1616
+ const last = output.lastElementChild as HTMLElement | null;
1617
+ if (last === null || rowAbs(last) < abs) {
1618
+ output.appendChild(el);
1619
+ return;
1620
+ }
1621
+ for (const child of output.children) {
1622
+ if (rowAbs(child as HTMLElement) > abs) {
1623
+ output.insertBefore(el, child);
1624
+ return;
1625
+ }
1626
+ }
1627
+ output.appendChild(el);
1982
1628
  }
1983
- }
1984
1629
 
1985
- /** The permanent label: nothing below the oldest held index can be recovered. */
1986
- const TRIM_LABEL_GONE = "earlier output trimmed";
1987
- /** The recoverable label: history above exists and a fetch can bring it back. */
1988
- const TRIM_LABEL_PENDING = "earlier output not loaded";
1989
-
1990
- /** Which top-of-store statement is true, or null for no marker (§5.4). */
1991
- function topMarkerLabel(): string | null {
1992
- const oldest = store.oldestIndex();
1993
- if (oldest <= 0) {
1994
- return null; // holds index 0, or holds nothing: no history above it
1995
- }
1996
- if (!store.pagingDeclared()) {
1997
- return store.hasTrimmedHistory() ? TRIM_LABEL_GONE : null;
1630
+ function rowAbs(el: HTMLElement): number {
1631
+ const v = el.dataset["abs"];
1632
+ return v === undefined ? -1 : Number(v);
1998
1633
  }
1999
- const serverOldest = store.serverOldestIndex();
2000
- const condemned =
2001
- store.pagingFloorIndex() >= oldest || (serverOldest >= 0 && serverOldest >= oldest);
2002
- return condemned ? TRIM_LABEL_GONE : TRIM_LABEL_PENDING;
2003
- }
2004
1634
 
2005
- // upsertRow builds or updates the DOM row for an absolute index, or removes it
2006
- // if the store no longer holds it. New rows are inserted in ascending data-abs
2007
- // order.
2008
- function upsertRow(abs: number): void {
2009
- const runs = store.getLine(abs);
2010
- if (runs === undefined) {
2011
- const stale = rowEls.get(abs);
2012
- if (stale) {
2013
- stale.remove();
2014
- removedRowsThisPass = true;
2015
- rowEls.delete(abs);
2016
- }
2017
- return;
2018
- }
2019
- const spans = buildRowSpans(runs);
2020
- let el = rowEls.get(abs);
2021
- if (el === undefined) {
2022
- el = document.createElement("div");
2023
- el.className = "term-row";
2024
- el.dataset["abs"] = String(abs);
2025
- el.replaceChildren(...spans);
2026
- insertRowInOrder(el, abs);
2027
- rowEls.set(abs, el);
2028
- } else {
2029
- el.replaceChildren(...spans);
2030
- }
2031
- }
1635
+ // --- Alt screen (ephemeral grid; no history) ---
1636
+ let altRendered = false;
1637
+ // The alt row arrays rendered by the last flush, by grid index. Row identity
1638
+ // is the store's change signal (applyScreen swaps exactly the changed rows'
1639
+ // arrays), so `prev[y] === rows[y]` means row y's DOM is current. A separate
1640
+ // mutable container from the store's array, which is mutated in place: the
1641
+ // full build snapshots it once and the reconcile path updates only the
1642
+ // entries it rebuilt, instead of a fresh `rows.slice()` per alt flush.
1643
+ let altPrevRows: (readonly WireRun[])[] = [];
2032
1644
 
2033
- // insertRowInOrder places a freshly-created row element among output's
2034
- // children so they stay in ascending data-abs order. The common case (a new
2035
- // highest index) is an O(1) append; out-of-order inserts scan for the slot.
2036
- function insertRowInOrder(el: HTMLDivElement, abs: number): void {
2037
- const last = output.lastElementChild as HTMLElement | null;
2038
- if (last === null || rowAbs(last) < abs) {
2039
- output.appendChild(el);
2040
- return;
2041
- }
2042
- for (const child of output.children) {
2043
- if (rowAbs(child as HTMLElement) > abs) {
2044
- output.insertBefore(el, child);
1645
+ function renderAlt(rows: readonly (readonly WireRun[])[]): void {
1646
+ if (!output) {
2045
1647
  return;
2046
1648
  }
1649
+ rowEls.clear();
1650
+ // Full (re)build: first alt frame, a grid-height change, or a desynced DOM.
1651
+ if (!altRendered || output.children.length !== rows.length) {
1652
+ altRendered = true;
1653
+ const els: HTMLDivElement[] = [];
1654
+ for (const runs of rows) {
1655
+ const div = document.createElement("div");
1656
+ div.className = "term-row";
1657
+ div.replaceChildren(...buildRowSpans(runs));
1658
+ els.push(div);
1659
+ }
1660
+ // The first alt frame removes the entire main-buffer scrollback for one
1661
+ // screen of grid, and the alt branch in flushRenderInner returns before
1662
+ // the shared bookkeeping, so the flag is set here or not at all. Over-
1663
+ // announcing on a resize is harmless: both seams are position-gated.
1664
+ removedRowsThisPass = true;
1665
+ output.replaceChildren(...els);
1666
+ altPrevRows = rows.slice();
1667
+ return;
1668
+ }
1669
+ // Reconcile in place: a TUI that repaints a few lines (vim, a progress bar)
1670
+ // touches only those rows' DOM, measured ~50x less flush CPU.
1671
+ for (let y = 0; y < rows.length; y++) {
1672
+ if (altPrevRows[y] === rows[y]) {
1673
+ continue;
1674
+ }
1675
+ const div = output.children[y] as HTMLDivElement;
1676
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length === children.length
1677
+ div.replaceChildren(...buildRowSpans(rows[y]!));
1678
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- same bound as above
1679
+ altPrevRows[y] = rows[y]!;
1680
+ }
2047
1681
  }
2048
- output.appendChild(el);
2049
- }
2050
-
2051
- function rowAbs(el: HTMLElement): number {
2052
- const v = el.dataset["abs"];
2053
- return v === undefined ? -1 : Number(v);
2054
- }
2055
1682
 
2056
- // --- Alt screen (ephemeral grid; no history) ---
2057
- let altRendered = false;
2058
- // The alt row arrays rendered by the last flush, by grid index. Row identity
2059
- // is the store's change signal (applyScreen swaps exactly the changed rows'
2060
- // arrays; getAltRows returns the live references), so `prev[y] === rows[y]`
2061
- // means row y's DOM is already current. A separate MUTABLE container from the
2062
- // store's own array (which is mutated in place across frames): the full build
2063
- // snapshots it once, and the reconcile path updates only the entries it
2064
- // rebuilt — the previous per-frame `rows.slice()` allocated a fresh array on
2065
- // every alt flush (60fps GC churn under a full-screen TUI) for identical
2066
- // semantics. Cleared on alt exit and re-init.
2067
- let altPrevRows: (readonly WireRun[])[] = [];
2068
-
2069
- function renderAlt(rows: readonly (readonly WireRun[])[]): void {
2070
- rowEls.clear();
2071
- // Full (re)build: first alt frame, a grid-height change (resize), or a
2072
- // desynced DOM (defensive; e.g. a consumer poked the subtree).
2073
- if (!altRendered || output.children.length !== rows.length) {
2074
- altRendered = true;
2075
- const els: HTMLDivElement[] = [];
2076
- for (const runs of rows) {
2077
- const div = document.createElement("div");
2078
- div.className = "term-row";
2079
- div.replaceChildren(...buildRowSpans(runs));
2080
- els.push(div);
2081
- }
2082
- // This replaceChildren REMOVES rows, and on the first alt frame it removes
2083
- // the entire main-buffer scrollback in favour of one screen of grid: the
2084
- // largest shrink in this module after rebuild()'s wipe. The alt branch in
2085
- // flushRenderInner returns before the shared bookkeeping, so the flag is set
2086
- // here or not at all, and without it the pass reached neither
2087
- // noteContentShrink (leaving the clamp to be inferred from its lossy
2088
- // arithmetic signature, which can silently switch auto-follow off) nor
2089
- // reconcileScrollRange (leaving the viewport parked past the grid).
2090
- //
2091
- // Over-announcing is harmless by mechanism: the resize and desynced-DOM
2092
- // cases reach this branch too, and both seams are position-gated, so a
2093
- // rebuild that moves nothing and strands nothing announces nothing.
2094
- removedRowsThisPass = true;
2095
- output.replaceChildren(...els);
2096
- altPrevRows = rows.slice();
2097
- return;
2098
- }
2099
- // Reconcile in place: rebuild only rows whose array identity changed. A
2100
- // full-screen TUI that repaints everything (htop) rebuilds every row either
2101
- // way; one that repaints a few lines (vim editing, a progress bar) now
2102
- // touches only those rows' DOM — measured ~50x less flush CPU in the render
2103
- // bench's partial-update scenario, and the browser skips layout for
2104
- // untouched rows.
2105
- for (let y = 0; y < rows.length; y++) {
2106
- if (altPrevRows[y] === rows[y]) {
2107
- continue;
2108
- }
2109
- const div = output.children[y] as HTMLDivElement;
2110
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length === children.length
2111
- div.replaceChildren(...buildRowSpans(rows[y]!));
2112
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- same bound as above
2113
- altPrevRows[y] = rows[y]!;
1683
+ function stickToBottomIfFollowing(): void {
1684
+ scroll.stickToBottom();
2114
1685
  }
2115
- }
2116
1686
 
2117
- /** Pin the viewport to the bottom iff the user is following. The scroll
2118
- * controller (scroll.ts) owns scrollTop and the follow/hold decision. */
2119
- function stickToBottomIfFollowing(): void {
2120
- scroll.stickToBottom();
2121
- }
1687
+ // --- Cursor blink ---
1688
+ let blinkInterval: ReturnType<typeof setInterval> | null = null;
1689
+ let blinkEnabled = true;
1690
+ // What the running interval (if any) is configured as; syncCursorBlink()
1691
+ // reconfigures only on mode change, so it is safe to call on every flush.
1692
+ let blinkMode: BlinkMode = "off";
2122
1693
 
2123
- // --- Cursor blink ---
2124
- const CURSOR_BLINK_MS = 530;
2125
- // While the application hides the cursor (DECTCM — e.g. a full-screen TUI
2126
- // that paints its own cursor cell, like an agent front-end), the fast toggle
2127
- // would only restyle a display:none overlay, so the interval downshifts to
2128
- // this slow re-check: still polling for the cursor's return (a self-healing
2129
- // backstop even if a transition frame were somehow missed — the flush hook is
2130
- // the primary, instant restart), at a fraction of the 530ms blink's wakeups.
2131
- // For agent consumers the hidden-cursor state is the session's steady state.
2132
- const CURSOR_RECHECK_MS = 4000;
2133
- let blinkInterval: ReturnType<typeof setInterval> | null = null;
2134
- let blinkEnabled = true;
2135
- // What the running interval (if any) is configured as. syncCursorBlink()
2136
- // reconfigures only on mode change, so it is safe to call on every flush.
2137
- type BlinkMode = "off" | "idle" | "fast";
2138
- let blinkMode: BlinkMode = "off";
2139
-
2140
- // hiddenDoc reports whether the page is currently background/hidden. The blink
2141
- // interval is gated on it: rAF-driven flushes already freeze in a hidden tab,
2142
- // but a plain setInterval keeps firing (throttled), so an idle hidden terminal
2143
- // would keep toggling a class — pointless wakeups that cost battery on mobile.
2144
- function hiddenDoc(): boolean {
2145
- return typeof document !== "undefined" && document.visibilityState === "hidden";
2146
- }
2147
-
2148
- // desiredBlinkMode folds the three gates into an interval mode: no timer at
2149
- // all when blinking is disabled or the page is hidden (both have event-driven
2150
- // restarts — the next mode frame's flush and visibilitychange), the fast
2151
- // phase toggle when the cursor is visible, and the slow re-check while the
2152
- // application hides it.
2153
- function desiredBlinkMode(): BlinkMode {
2154
- if (!blinkEnabled || hiddenDoc()) {
2155
- return "off";
1694
+ // rAF-driven flushes freeze in a hidden tab, but a plain setInterval keeps
1695
+ // firing (throttled), so an idle hidden terminal would keep toggling a class:
1696
+ // pointless wakeups that cost battery on mobile.
1697
+ function hiddenDoc(): boolean {
1698
+ return document.visibilityState === "hidden";
2156
1699
  }
2157
- return cursorHidden ? "idle" : "fast";
2158
- }
2159
1700
 
2160
- // syncCursorBlink reconciles the interval with the current gate state; every
2161
- // mode change resets the phase to solid. Called from the flush (blink mode +
2162
- // DECTCM state), the visibilitychange listener, init(), and the idle re-check
2163
- // itself. The blink class lives on termWrap: the caret overlay is a termWrap
2164
- // child (not inside output), and the `.cursor-blink-off .term-cursor`
2165
- // descendant selector must reach it.
2166
- function syncCursorBlink(): void {
2167
- const mode = desiredBlinkMode();
2168
- if (mode === blinkMode) {
2169
- return;
2170
- }
2171
- blinkMode = mode;
2172
- if (blinkInterval !== null) {
2173
- clearInterval(blinkInterval);
2174
- blinkInterval = null;
2175
- }
2176
- termWrap.classList.remove("cursor-blink-off");
2177
- if (mode === "fast") {
2178
- blinkInterval = setInterval(() => {
2179
- termWrap.classList.toggle("cursor-blink-off");
2180
- }, CURSOR_BLINK_MS);
2181
- } else if (mode === "idle") {
2182
- blinkInterval = setInterval(syncCursorBlink, CURSOR_RECHECK_MS);
1701
+ // No timer when blinking is disabled or the page is hidden (both have
1702
+ // event-driven restarts), the fast toggle when the cursor is visible, the slow
1703
+ // re-check while the application hides it.
1704
+ function desiredBlinkMode(): BlinkMode {
1705
+ if (!blinkEnabled || hiddenDoc()) {
1706
+ return "off";
1707
+ }
1708
+ return cursorHidden ? "idle" : "fast";
2183
1709
  }
2184
- }
2185
1710
 
2186
- // Pause the interval while hidden; resume (cursor solid, phase reset) when the
2187
- // tab is foregrounded again. Registered once at module load — init() re-runs
2188
- // on re-attach, and a per-init registration would stack listeners.
2189
- if (typeof document !== "undefined") {
2190
- document.addEventListener("visibilitychange", () => {
2191
- // Pre-init (no termWrap yet) there is nothing to pause or resume.
2192
- if ((termWrap as HTMLElement | undefined) === undefined) {
1711
+ // Every mode change resets the phase to solid. The blink class lives on
1712
+ // termWrap because the caret overlay is a termWrap child, not inside output,
1713
+ // and the `.cursor-blink-off .term-cursor` descendant selector must reach it.
1714
+ function syncCursorBlink(): void {
1715
+ const el = termWrap;
1716
+ if (!el) {
2193
1717
  return;
2194
1718
  }
2195
- // blinkEnabled/cursorHidden keep their server-driven state across a
2196
- // background stint, so foregrounding restores the right mode without
2197
- // waiting for the next frame.
2198
- syncCursorBlink();
2199
- });
2200
- }
2201
-
2202
- // --- Font metrics & sizing ---
2203
- /**
2204
- * Re-measure the cell width/height from the rendered DOM. Call after any font
2205
- * or zoom change so subsequent `computeSize()` and `getCursorPx()` use fresh
2206
- * metrics.
2207
- */
2208
- export function updateFontMetrics(): void {
2209
- // Capture BEFORE the metrics change: a row-height change rescales every
2210
- // offsetTop, so a holding reader's line moves under them with nothing to
2211
- // correct it — the read anchor only measures drift across a FLUSH's own
2212
- // mutations, and a pure restyle may not schedule one at all. `abs` (the line
2213
- // identity, which is what the reader cares about) is exact regardless of when
2214
- // this runs; `screenTop` can be up to one row stale if the layout changed
2215
- // before this call, which is a sub-row error rather than a lost position.
2216
- const before = captureViewMemory();
2217
- const cs = window.getComputedStyle(termWrap);
2218
- // Refresh the overlay-positioning padding cache from the same computed
2219
- // style (one staleness contract for all cached box metrics).
2220
- padLeft = parseFloat(cs.paddingLeft);
2221
- padTop = parseFloat(cs.paddingTop);
2222
- padValid = true;
2223
- const fontSize = cs.fontSize;
2224
- const family = cs.fontFamily;
2225
- fontString = `${fontSize} ${family}`;
2226
- widthFlat.fill(WIDTH_FLAT_UNSET);
2227
- widthMap.clear();
2228
- resetVariantContexts();
2229
- const measuredW = measureCellWidth();
2230
- cellWidth = Math.round(measuredW);
2231
- cellHeight = parseFloat(cs.lineHeight) || 17;
2232
- // The column count is derived from the cell, so the cache gridSize reads is
2233
- // stale as of this call.
2234
- measuredCols = 0;
2235
- defaultSpacing = cellWidth - measuredW;
2236
- output.style.letterSpacing = `${defaultSpacing}px`;
2237
- document.documentElement.style.setProperty("--char-w", `${cellWidth}px`);
2238
- // A holding reader's line is restored through the same machinery a tab switch
2239
- // uses: arm it and let the next flush re-assert it. Idempotent when the metrics
2240
- // did not actually change (the drift measures zero), and a no-op for a
2241
- // following viewport, whose position the bottom pin already owns.
2242
- // An armed restore SURVIVES this call, and its baseline has to be refreshed or
2243
- // it does not: the reflow that provoked the call has already moved scrollTop,
2244
- // so leaving lastWrote stale makes the next flush read that as a gesture and
2245
- // cancel the very restore this branch is protecting. Declining to arm and
2246
- // silently killing the existing arm would be the worst of both.
2247
- if (pendingRestore !== null) {
2248
- pendingRestore.lastWrote = scroll.currentScrollTop();
2249
- }
2250
- // NEVER over an armed restore. A resize settle calls this (through the
2251
- // consumer's measurable-size check) and a tab switch reconnects, so the two
2252
- // routinely overlap: replacing a bind's saved anchor with one measured
2253
- // mid-rebuild would swap the reader's real position for a transient.
2254
- if (before !== null && !before.following && pendingRestore === null) {
2255
- pendingRestore = {
2256
- view: before,
2257
- gen: bindGen,
2258
- lastWrote: scroll.currentScrollTop(),
2259
- deadline: Date.now() + RESTORE_MAX_MS,
2260
- };
2261
- scheduleFlush();
1719
+ const mode = desiredBlinkMode();
1720
+ if (mode === blinkMode) {
1721
+ return;
1722
+ }
1723
+ blinkMode = mode;
1724
+ if (blinkInterval !== null) {
1725
+ clearInterval(blinkInterval);
1726
+ blinkInterval = null;
1727
+ }
1728
+ el.classList.remove("cursor-blink-off");
1729
+ if (mode === "fast") {
1730
+ blinkInterval = setInterval(() => {
1731
+ el.classList.toggle("cursor-blink-off");
1732
+ }, CURSOR_BLINK_MS);
1733
+ } else if (mode === "idle") {
1734
+ blinkInterval = setInterval(syncCursorBlink, CURSOR_RECHECK_MS);
1735
+ }
2262
1736
  }
2263
- }
2264
-
2265
- const MIN_COLS = 20;
2266
- const MIN_ROWS = 5;
2267
-
2268
- // The column count of the most recent measurement, which is the count the
2269
- // resize announce carried to the server. gridSize() reads it instead of
2270
- // measuring, because it is called on every raw mousemove while an application
2271
- // tracks motion and computeSize() costs a computed-style read plus two layout
2272
- // reads. 0 until the first measurement.
2273
- let measuredCols = 0;
2274
1737
 
2275
- /**
2276
- * Compute the integer (cols, rows) the terminal element can fit at current
2277
- * font metrics, clamped to a minimum of 20×5. Used to decide what dimensions
2278
- * to send to the server in a `resize` control message.
2279
- */
2280
- export function computeSize(): { cols: number; rows: number } {
2281
- const cs = window.getComputedStyle(termWrap);
2282
- const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
2283
- const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
2284
- const contentW = termWrap.clientWidth - padX;
2285
- const contentH = termWrap.clientHeight - padY;
2286
- const cols = Math.max(MIN_COLS, Math.floor(contentW / cellWidth));
2287
- const rows = Math.max(MIN_ROWS, Math.floor(contentH / cellHeight));
2288
- measuredCols = cols;
2289
- return { cols, rows };
2290
- }
1738
+ // --- Font metrics & sizing ---
1739
+ function updateFontMetrics(): void {
1740
+ // Capture BEFORE the metrics change: a row-height change rescales every
1741
+ // offsetTop, and the read anchor only measures drift across a FLUSH's own
1742
+ // mutations, which a pure restyle may not schedule at all.
1743
+ if (!output || !termWrap) {
1744
+ return;
1745
+ }
1746
+ const before = captureViewMemory();
1747
+ const cs = window.getComputedStyle(termWrap);
1748
+ padLeft = parseFloat(cs.paddingLeft);
1749
+ padTop = parseFloat(cs.paddingTop);
1750
+ padValid = true;
1751
+ const fontSize = cs.fontSize;
1752
+ const family = cs.fontFamily;
1753
+ fontString = `${fontSize} ${family}`;
1754
+ widthFlat.fill(WIDTH_FLAT_UNSET);
1755
+ widthMap.clear();
1756
+ resetVariantContexts();
1757
+ const measuredW = measureCellWidth(termWrap);
1758
+ cellWidth = Math.round(measuredW);
1759
+ cellHeight = parseFloat(cs.lineHeight) || 17;
1760
+ measuredCols = 0;
1761
+ defaultSpacing = cellWidth - measuredW;
1762
+ output.style.letterSpacing = `${defaultSpacing}px`;
1763
+ termWrap.style.setProperty("--char-w", `${cellWidth}px`);
1764
+ // An armed restore SURVIVES this call and its baseline must be refreshed:
1765
+ // the reflow that provoked the call has already moved scrollTop, and a stale
1766
+ // lastWrote makes the next flush read that as a gesture and cancel it.
1767
+ if (pendingRestore !== null) {
1768
+ pendingRestore.lastWrote = scroll.currentScrollTop();
1769
+ }
1770
+ // A holding reader's line is restored through the tab-switch machinery, but
1771
+ // NEVER over an armed restore: a resize settle and a tab switch routinely
1772
+ // overlap, and an anchor measured mid-rebuild is a transient.
1773
+ if (before !== null && !before.following && pendingRestore === null) {
1774
+ pendingRestore = {
1775
+ view: before,
1776
+ gen: bindGen,
1777
+ lastWrote: scroll.currentScrollTop(),
1778
+ deadline: Date.now() + RESTORE_MAX_MS,
1779
+ };
1780
+ scheduleFlush();
1781
+ }
1782
+ }
2291
1783
 
2292
- /**
2293
- * The dimensions of the grid currently ON SCREEN — what
2294
- * `mouse.MouseInputHandler.gridSize` wants, so a pointer resolves against the
2295
- * rows the reader is actually looking at.
2296
- *
2297
- * Deliberately not `computeSize()`, whose rows are the size this client WOULD
2298
- * like: the server's screen is last-writer-wins across every attached client
2299
- * and relaxes to the smallest on disconnect, so a second attached device — or
2300
- * any local resize before the next screen frame lands — leaves the measured
2301
- * height describing a grid nothing is painting. The row hit test anchors on this
2302
- * number rather than merely clamping to it, so a disagreement of N shifts every
2303
- * reported row by N. The alt grid keeps its own height, and a TUI runs there.
2304
- *
2305
- * Columns stay the measured count: the store has none to answer with (a row's
2306
- * trailing blank cells are trimmed off the wire) and the axis only bounds a
2307
- * report, where over-reporting costs nothing.
2308
- *
2309
- * Rows are 0 until the first screen frame lands, which the mouse module treats
2310
- * as "no grid yet" and refuses to report against — the honest answer, since
2311
- * nothing knows the screen's height before the server describes it.
2312
- */
2313
- export function gridSize(): { cols: number; rows: number } {
2314
- const rows = store.isAlt() ? store.getAltRows().length : store.getWindow().height;
2315
- return { cols: measuredCols > 0 ? measuredCols : computeSize().cols, rows };
2316
- }
1784
+ // The column count of the most recent measurement. gridSize() reads it
1785
+ // instead of measuring because it runs on every raw mousemove under motion
1786
+ // tracking, and computeSize() costs a computed-style read plus two layout reads.
1787
+ let measuredCols = 0;
2317
1788
 
2318
- // rowTopInTermWrap returns a row element's top in termWrap's coordinate space
2319
- // (the space every absolutely-positioned overlay — caret, predicted cursor,
2320
- // composition view — resolves against). A bare `offsetTop` is NOT that: it is
2321
- // relative to the element's offsetParent, and the UI package's CSS makes
2322
- // `.term-output` a positioned element (position: relative, for its own
2323
- // reasons), so rows report offsets in output-space,
2324
- // one padding/offset short of termWrap-space (the caret floated 4px above
2325
- // every glyph under the real stylesheet — invisible to the harness, whose
2326
- // unpositioned #out made both spaces coincide). Walk the offsetParent chain
2327
- // and accumulate until termWrap so the math is correct under EITHER
2328
- // stylesheet, and under any future wrapper the UI grows between the two.
2329
- function rowTopInTermWrap(el: HTMLElement): number {
2330
- let top = 0;
2331
- let node: Element | null = el;
2332
- while (node instanceof HTMLElement && node !== termWrap) {
2333
- top += node.offsetTop;
2334
- node = node.offsetParent;
2335
- // offsetTop is measured from the offsetParent's padding edge, but an
2336
- // intermediate parent's own offsetTop locates its BORDER box — add its
2337
- // top border so the accumulation stays exact if a wrapper ever grows one
2338
- // (both current stylesheets use border: 0 here). termWrap itself is
2339
- // excluded: absolute children resolve from its padding edge already.
2340
- if (node instanceof HTMLElement && node !== termWrap) {
2341
- top += node.clientTop;
2342
- }
2343
- }
2344
- // Chain ended off termWrap (display:none, detached, or termWrap unpositioned
2345
- // with the row offset-rooted elsewhere): fall back to rect delta, which is
2346
- // space-independent. scrollTop re-bases the viewport-relative delta into
2347
- // content space.
2348
- if (node !== termWrap) {
2349
- return (
2350
- el.getBoundingClientRect().top - termWrap.getBoundingClientRect().top + termWrap.scrollTop
2351
- );
1789
+ function computeSize(): { cols: number; rows: number } {
1790
+ if (!termWrap) {
1791
+ return { cols: 0, rows: 0 };
1792
+ }
1793
+ const cs = window.getComputedStyle(termWrap);
1794
+ const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
1795
+ const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
1796
+ const contentW = termWrap.clientWidth - padX;
1797
+ const contentH = termWrap.clientHeight - padY;
1798
+ const cols = Math.max(MIN_COLS, Math.floor(contentW / cellWidth));
1799
+ const rows = Math.max(MIN_ROWS, Math.floor(contentH / cellHeight));
1800
+ measuredCols = cols;
1801
+ return { cols, rows };
1802
+ }
1803
+
1804
+ // Rows are the store's, not `computeSize()`'s: the server's screen is
1805
+ // last-writer-wins across attached clients, so the measured height can
1806
+ // describe a grid nothing is painting, and the mouse hit test ANCHORS on this
1807
+ // number. Columns stay measured: the store has none (trailing blanks are
1808
+ // trimmed off the wire) and that axis only bounds a report.
1809
+ function gridSize(): { cols: number; rows: number } {
1810
+ if (!store) {
1811
+ return { cols: 0, rows: 0 };
1812
+ }
1813
+ const rows = store.isAlt() ? store.getAltRows().length : store.getWindow().height;
1814
+ return { cols: measuredCols > 0 ? measuredCols : computeSize().cols, rows };
1815
+ }
1816
+
1817
+ // A row's top in termWrap's coordinate space, which every absolutely
1818
+ // positioned overlay resolves against. A bare `offsetTop` is relative to the
1819
+ // offsetParent, and a stylesheet that positions `.term-output` puts rows in
1820
+ // output-space, one padding short (the caret floated 4px above every glyph).
1821
+ // Walking the offsetParent chain is correct under either stylesheet.
1822
+ function rowTopInTermWrap(el: HTMLElement): number {
1823
+ const wrap = termWrap;
1824
+ if (!wrap) {
1825
+ return 0;
1826
+ }
1827
+ let top = 0;
1828
+ let node: Element | null = el;
1829
+ while (node instanceof HTMLElement && node !== wrap) {
1830
+ top += node.offsetTop;
1831
+ node = node.offsetParent;
1832
+ // An intermediate parent's offsetTop locates its BORDER box while
1833
+ // offsetTop is measured from the padding edge; termWrap's own absolute
1834
+ // children resolve from its padding edge already.
1835
+ if (node instanceof HTMLElement && node !== wrap) {
1836
+ top += node.clientTop;
1837
+ }
1838
+ }
1839
+ // Chain ended off termWrap (display:none, detached): the rect delta is
1840
+ // space-independent, re-based into content space by scrollTop.
1841
+ if (node !== wrap) {
1842
+ return el.getBoundingClientRect().top - wrap.getBoundingClientRect().top + wrap.scrollTop;
1843
+ }
1844
+ return top;
1845
+ }
1846
+
1847
+ // The ONE resolver for the caret, the predicted cursor and `getCursorPx`, so
1848
+ // the consumer's IME view is never told a different row than the caret paints
1849
+ // on (`rowEls` is empty for a whole alt-screen session). A built row answers
1850
+ // from its DOM offset; a real index with no row from grid arithmetic; `abs <
1851
+ // 0` is not a row at all and the content origin IS the accurate answer.
1852
+ function rowTopFor(abs: number): number {
1853
+ const el = rowEls.get(abs);
1854
+ if (el) {
1855
+ return rowTopInTermWrap(el);
1856
+ }
1857
+ const { padT } = termPadding();
1858
+ if (abs < 0 || !store) {
1859
+ return padT;
1860
+ }
1861
+ return padT + (abs - store.getWindow().base) * cellHeight;
2352
1862
  }
2353
- return top;
2354
- }
2355
-
2356
- // rowTopFor resolves the top of the row holding absolute line `abs`, in
2357
- // termWrap's coordinate space. Three positioners need that answer — the caret
2358
- // overlay, the predicted-cursor overlay, and `getCursorPx` for the consumer's
2359
- // IME view and hidden textarea — and they must not disagree, because they
2360
- // describe the same row. They did: `getCursorPx` fell back to a bare `padT`
2361
- // while the two that PAINT fell back to grid arithmetic, and `rowEls` is
2362
- // populated only by the main-screen row builder (`renderAlt` clears it), so for
2363
- // an ENTIRE alt-screen session the caret painted on the right row while the
2364
- // consumer was told the top of the terminal every frame.
2365
- //
2366
- // Three cases, in order:
2367
- // - the map holds a built row: its own DOM offset, walked into termWrap space.
2368
- // - no row for a real index (an alt-screen grid row, or a main row this flush
2369
- // has not built yet): uniform-grid arithmetic from the window base.
2370
- // - `abs < 0`: not a row at all. That is the state
2371
- // `collapseContentSpaceOverlays` leaves behind, where the cursor genuinely
2372
- // has no row and the content origin IS the accurate answer; the arithmetic
2373
- // would put the overlay a cell above it.
2374
- function rowTopFor(abs: number): number {
2375
- const el = rowEls.get(abs);
2376
- if (el) {
2377
- return rowTopInTermWrap(el);
2378
- }
2379
- const { padT } = termPadding();
2380
- if (abs < 0) {
2381
- return padT;
2382
- }
2383
- return padT + (abs - store.getWindow().base) * cellHeight;
2384
- }
2385
1863
 
2386
- /**
2387
- * Returns the cursor's pixel position relative to the scroll container
2388
- * (termWrap) — the coordinate space of the absolutely-positioned overlays
2389
- * (predicted-cursor, IME composition view) — plus the current cell height.
2390
- * Uses the cursor row's actual DOM offset, or the grid position of a row that
2391
- * has none (see rowTopFor).
2392
- */
2393
- export function getCursorPx(): { left: number; top: number; cellH: number } {
2394
- const { padL } = termPadding();
2395
- return {
2396
- left: Math.round(padL + cursorCol * cellWidth),
2397
- top: Math.round(rowTopFor(cursorAbs)),
2398
- cellH: cellHeight,
2399
- };
2400
- }
1864
+ function getCursorPx(): { left: number; top: number; cellH: number } {
1865
+ if (disposed) {
1866
+ return { left: 0, top: 0, cellH: 0 };
1867
+ }
1868
+ const { padL } = termPadding();
1869
+ return {
1870
+ left: Math.round(padL + cursorCol * cellWidth),
1871
+ top: Math.round(rowTopFor(cursorAbs)),
1872
+ cellH: cellHeight,
1873
+ };
1874
+ }
2401
1875
 
2402
- /**
2403
- * The measured pixel size of one cell — what `mouse.MouseInputHandler.cellSize`
2404
- * wants. Only meaningful once `updateFontMetrics` has measured this terminal's
2405
- * font; before that it answers the module's power-on fallback, not the font in
2406
- * use.
2407
- */
2408
- export function cellSize(): { width: number; height: number } {
2409
- return { width: cellWidth, height: cellHeight };
2410
- }
1876
+ function cellSize(): { width: number; height: number } {
1877
+ return disposed ? { width: 0, height: 0 } : { width: cellWidth, height: cellHeight };
1878
+ }
2411
1879
 
2412
- // --- Caret overlay ---
2413
- //
2414
- // The real cursor is a single absolutely-positioned element appended to
2415
- // termWrap (the positioned scroll container, same coordinate system as the
2416
- // predicted-cursor overlay), NOT a restyled span inside the cursor row. Rows
2417
- // stay pure content: cursor motion repositions this element and never
2418
- // rewrites row DOM, so a native selection survives typing — including on the
2419
- // cursor row itself — and the per-keystroke row rebuild is gone.
2420
- let cursorEl: HTMLElement | null = null;
2421
-
2422
- function ensureCursorEl(): HTMLElement {
2423
- if (cursorEl === null) {
2424
- cursorEl = document.createElement("div");
2425
- cursorEl.setAttribute("aria-hidden", "true");
2426
- termWrap.appendChild(cursorEl);
2427
- }
2428
- return cursorEl;
2429
- }
1880
+ // --- Caret overlay: one absolutely positioned child of termWrap ---
1881
+ let cursorEl: HTMLElement | null = null;
2430
1882
 
2431
- // glyphAt returns the character occupying `col` in a row's runs, advancing
2432
- // columns exactly like buildRowSpans (each char one cell; the \uFFFF
2433
- // continuation placeholder occupies the wide char's second cell). A miss —
2434
- // cursor past end of text, empty row, or a continuation cell — reads as a
2435
- // space (the block cursor then paints an inverted blank, matching a real
2436
- // terminal).
2437
- function glyphAt(runs: readonly WireRun[] | undefined, col: number): string {
2438
- if (!runs) {
2439
- return " ";
1883
+ function ensureCursorEl(): HTMLElement {
1884
+ if (cursorEl === null) {
1885
+ cursorEl = document.createElement("div");
1886
+ cursorEl.setAttribute("aria-hidden", "true");
1887
+ termWrap?.appendChild(cursorEl);
1888
+ }
1889
+ return cursorEl;
2440
1890
  }
2441
- let c = 0;
2442
- for (const run of runs) {
2443
- if (!run.t) {
2444
- continue;
1891
+
1892
+ // The character at `col`, advancing columns exactly like buildRowSpans (the
1893
+ // \uFFFF placeholder occupies a wide char's second cell). A miss reads as a
1894
+ // space, so the block cursor paints an inverted blank like a real terminal.
1895
+ function glyphAt(runs: readonly WireRun[] | undefined, col: number): string {
1896
+ if (!runs) {
1897
+ return " ";
2445
1898
  }
2446
- for (const ch of run.t) {
2447
- if (c === col) {
2448
- return ch === "\uFFFF" ? " " : ch;
1899
+ let c = 0;
1900
+ for (const run of runs) {
1901
+ if (!run.t) {
1902
+ continue;
1903
+ }
1904
+ for (const ch of run.t) {
1905
+ if (c === col) {
1906
+ return ch === "\uFFFF" ? " " : ch;
1907
+ }
1908
+ c++;
2449
1909
  }
2450
- c++;
2451
1910
  }
1911
+ return " ";
2452
1912
  }
2453
- return " ";
2454
- }
2455
1913
 
2456
- // positionCursorOverlay moves/styles the caret overlay for the current cursor
2457
- // state. `runs` is the cursor row's content (main: store.getLine; alt: the
2458
- // grid row) — the block style copies the glyph under the cursor so the
2459
- // inverted cell looks exactly like the old inline-span cursor. Called at the
2460
- // end of every flush; hidden when the cursor is hidden or the screen is empty.
2461
- function positionCursorOverlay(runs: readonly WireRun[] | undefined): void {
2462
- const el = ensureCursorEl();
2463
- if (cursorHidden || cursorAbs < 0) {
2464
- el.className = "term-cursor-overlay";
2465
- return;
2466
- }
2467
- const { padL } = termPadding();
2468
- // Alt-screen rows are not registered in rowEls; their grid is uniform, so the
2469
- // row offset derives from the window-relative row index. Main-screen rows
2470
- // exist by the time this runs (the flush force-builds the cursor row).
2471
- // rowTopFor is shared with getCursorPx so the consumer's IME view cannot be
2472
- // told a different row than the one the caret paints on.
2473
- const top = rowTopFor(cursorAbs);
2474
- const ch = glyphAt(runs, cursorCol);
2475
- // A wide glyph (CJK, emoji) owns two cells; the overlay covers both, like
2476
- // the old inline cursor span did via its continuation-spacer adjustment.
2477
- const wide = measureChar(ch, false, false) > cellWidth * 1.5;
2478
- el.textContent = ch === " " ? "\u00a0" : ch;
2479
- el.className = `term-cursor-overlay visible ${cursorClassName()}`;
2480
- el.style.left = `${Math.round(padL + cursorCol * cellWidth)}px`;
2481
- el.style.top = `${Math.round(top)}px`;
2482
- el.style.width = `${wide ? cellWidth * 2 : cellWidth}px`;
2483
- el.style.height = `${cellHeight}px`;
2484
- el.style.lineHeight = `${cellHeight}px`;
2485
- }
1914
+ // `runs` is the cursor row's content (main: store.getLine; alt: the grid row);
1915
+ // the block style copies the glyph under the cursor so the inverted cell reads
1916
+ // as text. Hidden when the cursor is hidden or the screen is empty.
1917
+ function positionCursorOverlay(runs: readonly WireRun[] | undefined): void {
1918
+ const el = ensureCursorEl();
1919
+ if (cursorHidden || cursorAbs < 0) {
1920
+ el.className = "term-cursor-overlay";
1921
+ return;
1922
+ }
1923
+ const { padL } = termPadding();
1924
+ const top = rowTopFor(cursorAbs);
1925
+ const ch = glyphAt(runs, cursorCol);
1926
+ // A wide glyph (CJK, emoji) owns two cells; the overlay covers both.
1927
+ const wide = measureChar(ch, false, false) > cellWidth * 1.5;
1928
+ el.textContent = ch === " " ? "\u00a0" : ch;
1929
+ el.className = `term-cursor-overlay visible ${cursorClassName()}`;
1930
+ el.style.left = `${Math.round(padL + cursorCol * cellWidth)}px`;
1931
+ el.style.top = `${Math.round(top)}px`;
1932
+ el.style.width = `${wide ? cellWidth * 2 : cellWidth}px`;
1933
+ el.style.height = `${cellHeight}px`;
1934
+ el.style.lineHeight = `${cellHeight}px`;
1935
+ }
1936
+
1937
+ let predCursorEl: HTMLElement | null = null;
1938
+
1939
+ // Renderer-owned, so the engine never depends on a host-provided scaffold
1940
+ // element; styled by the consumer's `.pred-cursor` rule.
1941
+ function createPredCursorEl(): HTMLElement {
1942
+ const el = document.createElement("div");
1943
+ el.className = "pred-cursor";
1944
+ el.setAttribute("aria-hidden", "true");
1945
+ termWrap?.appendChild(el);
1946
+ return el;
1947
+ }
1948
+
1949
+ function setPredictedCursor(row: number, col: number, active: boolean): void {
1950
+ if (!store) {
1951
+ return;
1952
+ }
1953
+ const el = predCursorEl ?? (predCursorEl = createPredCursorEl());
1954
+ const win = store.getWindow();
1955
+ const predAbs = win.base + row;
1956
+ if (!active || (predAbs === cursorAbs && col === cursorCol)) {
1957
+ el.classList.remove("visible");
1958
+ return;
1959
+ }
1960
+ const { padL } = termPadding();
1961
+ // A prediction below the first line of the session (`predAbs < 0`) lands on
1962
+ // the content origin; a negative top would paint over the top padding.
1963
+ const top = rowTopFor(predAbs);
1964
+ el.style.left = `${Math.round(padL + col * cellWidth)}px`;
1965
+ el.style.top = `${Math.round(top)}px`;
1966
+ el.style.width = `${cellWidth}px`;
1967
+ el.style.height = `${cellHeight}px`;
1968
+ el.classList.add("visible");
1969
+ }
1970
+
1971
+ function updateReverseVideo(): void {
1972
+ if (!termWrap) {
1973
+ return;
1974
+ }
1975
+ if (modes.isReverseVideo()) {
1976
+ termWrap.classList.add("term-reverse-video");
1977
+ } else {
1978
+ termWrap.classList.remove("term-reverse-video");
1979
+ }
1980
+ }
2486
1981
 
2487
- let predCursorEl: HTMLElement | null = null;
2488
-
2489
- // Create the predicted-cursor overlay the renderer owns. Appended to termWrap
2490
- // (the positioned scroll container) so its absolute left/top math matches the
2491
- // row offsets. Styled by the `.pred-cursor` class from the UI CSS bundle. The
2492
- // renderer owning this means the engine never depends on a host-provided
2493
- // `#pred-cursor` scaffold element.
2494
- function createPredCursorEl(): HTMLElement {
2495
- const el = document.createElement("div");
2496
- el.className = "pred-cursor";
2497
- el.setAttribute("aria-hidden", "true");
2498
- termWrap.appendChild(el);
2499
- return el;
2500
- }
1982
+ // Pause the blink interval while hidden; resume (cursor solid, phase reset)
1983
+ // when the tab is foregrounded again. blinkEnabled/cursorHidden keep their
1984
+ // server-driven state across a background stint.
1985
+ const onVisibilityChange = (): void => {
1986
+ syncCursorBlink();
1987
+ };
2501
1988
 
2502
- /**
2503
- * Show or hide a "predicted" cursor overlay at window-relative (row, col).
2504
- * Useful for client-side echo of typed characters before the server
2505
- * acknowledges them, over high-latency connections. The overlay element is
2506
- * created lazily on first use (a consumer that never predicts never creates
2507
- * it).
2508
- */
2509
- export function setPredictedCursor(row: number, col: number, active: boolean): void {
2510
- const el = predCursorEl ?? (predCursorEl = createPredCursorEl());
2511
- const win = store.getWindow();
2512
- const predAbs = win.base + row;
2513
- if (!active || (predAbs === cursorAbs && col === cursorCol)) {
2514
- el.classList.remove("visible");
2515
- return;
2516
- }
2517
- const { padL } = termPadding();
2518
- // Same resolver as the caret and getCursorPx. A row the flush has not built
2519
- // yet answers from grid arithmetic, which for `predAbs >= 0` is exactly the
2520
- // `padT + row * cellHeight` this used to compute inline. A prediction BELOW
2521
- // the first line of the session (`predAbs < 0`) now lands on the content
2522
- // origin instead of a negative top: that row does not exist, and an overlay
2523
- // at a negative offset paints over the terminal's top padding.
2524
- const top = rowTopFor(predAbs);
2525
- el.style.left = `${Math.round(padL + col * cellWidth)}px`;
2526
- el.style.top = `${Math.round(top)}px`;
2527
- el.style.width = `${cellWidth}px`;
2528
- el.style.height = `${cellHeight}px`;
2529
- el.classList.add("visible");
2530
- }
1989
+ function dispose(): void {
1990
+ if (disposed) {
1991
+ return;
1992
+ }
1993
+ disposed = true;
1994
+ if (pendingFrame !== undefined) {
1995
+ cancelAnimationFrame(pendingFrame);
1996
+ pendingFrame = undefined;
1997
+ }
1998
+ if (blinkInterval !== null) {
1999
+ clearInterval(blinkInterval);
2000
+ blinkInterval = null;
2001
+ }
2002
+ blinkMode = "off";
2003
+ document.removeEventListener("visibilitychange", onVisibilityChange);
2004
+ resetVariantContexts();
2005
+ widthFlat.fill(WIDTH_FLAT_UNSET);
2006
+ widthMap.clear();
2007
+ rowEls.clear();
2008
+ for (const el of gapMarkerEls.values()) {
2009
+ el.remove();
2010
+ }
2011
+ gapMarkerEls.clear();
2012
+ renderQueue.clear();
2013
+ requestHistoryFn = null;
2014
+ historyBudgetFn = null;
2015
+ onCursorMove = null;
2016
+ pendingRestore = null;
2017
+ store = null;
2018
+ altPrevRows = [];
2019
+ altRendered = false;
2020
+ trimMarkerEl?.remove();
2021
+ trimMarkerEl = null;
2022
+ cursorEl?.remove();
2023
+ cursorEl = null;
2024
+ predCursorEl?.remove();
2025
+ predCursorEl = null;
2026
+ if (output) {
2027
+ output.replaceChildren();
2028
+ output.style.letterSpacing = "";
2029
+ }
2030
+ if (termWrap) {
2031
+ termWrap.classList.remove("cursor-blink-off", "term-reverse-video");
2032
+ termWrap.style.removeProperty("--char-w");
2033
+ }
2034
+ output = null;
2035
+ termWrap = null;
2036
+ }
2531
2037
 
2532
- /** Apply or remove the reverse-video class on the terminal output.
2533
- * When DECSCNM (mode 5) is active, default fg/bg are swapped via CSS. */
2534
- export function updateReverseVideo(): void {
2535
- if (isReverseVideo()) {
2536
- termWrap.classList.add("term-reverse-video");
2537
- } else {
2538
- termWrap.classList.remove("term-reverse-video");
2038
+ try {
2039
+ document.addEventListener("visibilitychange", onVisibilityChange);
2040
+ syncCursorBlink();
2041
+ } catch (err) {
2042
+ dispose();
2043
+ throw err;
2539
2044
  }
2045
+
2046
+ return {
2047
+ resetScreen,
2048
+ resetScrollback,
2049
+ bind,
2050
+ boundStore,
2051
+ rebuild,
2052
+ getHighestIndex,
2053
+ getReplayBoundary,
2054
+ noteResumeBounds,
2055
+ handleHistoryReply,
2056
+ applyResumeTransition,
2057
+ noteSolicited,
2058
+ clearSolicited,
2059
+ dropBrowseCache,
2060
+ lastBrowseActivityMs,
2061
+ browseCacheSize,
2062
+ replayMaxForResume,
2063
+ pendingRowCount,
2064
+ handleScreen,
2065
+ handleScroll,
2066
+ handleScrollPosition,
2067
+ captureViewMemory,
2068
+ pendingRestoreAbs,
2069
+ maybeFetchHistory,
2070
+ updateFontMetrics,
2071
+ computeSize,
2072
+ gridSize,
2073
+ getCursorPx,
2074
+ cellSize,
2075
+ setPredictedCursor,
2076
+ updateReverseVideo,
2077
+ dispose,
2078
+ };
2540
2079
  }