@cplieger/web-terminal-engine 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/render.ts ADDED
@@ -0,0 +1,823 @@
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 (see the #web-terminal-engine steering doc, "Design rationale").
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.
17
+
18
+ import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
19
+ import { LineStore } from "./store.js";
20
+ import * as scroll from "./scroll.js";
21
+ import { isReverseVideo } from "./modes.js";
22
+
23
+ // --- Width cache (two-tier, xterm.js style) ---
24
+ const WIDTH_FLAT_SIZE = 256;
25
+ const WIDTH_FLAT_UNSET = -9999;
26
+ const widthFlat = new Float32Array(WIDTH_FLAT_SIZE).fill(WIDTH_FLAT_UNSET);
27
+ const widthMap = new Map<string, number>();
28
+
29
+ const VARIANT_REGULAR = 0;
30
+ const VARIANT_BOLD = 1;
31
+ const VARIANT_ITALIC = 2;
32
+ const variantCtx: (CanvasRenderingContext2D | null)[] = [null, null, null, null];
33
+ let fontString = "";
34
+
35
+ function variantContext(variant: number): CanvasRenderingContext2D {
36
+ let ctx = variantCtx[variant];
37
+ if (ctx) {
38
+ return ctx;
39
+ }
40
+ const canvas = document.createElement("canvas");
41
+ canvas.width = 1;
42
+ canvas.height = 1;
43
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 2d context always available on fresh canvas
44
+ ctx = canvas.getContext("2d")!;
45
+ let f = "";
46
+ if (variant & VARIANT_ITALIC) {
47
+ f += "italic ";
48
+ }
49
+ if (variant & VARIANT_BOLD) {
50
+ f += "bold ";
51
+ }
52
+ f += fontString;
53
+ ctx.font = f;
54
+ variantCtx[variant] = ctx;
55
+ return ctx;
56
+ }
57
+
58
+ function resetVariantContexts(): void {
59
+ for (let i = 0; i < variantCtx.length; i++) {
60
+ variantCtx[i] = null;
61
+ }
62
+ }
63
+
64
+ function measureChar(ch: string, bold: boolean, italic: boolean): number {
65
+ if (!bold && !italic && ch.length === 1) {
66
+ const cp = ch.charCodeAt(0);
67
+ if (cp < WIDTH_FLAT_SIZE) {
68
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked above
69
+ const cached = widthFlat[cp]!;
70
+ if (cached !== WIDTH_FLAT_UNSET) {
71
+ return cached;
72
+ }
73
+ const w = variantContext(VARIANT_REGULAR).measureText(ch).width;
74
+ widthFlat[cp] = w;
75
+ return w;
76
+ }
77
+ }
78
+ const key = (bold ? "B" : "") + (italic ? "I" : "") + ch;
79
+ const cached = widthMap.get(key);
80
+ if (cached !== undefined) {
81
+ return cached;
82
+ }
83
+ let variant = 0;
84
+ if (bold) {
85
+ variant |= VARIANT_BOLD;
86
+ }
87
+ if (italic) {
88
+ variant |= VARIANT_ITALIC;
89
+ }
90
+ const w = variantContext(variant).measureText(ch).width;
91
+ widthMap.set(key, w);
92
+ return w;
93
+ }
94
+
95
+ function measureCellWidth(): number {
96
+ // Measure using a span appended to termWrap (which already has the
97
+ // font applied via CSS). This ensures the web font is used if loaded.
98
+ const span = document.createElement("span");
99
+ span.style.visibility = "hidden";
100
+ span.style.position = "absolute";
101
+ span.style.whiteSpace = "pre";
102
+ span.textContent = "MMMMMMMMMM";
103
+ termWrap.appendChild(span);
104
+ const width = span.getBoundingClientRect().width / 10;
105
+ termWrap.removeChild(span);
106
+ return width;
107
+ }
108
+
109
+ // --- State ---
110
+ let output: HTMLElement;
111
+ let termWrap: HTMLElement;
112
+
113
+ const store = new LineStore();
114
+ // abs index -> its row element. The DOM children of `output` are these
115
+ // elements, kept in ascending data-abs order.
116
+ const rowEls = new Map<number, HTMLDivElement>();
117
+
118
+ // The "earlier output trimmed" marker (a non-data-abs first child of output,
119
+ // shown when the store reports history older than it holds was trimmed). Kept
120
+ // as a module ref so it is reused rather than recreated each flush.
121
+ let trimMarkerEl: HTMLDivElement | null = null;
122
+
123
+ // Rows awaiting a DOM (re)build, processed in budgeted batches across frames.
124
+ // A session restore (kiro-cli's /chat) or a `cat bigfile` dumps thousands of
125
+ // lines in one wire frame; building them all in a single rAF janks or, on a
126
+ // constrained device, hangs the tab. The store still ingests the whole burst
127
+ // at once (it is cheap, pure data); the renderer drains this queue at most
128
+ // MAX_ROWS_PER_FRAME per frame and reschedules until it is empty, so each
129
+ // frame stays short and the terminal fills smoothly. The cursor row is always
130
+ // built regardless of the budget so the caret never lags.
131
+ const renderQueue = new Set<number>();
132
+ const MAX_ROWS_PER_FRAME = 300;
133
+
134
+ // Cursor state, refreshed from the store window on each flush. Kept as module
135
+ // vars because buildRowSpans/cursorClassName read them.
136
+ let cursorAbs = -1; // absolute index of the row the cursor is on
137
+ let cursorCol = 0;
138
+ let cursorHidden = false;
139
+ let cursorStyleVal = 0; // 0-6: DECSCUSR
140
+
141
+ function cursorClassName(): string {
142
+ // DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
143
+ // 4=steady underline, 5=blinking bar, 6=steady bar
144
+ if (cursorStyleVal === 3 || cursorStyleVal === 4) {
145
+ return "term-cursor-underline";
146
+ }
147
+ if (cursorStyleVal === 5 || cursorStyleVal === 6) {
148
+ return "term-cursor-bar";
149
+ }
150
+ return "term-cursor";
151
+ }
152
+ let cellWidth = 8;
153
+ let cellHeight = 17;
154
+ let defaultSpacing = 0;
155
+ let onCursorMove: (() => void) | null = null;
156
+ let pendingFrame: number | undefined;
157
+
158
+ /**
159
+ * Initialize the renderer by attaching it to a pair of DOM elements: the
160
+ * scrollable terminal wrapper and the inner output container that receives
161
+ * row elements. Must be called once before any handleScreen/handleScroll call.
162
+ *
163
+ * @param opts.output Inner element that holds row children.
164
+ * @param opts.termWrap Outer scroll container.
165
+ * @param opts.onCursorMove Optional callback invoked when the cursor moves.
166
+ */
167
+ export function init(opts: {
168
+ output: HTMLElement;
169
+ termWrap: HTMLElement;
170
+ onCursorMove?: () => void;
171
+ }): void {
172
+ output = opts.output;
173
+ termWrap = opts.termWrap;
174
+ onCursorMove = opts.onCursorMove ?? null;
175
+ // Fresh attach: drop any prior model + DOM so re-init (and vitest's
176
+ // non-isolated module reuse) starts clean.
177
+ store.reset();
178
+ rowEls.clear();
179
+ renderQueue.clear();
180
+ trimMarkerEl = null;
181
+ output.replaceChildren();
182
+ cursorAbs = -1;
183
+ if (pendingFrame !== undefined) {
184
+ cancelAnimationFrame(pendingFrame);
185
+ pendingFrame = undefined;
186
+ }
187
+ startCursorBlink();
188
+ }
189
+
190
+ /**
191
+ * Reset internal screen state so the next frame performs a full repaint.
192
+ * With the store model this is a full reset (used on server restart): the
193
+ * store clears and the next flush wipes and rebuilds the DOM.
194
+ */
195
+ export function resetScreen(): void {
196
+ store.reset();
197
+ scheduleFlush();
198
+ }
199
+
200
+ /**
201
+ * Clear all rows (history + window). Used on server restart alongside
202
+ * resetScreen; both reset the store, so this is equivalent.
203
+ */
204
+ export function resetScrollback(): void {
205
+ store.reset();
206
+ scheduleFlush();
207
+ }
208
+
209
+ /**
210
+ * Highest absolute line index the client holds, or -1 if empty. This is the
211
+ * resume `haveThrough` value (it replaces the old DOM-row count). Exposed so
212
+ * the connection layer can request only the lines the client is missing.
213
+ */
214
+ export function getHighestIndex(): number {
215
+ return store.highestIndex();
216
+ }
217
+
218
+ /**
219
+ * Record the server's retained-history bounds from a resumeAck (committed =
220
+ * one past the newest retained, oldest = oldest retained absolute index). The
221
+ * store uses these to tell a genuine history trim (the server evicted lines
222
+ * the client was missing) from a still-loading state, which drives the
223
+ * "earlier output trimmed" marker. Resync guard 8.2.2.
224
+ */
225
+ export function noteResumeBounds(committed: number, oldest: number): void {
226
+ store.noteResumeBounds(committed, oldest);
227
+ scheduleFlush();
228
+ }
229
+
230
+ // --- Color helpers ---
231
+ function colorHex(c: number | undefined): string | null {
232
+ if (c === undefined || c < 0) {
233
+ return null;
234
+ }
235
+ return "#" + c.toString(16).padStart(6, "0");
236
+ }
237
+
238
+ // --- URL detection (xterm.js addon-web-links pattern) ---
239
+ const URL_RE = /(https?|HTTPS?):\/\/[^\s"'!*(){}|\\^<>`]*[^\s"':,.!?{}|\\^~[\]`()<>]/g;
240
+
241
+ function linkifySpans(
242
+ spans: (HTMLSpanElement | HTMLAnchorElement)[],
243
+ ): (HTMLSpanElement | HTMLAnchorElement)[] {
244
+ const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
245
+ for (const span of spans) {
246
+ // Pass anchors through untouched. A span may already be an <a> from an
247
+ // OSC 8 hyperlink emitted by the application (see buildRowSpans). The
248
+ // app-provided href is authoritative and takes precedence over heuristic
249
+ // autolinking — re-scanning it with URL_RE would rebuild the link from
250
+ // the *visible* text, which for a URL that wraps across rows is only a
251
+ // fragment. That truncates the href and defeats the terminal's
252
+ // clickable-across-line-wraps behavior. Skip; only autolink plain text.
253
+ if (span.tagName === "A") {
254
+ out.push(span);
255
+ continue;
256
+ }
257
+ const text = span.textContent;
258
+ URL_RE.lastIndex = 0;
259
+ let match: RegExpExecArray | null;
260
+ let last = 0;
261
+ let found = false;
262
+ while ((match = URL_RE.exec(text)) !== null) {
263
+ found = true;
264
+ if (match.index > last) {
265
+ const pre = span.cloneNode(false) as HTMLSpanElement;
266
+ pre.textContent = text.slice(last, match.index);
267
+ out.push(pre);
268
+ }
269
+ const a = document.createElement("a");
270
+ a.href = match[0];
271
+ a.target = "_blank";
272
+ a.rel = "noopener noreferrer";
273
+ a.className = "term-link";
274
+ a.textContent = match[0];
275
+ // Copy inline styles from the source span
276
+ a.style.cssText = span.style.cssText;
277
+ out.push(a);
278
+ last = match.index + match[0].length;
279
+ }
280
+ if (!found) {
281
+ out.push(span);
282
+ } else if (last < text.length) {
283
+ const post = span.cloneNode(false) as HTMLSpanElement;
284
+ post.textContent = text.slice(last);
285
+ out.push(post);
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
291
+ // --- Build row DOM ---
292
+ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HTMLAnchorElement)[] {
293
+ const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
294
+ let col = 0;
295
+ for (const run of runs) {
296
+ if (!run.t) {
297
+ continue;
298
+ }
299
+ const runStartIdx = out.length;
300
+ const attrs = run.a ?? 0;
301
+ const isBold = (attrs & 1) !== 0;
302
+ const isItalic = (attrs & 2) !== 0;
303
+ const isUnderline = (attrs & 4) !== 0;
304
+ const isInverse = (attrs & 8) !== 0;
305
+ const isStrike = (attrs & 16) !== 0;
306
+ const isDim = (attrs & 32) !== 0;
307
+ const isHidden = (attrs & 64) !== 0;
308
+ const isBlink = (attrs & 128) !== 0;
309
+ const isOverline = (attrs & 256) !== 0;
310
+ const isDoubleUnderline = (attrs & 512) !== 0;
311
+
312
+ // Server swaps FG/BG for inverse in wire.go, but when both are
313
+ // default (-1) the swap is a no-op. Detect inverse + defaults and
314
+ // apply theme-inverted colors so the inverted space is visible.
315
+ let fg = colorHex(run.f);
316
+ let bg = colorHex(run.b);
317
+ if (isInverse && fg === null && bg === null) {
318
+ fg = "var(--bg)";
319
+ bg = "var(--text)";
320
+ }
321
+ const ucColor = colorHex(run.uc);
322
+
323
+ const applyStyle = (span: HTMLSpanElement, spacing: number): void => {
324
+ if (isHidden) {
325
+ span.style.visibility = "hidden";
326
+ }
327
+ if (fg !== null) {
328
+ span.style.color = fg;
329
+ }
330
+ if (bg !== null) {
331
+ span.style.background = bg;
332
+ }
333
+ if (isBold) {
334
+ span.style.fontWeight = "bold";
335
+ }
336
+ if (isItalic) {
337
+ span.style.fontStyle = "italic";
338
+ }
339
+ if (isDim) {
340
+ span.style.opacity = ".5";
341
+ }
342
+ // Build text-decoration combining all line types.
343
+ const decoLines: string[] = [];
344
+ if (isDoubleUnderline) {
345
+ decoLines.push("underline");
346
+ } else if (isUnderline) {
347
+ decoLines.push("underline");
348
+ }
349
+ if (isOverline) {
350
+ decoLines.push("overline");
351
+ }
352
+ if (isStrike) {
353
+ decoLines.push("line-through");
354
+ }
355
+ if (decoLines.length > 0) {
356
+ let deco = decoLines.join(" ");
357
+ if (isDoubleUnderline) {
358
+ deco += " double";
359
+ }
360
+ span.style.textDecoration = deco;
361
+ }
362
+ if (ucColor !== null) {
363
+ span.style.textDecorationColor = ucColor;
364
+ }
365
+ if (spacing !== defaultSpacing) {
366
+ span.style.letterSpacing = `${spacing}px`;
367
+ }
368
+ if (isBlink) {
369
+ span.classList.add("term-blink");
370
+ }
371
+ };
372
+
373
+ let prevSpacing: number | null = null;
374
+ let buffer = "";
375
+ const flush = (): void => {
376
+ if (buffer.length === 0) {
377
+ return;
378
+ }
379
+ const span = document.createElement("span");
380
+ span.textContent = buffer;
381
+ applyStyle(span, prevSpacing ?? 0);
382
+ out.push(span);
383
+ buffer = "";
384
+ };
385
+ for (const ch of run.t) {
386
+ if (ch === "\uFFFF") {
387
+ // Wide-char continuation placeholder: mark previous span as double-width.
388
+ // Flush any buffered text first so the wide char is in its own span.
389
+ flush();
390
+ if (out.length > 0) {
391
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
392
+ const prev = out[out.length - 1]!;
393
+
394
+ const prevText = prev.textContent;
395
+ if (prevText.length > 0) {
396
+ // 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
397
+ const lastChar = [...prevText].at(-1)!;
398
+ const w = measureChar(lastChar, isBold, isItalic);
399
+ prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
400
+ }
401
+ }
402
+ continue;
403
+ }
404
+ if (col === cursorAt) {
405
+ flush();
406
+ const w = measureChar(ch, isBold, isItalic);
407
+ const spacing = cellWidth - w;
408
+ const span = document.createElement("span");
409
+ span.className = cursorClassName();
410
+ span.textContent = ch;
411
+ if (spacing !== defaultSpacing) {
412
+ span.style.letterSpacing = `${spacing}px`;
413
+ }
414
+ out.push(span);
415
+ col++;
416
+ continue;
417
+ }
418
+ const w = measureChar(ch, isBold, isItalic);
419
+ const spacing = cellWidth - w;
420
+ if (prevSpacing === null) {
421
+ prevSpacing = spacing;
422
+ } else if (spacing !== prevSpacing) {
423
+ flush();
424
+ prevSpacing = spacing;
425
+ }
426
+ buffer += ch;
427
+ col++;
428
+ }
429
+ flush();
430
+ // Wrap spans from this run in an <a> if it has a hyperlink URL.
431
+ const href = run.u && /^https?:\/\//i.test(run.u) ? run.u : null;
432
+ if (href) {
433
+ const a = document.createElement("a");
434
+ a.href = href;
435
+ a.target = "_blank";
436
+ a.rel = "noopener";
437
+ a.className = "term-link";
438
+ // Move spans from this run into the anchor.
439
+ const runSpans = out.splice(runStartIdx);
440
+ for (const s of runSpans) {
441
+ a.appendChild(s);
442
+ }
443
+ out.push(a);
444
+ }
445
+ }
446
+ if (cursorAt >= 0 && col <= cursorAt) {
447
+ while (col < cursorAt) {
448
+ const span = document.createElement("span");
449
+ span.textContent = " ";
450
+ out.push(span);
451
+ col++;
452
+ }
453
+ const cursor = document.createElement("span");
454
+ cursor.className = cursorClassName();
455
+ cursor.textContent = " ";
456
+ out.push(cursor);
457
+ }
458
+ if (out.length === 0) {
459
+ const span = document.createElement("span");
460
+ span.innerHTML = "&nbsp;";
461
+ out.push(span);
462
+ }
463
+ return linkifySpans(out);
464
+ }
465
+
466
+ // --- Frame handling: feed the store, then flush to DOM ---
467
+
468
+ /**
469
+ * Apply a `ScreenMessage`: update the store's window + changed rows, then
470
+ * schedule a render flush. The store handles merging changed rows by absolute
471
+ * index, so no client-side frame coalescing is needed.
472
+ */
473
+ export function handleScreen(msg: ScreenMessage): void {
474
+ store.applyScreen(msg);
475
+ scheduleFlush();
476
+ }
477
+
478
+ /**
479
+ * Apply a `ScrollMessage`: commit history lines into the store by absolute
480
+ * index, then schedule a render flush.
481
+ */
482
+ export function handleScroll(msg: ScrollMessage): void {
483
+ store.applyScroll(msg);
484
+ scheduleFlush();
485
+ }
486
+
487
+ function scheduleFlush(): void {
488
+ if (pendingFrame !== undefined) {
489
+ return;
490
+ }
491
+ pendingFrame = requestAnimationFrame(flushRender);
492
+ }
493
+
494
+ function flushRender(): void {
495
+ pendingFrame = undefined;
496
+ try {
497
+ flushRenderInner();
498
+ } catch (err) {
499
+ console.error("vterm: render error", err);
500
+ }
501
+ // Single auto-follow invariant, applied after every DOM mutation.
502
+ stickToBottomIfFollowing();
503
+ }
504
+
505
+ function flushRenderInner(): void {
506
+ const ch = store.drainChanges();
507
+
508
+ if (ch.fullReset) {
509
+ output.replaceChildren();
510
+ rowEls.clear();
511
+ renderQueue.clear();
512
+ trimMarkerEl = null;
513
+ cursorAbs = -1;
514
+ } else {
515
+ for (const abs of ch.evictedLines) {
516
+ const el = rowEls.get(abs);
517
+ if (el) {
518
+ el.remove();
519
+ }
520
+ rowEls.delete(abs);
521
+ renderQueue.delete(abs);
522
+ }
523
+ }
524
+
525
+ // Refresh cursor state from the window.
526
+ const win = store.getWindow();
527
+ const newCursorAbs = win.base + win.cursorRow;
528
+ const prevCursorAbs = cursorAbs;
529
+ cursorAbs = newCursorAbs;
530
+ cursorCol = win.cursorCol;
531
+ cursorHidden = win.cursorHidden;
532
+ cursorStyleVal = win.cursorStyle;
533
+ setCursorBlink(win.cursorBlink);
534
+
535
+ // Alt screen: render the ephemeral grid instead of the absolute buffer.
536
+ if (store.isAlt()) {
537
+ renderAlt(store.getAltRows());
538
+ if (onCursorMove) {
539
+ onCursorMove();
540
+ }
541
+ return;
542
+ }
543
+ if (altRendered) {
544
+ // Just exited alt: drop the ephemeral rows and rebuild from the store.
545
+ altRendered = false;
546
+ output.replaceChildren();
547
+ rowEls.clear();
548
+ renderQueue.clear();
549
+ trimMarkerEl = null;
550
+ store.forEachLine((abs) => renderQueue.add(abs));
551
+ }
552
+
553
+ // Queue this frame's changed rows for building.
554
+ for (const abs of ch.dirtyLines) {
555
+ renderQueue.add(abs);
556
+ }
557
+
558
+ // The cursor rows are built every frame regardless of the budget so the
559
+ // inline cursor span is always current (it moves off the old row and onto
560
+ // the new one); a huge backlog must never make the caret lag.
561
+ if (prevCursorAbs !== newCursorAbs && prevCursorAbs >= 0) {
562
+ upsertRow(prevCursorAbs);
563
+ renderQueue.delete(prevCursorAbs);
564
+ }
565
+ upsertRow(newCursorAbs);
566
+ renderQueue.delete(newCursorAbs);
567
+
568
+ // Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
569
+ // to the next frame (scheduled below) so one big burst never blocks paint.
570
+ let built = 0;
571
+ for (const abs of renderQueue) {
572
+ if (built >= MAX_ROWS_PER_FRAME) {
573
+ break;
574
+ }
575
+ upsertRow(abs);
576
+ renderQueue.delete(abs);
577
+ built++;
578
+ }
579
+
580
+ updateTrimMarker();
581
+
582
+ // More rows pending: keep draining on subsequent frames.
583
+ if (renderQueue.size > 0) {
584
+ scheduleFlush();
585
+ }
586
+
587
+ if (onCursorMove) {
588
+ onCursorMove();
589
+ }
590
+ }
591
+
592
+ // updateTrimMarker shows or hides the "earlier output trimmed" marker as the
593
+ // first child of output, driven by the store. It appears when history older
594
+ // than the store holds was trimmed — either the client evicted its oldest
595
+ // lines at the cap, or the server reported (via resumeAck bounds) that it no
596
+ // longer retains history the client was missing on resume. The marker carries
597
+ // no data-abs, so insertRowInOrder (which compares numeric data-abs) never
598
+ // places a row before it; it stays pinned at the top.
599
+ function updateTrimMarker(): void {
600
+ if (store.hasTrimmedHistory()) {
601
+ if (trimMarkerEl === null) {
602
+ trimMarkerEl = document.createElement("div");
603
+ trimMarkerEl.className = "term-trim-marker";
604
+ trimMarkerEl.setAttribute("role", "status");
605
+ trimMarkerEl.setAttribute("aria-label", "earlier output trimmed");
606
+ trimMarkerEl.textContent = "earlier output trimmed";
607
+ }
608
+ if (trimMarkerEl.parentElement !== output || output.firstChild !== trimMarkerEl) {
609
+ output.insertBefore(trimMarkerEl, output.firstChild);
610
+ }
611
+ } else if (trimMarkerEl !== null && trimMarkerEl.parentElement === output) {
612
+ trimMarkerEl.remove();
613
+ }
614
+ }
615
+
616
+ // upsertRow builds or updates the DOM row for an absolute index, or removes it
617
+ // if the store no longer holds it. New rows are inserted in ascending data-abs
618
+ // order.
619
+ function upsertRow(abs: number): void {
620
+ const runs = store.getLine(abs);
621
+ if (runs === undefined) {
622
+ const stale = rowEls.get(abs);
623
+ if (stale) {
624
+ stale.remove();
625
+ rowEls.delete(abs);
626
+ }
627
+ return;
628
+ }
629
+ const cursorAt = !cursorHidden && abs === cursorAbs ? cursorCol : -1;
630
+ const spans = buildRowSpans(runs, cursorAt);
631
+ let el = rowEls.get(abs);
632
+ if (el === undefined) {
633
+ el = document.createElement("div");
634
+ el.className = "term-row";
635
+ el.dataset["abs"] = String(abs);
636
+ el.replaceChildren(...spans);
637
+ insertRowInOrder(el, abs);
638
+ rowEls.set(abs, el);
639
+ } else {
640
+ el.replaceChildren(...spans);
641
+ }
642
+ }
643
+
644
+ // insertRowInOrder places a freshly-created row element among output's
645
+ // children so they stay in ascending data-abs order. The common case (a new
646
+ // highest index) is an O(1) append; out-of-order inserts scan for the slot.
647
+ function insertRowInOrder(el: HTMLDivElement, abs: number): void {
648
+ const last = output.lastElementChild as HTMLElement | null;
649
+ if (last === null || rowAbs(last) < abs) {
650
+ output.appendChild(el);
651
+ return;
652
+ }
653
+ for (const child of output.children) {
654
+ if (rowAbs(child as HTMLElement) > abs) {
655
+ output.insertBefore(el, child);
656
+ return;
657
+ }
658
+ }
659
+ output.appendChild(el);
660
+ }
661
+
662
+ function rowAbs(el: HTMLElement): number {
663
+ const v = el.dataset["abs"];
664
+ return v === undefined ? -1 : Number(v);
665
+ }
666
+
667
+ // --- Alt screen (ephemeral grid; no history) ---
668
+ let altRendered = false;
669
+
670
+ function renderAlt(rows: WireRun[][]): void {
671
+ altRendered = true;
672
+ rowEls.clear();
673
+ const els: HTMLDivElement[] = [];
674
+ for (let y = 0; y < rows.length; y++) {
675
+ const div = document.createElement("div");
676
+ div.className = "term-row";
677
+ const cursorAt = !cursorHidden && y === cursorAbs - store.getWindow().base ? cursorCol : -1;
678
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length
679
+ div.replaceChildren(...buildRowSpans(rows[y]!, cursorAt));
680
+ els.push(div);
681
+ }
682
+ output.replaceChildren(...els);
683
+ }
684
+
685
+ /** Pin the viewport to the bottom iff the user is following. The scroll
686
+ * controller (scroll.ts) owns scrollTop and the follow/hold decision. */
687
+ function stickToBottomIfFollowing(): void {
688
+ scroll.stickToBottom();
689
+ }
690
+
691
+ // --- Cursor blink ---
692
+ const CURSOR_BLINK_MS = 530;
693
+ let blinkInterval: ReturnType<typeof setInterval> | null = null;
694
+ let blinkEnabled = true;
695
+
696
+ function startCursorBlink(): void {
697
+ if (blinkInterval !== null) {
698
+ return;
699
+ }
700
+ output.classList.remove("cursor-blink-off");
701
+ blinkInterval = setInterval(() => {
702
+ output.classList.toggle("cursor-blink-off");
703
+ }, CURSOR_BLINK_MS);
704
+ }
705
+
706
+ function stopCursorBlink(): void {
707
+ if (blinkInterval !== null) {
708
+ clearInterval(blinkInterval);
709
+ blinkInterval = null;
710
+ }
711
+ output.classList.remove("cursor-blink-off");
712
+ }
713
+
714
+ /** Called from the flush when cursorBlink state changes. */
715
+ function setCursorBlink(enabled: boolean): void {
716
+ if (enabled === blinkEnabled) {
717
+ return;
718
+ }
719
+ blinkEnabled = enabled;
720
+ if (enabled) {
721
+ startCursorBlink();
722
+ } else {
723
+ stopCursorBlink();
724
+ }
725
+ }
726
+
727
+ // --- Font metrics & sizing ---
728
+ /**
729
+ * Re-measure the cell width/height from the rendered DOM. Call after any font
730
+ * or zoom change so subsequent `computeSize()` and `getCursorPx()` use fresh
731
+ * metrics.
732
+ */
733
+ export function updateFontMetrics(): void {
734
+ const cs = window.getComputedStyle(termWrap);
735
+ const fontSize = cs.fontSize;
736
+ const family = cs.fontFamily;
737
+ fontString = `${fontSize} ${family}`;
738
+ widthFlat.fill(WIDTH_FLAT_UNSET);
739
+ widthMap.clear();
740
+ resetVariantContexts();
741
+ const measuredW = measureCellWidth();
742
+ cellWidth = Math.round(measuredW);
743
+ cellHeight = parseFloat(cs.lineHeight) || 17;
744
+ defaultSpacing = cellWidth - measuredW;
745
+ output.style.letterSpacing = `${defaultSpacing}px`;
746
+ document.documentElement.style.setProperty("--char-w", `${cellWidth}px`);
747
+ }
748
+
749
+ const MIN_COLS = 20;
750
+ const MIN_ROWS = 5;
751
+
752
+ /**
753
+ * Compute the integer (cols, rows) the terminal element can fit at current
754
+ * font metrics, clamped to a minimum of 20×5. Used to decide what dimensions
755
+ * to send to the server in a `resize` control message.
756
+ */
757
+ export function computeSize(): { cols: number; rows: number } {
758
+ const cs = window.getComputedStyle(termWrap);
759
+ const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
760
+ const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
761
+ const contentW = termWrap.clientWidth - padX;
762
+ const contentH = termWrap.clientHeight - padY;
763
+ const cols = Math.max(MIN_COLS, Math.floor(contentW / cellWidth));
764
+ const rows = Math.max(MIN_ROWS, Math.floor(contentH / cellHeight));
765
+ return { cols, rows };
766
+ }
767
+
768
+ /**
769
+ * Returns the cursor's pixel position relative to the output element, plus
770
+ * the current cell height, for positioning custom overlays (predicted-cursor,
771
+ * IME composition, etc.). Uses the cursor row's actual DOM offset.
772
+ */
773
+ export function getCursorPx(): { left: number; top: number; cellH: number } {
774
+ const cs = window.getComputedStyle(termWrap);
775
+ const padL = parseFloat(cs.paddingLeft);
776
+ const padT = parseFloat(cs.paddingTop);
777
+ const el = rowEls.get(cursorAbs);
778
+ const top = el ? el.offsetTop : padT;
779
+ return {
780
+ left: Math.round(padL + cursorCol * cellWidth),
781
+ top: Math.round(top),
782
+ cellH: cellHeight,
783
+ };
784
+ }
785
+
786
+ let predCursorEl: HTMLElement | null = null;
787
+
788
+ /**
789
+ * Show or hide a "predicted" cursor overlay at window-relative (row, col).
790
+ * Useful for client-side echo of typed characters before the server
791
+ * acknowledges them, over high-latency connections.
792
+ */
793
+ export function setPredictedCursor(row: number, col: number, active: boolean): void {
794
+ const el = predCursorEl ?? (predCursorEl = document.getElementById("pred-cursor"));
795
+ if (!el) {
796
+ return;
797
+ }
798
+ const win = store.getWindow();
799
+ const predAbs = win.base + row;
800
+ if (!active || (predAbs === cursorAbs && col === cursorCol)) {
801
+ el.classList.remove("visible");
802
+ return;
803
+ }
804
+ const cs = window.getComputedStyle(termWrap);
805
+ const padL = parseFloat(cs.paddingLeft);
806
+ const rowEl = rowEls.get(predAbs);
807
+ const top = rowEl ? rowEl.offsetTop : parseFloat(cs.paddingTop) + row * cellHeight;
808
+ el.style.left = `${Math.round(padL + col * cellWidth)}px`;
809
+ el.style.top = `${Math.round(top)}px`;
810
+ el.style.width = `${cellWidth}px`;
811
+ el.style.height = `${cellHeight}px`;
812
+ el.classList.add("visible");
813
+ }
814
+
815
+ /** Apply or remove the reverse-video class on the terminal output.
816
+ * When DECSCNM (mode 5) is active, default fg/bg are swapped via CSS. */
817
+ export function updateReverseVideo(): void {
818
+ if (isReverseVideo()) {
819
+ termWrap.classList.add("term-reverse-video");
820
+ } else {
821
+ termWrap.classList.remove("term-reverse-video");
822
+ }
823
+ }