@cplieger/web-terminal-engine 2.7.0 → 3.0.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/mouse.ts CHANGED
@@ -84,7 +84,6 @@ export interface MouseInputHandler {
84
84
  }
85
85
 
86
86
  let handler: MouseInputHandler | null = null;
87
- let lastButton = -1;
88
87
  // True while a Shift-initiated press is in flight. Shift+click/drag is the
89
88
  // xterm convention for "bypass application mouse tracking": the gesture is
90
89
  // reserved for the browser's native text selection instead of being reported
@@ -92,21 +91,62 @@ let lastButton = -1;
92
91
  // so the move/up of a bypassed drag stay bypassed even if Shift is lifted
93
92
  // mid-drag.
94
93
  let shiftBypass = false;
94
+ // Last motion report sent, for same-cell dedup: a drag within one cell fires
95
+ // many DOM mousemove events that would all encode to the identical SGR
96
+ // sequence and flood the PTY (xterm.js applies the same suppression). Cleared
97
+ // on press/release so the first motion of a new gesture always reports.
98
+ let lastMotion = "";
99
+ // The element the current init() attached to, so a re-init or dispose can
100
+ // detach the exact listener set (addEventListener dedups identical
101
+ // registrations on the SAME element, but a re-init on a NEW element would
102
+ // otherwise leak the old one's listeners).
103
+ let attachedEl: HTMLElement | null = null;
104
+
105
+ function detach(): void {
106
+ if (!attachedEl) {
107
+ return;
108
+ }
109
+ attachedEl.removeEventListener("mousedown", onMouseDown);
110
+ attachedEl.removeEventListener("mouseup", onMouseUp);
111
+ attachedEl.removeEventListener("mousemove", onMouseMove);
112
+ attachedEl.removeEventListener("wheel", onWheel);
113
+ attachedEl.removeEventListener("focusin", onFocusIn);
114
+ attachedEl.removeEventListener("focusout", onFocusOut);
115
+ attachedEl = null;
116
+ }
95
117
 
96
118
  /**
97
119
  * Initialize the mouse module by attaching pointer/wheel/focus listeners to
98
120
  * the terminal element. Listeners gate on the active mouse mode + SGR 1006
99
121
  * encoding; they are no-ops when the server hasn't enabled mouse tracking.
122
+ *
123
+ * Returns an idempotent disposer that detaches the listeners and resets the
124
+ * module state, so a consumer can tear the terminal down (or re-init on a
125
+ * different element) without leaking listeners. Re-initializing without
126
+ * disposing first detaches the previous element automatically.
100
127
  */
101
- export function init(h: MouseInputHandler): void {
128
+ export function init(h: MouseInputHandler): () => void {
129
+ detach(); // self-heal a re-init on a different element
102
130
  handler = h;
131
+ shiftBypass = false;
132
+ lastMotion = "";
103
133
  const el = h.termElement();
134
+ attachedEl = el;
104
135
  el.addEventListener("mousedown", onMouseDown);
105
136
  el.addEventListener("mouseup", onMouseUp);
106
137
  el.addEventListener("mousemove", onMouseMove);
107
138
  el.addEventListener("wheel", onWheel, { passive: false });
108
139
  el.addEventListener("focusin", onFocusIn);
109
140
  el.addEventListener("focusout", onFocusOut);
141
+ return function dispose(): void {
142
+ if (attachedEl !== el) {
143
+ return; // superseded by a later init — nothing of ours left to detach
144
+ }
145
+ detach();
146
+ handler = null;
147
+ shiftBypass = false;
148
+ lastMotion = "";
149
+ };
110
150
  }
111
151
 
112
152
  // pixelToCell returns the coordinate pair to report for a mouse event. Normally
@@ -151,12 +191,12 @@ function onMouseDown(e: MouseEvent): void {
151
191
  return;
152
192
  }
153
193
  shiftBypass = false;
194
+ lastMotion = ""; // new gesture: next motion always reports
154
195
  const pos = pixelToCell(e);
155
196
  if (!pos) {
156
197
  return;
157
198
  }
158
- lastButton = domButtonToXterm(e.button);
159
- const code = buttonCode(lastButton, false, e.shiftKey, e.altKey, e.ctrlKey);
199
+ const code = buttonCode(domButtonToXterm(e.button), false, e.shiftKey, e.altKey, e.ctrlKey);
160
200
  handler.send(encodeSGR(code, pos.col, pos.row, false));
161
201
  e.preventDefault();
162
202
  }
@@ -178,7 +218,7 @@ function onMouseUp(e: MouseEvent): void {
178
218
  const btn = domButtonToXterm(e.button);
179
219
  const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
180
220
  handler.send(encodeSGR(code, pos.col, pos.row, true));
181
- lastButton = -1;
221
+ lastMotion = ""; // gesture over: next motion always reports
182
222
  e.preventDefault();
183
223
  }
184
224
 
@@ -207,7 +247,12 @@ function onMouseMove(e: MouseEvent): void {
207
247
  // code 3, not 0 — code 0 would be read as a left-button drag during hover.
208
248
  const btn = e.buttons ? (e.buttons & 1 ? 0 : e.buttons & 4 ? 1 : e.buttons & 2 ? 2 : 0) : 3;
209
249
  const code = buttonCode(btn, true, e.shiftKey, e.altKey, e.ctrlKey);
210
- handler.send(encodeSGR(code, pos.col, pos.row, false));
250
+ const seq = encodeSGR(code, pos.col, pos.row, false);
251
+ if (seq === lastMotion) {
252
+ return; // same cell, same buttons/modifiers: suppress the duplicate report
253
+ }
254
+ lastMotion = seq;
255
+ handler.send(seq);
211
256
  }
212
257
 
213
258
  function onWheel(e: WheelEvent): void {
package/src/render.ts CHANGED
@@ -135,15 +135,17 @@ let trimMarkerEl: HTMLDivElement | null = null;
135
135
  const renderQueue = new Set<number>();
136
136
  const MAX_ROWS_PER_FRAME = 300;
137
137
 
138
- // Cursor state, refreshed from the store window on each flush. Kept as module
139
- // vars because buildRowSpans/cursorClassName read them.
138
+ // Cursor state, refreshed from the store window on each flush. The caret is
139
+ // painted by a dedicated overlay element (see positionCursorOverlay), NOT by
140
+ // restyling the span at the cursor cell: rows are pure content, so cursor
141
+ // motion never rewrites row DOM — which preserves any native selection in the
142
+ // old/new cursor rows and deletes the per-keystroke row rebuild entirely
143
+ // (judgement finding: changed-row replaceChildren collapsed a row-local
144
+ // selection).
140
145
  let cursorAbs = -1; // absolute index of the row the cursor is on
141
146
  let cursorCol = 0;
142
147
  let cursorHidden = false;
143
148
  let cursorStyleVal = 0; // 0-6: DECSCUSR
144
- let prevCursorCol = -1;
145
- let prevCursorHidden = false;
146
- let prevCursorStyleVal = -1;
147
149
 
148
150
  function cursorClassName(): string {
149
151
  // DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
@@ -162,6 +164,28 @@ let defaultSpacing = 0;
162
164
  let onCursorMove: (() => void) | null = null;
163
165
  let pendingFrame: number | undefined;
164
166
 
167
+ // termWrap's padding, cached: the overlay positioners (caret, predicted
168
+ // cursor, getCursorPx for the IME view) need it EVERY flush, and a live
169
+ // getComputedStyle after the flush's DOM writes forces a style recalc —
170
+ // measured at real cost in the render bench (the caret positioner alone was
171
+ // ~11% of flush CPU under full-screen churn). Lazily read once per attach and
172
+ // refreshed by updateFontMetrics (the same staleness contract as
173
+ // cellWidth/cellHeight: a consumer that restyles the terminal calls
174
+ // updateFontMetrics, which re-reads both).
175
+ let padLeft = 0;
176
+ let padTop = 0;
177
+ let padValid = false;
178
+
179
+ function termPadding(): { padL: number; padT: number } {
180
+ if (!padValid) {
181
+ const cs = window.getComputedStyle(termWrap);
182
+ padLeft = parseFloat(cs.paddingLeft);
183
+ padTop = parseFloat(cs.paddingTop);
184
+ padValid = true;
185
+ }
186
+ return { padL: padLeft, padT: padTop };
187
+ }
188
+
165
189
  // Bounded error-path reschedule (l-f28 / d-u4-1). The drain loop deletes a
166
190
  // queued row only AFTER upsertRow succeeds, so a row whose build throws stays
167
191
  // queued. flushRender's catch reschedules to finish a partial drain and to
@@ -195,18 +219,24 @@ export function init(opts: {
195
219
  output = opts.output;
196
220
  termWrap = opts.termWrap;
197
221
  onCursorMove = opts.onCursorMove ?? null;
222
+ // New attach target: the cached padding belongs to the previous termWrap.
223
+ padValid = false;
198
224
  // Fresh attach: drop any prior model + DOM so re-init (and vitest's
199
225
  // non-isolated module reuse) starts clean.
200
226
  store.reset();
201
227
  rowEls.clear();
202
228
  renderQueue.clear();
203
229
  trimMarkerEl = null;
204
- // Drop the predicted-cursor overlay (re-created lazily against the new
205
- // termWrap on the next setPredictedCursor call) so re-init starts clean.
230
+ // Drop the predicted-cursor + caret overlays (re-created lazily against the
231
+ // new termWrap) so re-init starts clean.
206
232
  if (predCursorEl) {
207
233
  predCursorEl.remove();
208
234
  predCursorEl = null;
209
235
  }
236
+ if (cursorEl) {
237
+ cursorEl.remove();
238
+ cursorEl = null;
239
+ }
210
240
  output.replaceChildren();
211
241
  cursorAbs = -1;
212
242
  if (pendingFrame !== undefined) {
@@ -303,9 +333,7 @@ export function rebuild(): void {
303
333
  trimMarkerEl = null;
304
334
  cursorAbs = -1;
305
335
  altRendered = false;
306
- prevCursorCol = -1;
307
- prevCursorHidden = false;
308
- prevCursorStyleVal = -1;
336
+ altPrevRows = [];
309
337
  // Fresh surface: a stale give-up streak from a prior store must not deny the
310
338
  // rebuilt surface its full transient-retry budget.
311
339
  renderNoProgressStreak = 0;
@@ -390,8 +418,20 @@ function linkifySpans(
390
418
  // persistent underline would then bleed across the cell/row.
391
419
  a.className = "term-link term-autolink";
392
420
  a.textContent = match[0];
393
- // Copy inline styles from the source span
394
- a.style.cssText = span.style.cssText;
421
+ // Copy inline styles from the source span property-by-property, never
422
+ // via cssText: a cssText assignment is a string-PARSED style write, the
423
+ // one kind a style-src CSP reasons about, and this was the renderer's
424
+ // only such write. With it gone, every style write in the render path
425
+ // is an individually-assigned property (unambiguously ungoverned by
426
+ // style-src), which keeps the consumers' CSP-hardening story clean.
427
+ for (let i = 0; i < span.style.length; i++) {
428
+ const prop = span.style.item(i);
429
+ a.style.setProperty(
430
+ prop,
431
+ span.style.getPropertyValue(prop),
432
+ span.style.getPropertyPriority(prop),
433
+ );
434
+ }
395
435
  out.push(a);
396
436
  last = match.index + match[0].length;
397
437
  }
@@ -430,9 +470,8 @@ function runHasLinkText(spans: (HTMLSpanElement | HTMLAnchorElement)[]): boolean
430
470
  }
431
471
 
432
472
  // --- Build row DOM ---
433
- function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HTMLAnchorElement)[] {
473
+ function buildRowSpans(runs: readonly WireRun[]): (HTMLSpanElement | HTMLAnchorElement)[] {
434
474
  const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
435
- let col = 0;
436
475
  for (const run of runs) {
437
476
  if (!run.t) {
438
477
  continue;
@@ -540,26 +579,10 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
540
579
  prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
541
580
  }
542
581
  }
543
- // The spacer occupies the wide char's second cell, so it advances the
544
- // column exactly like a printed cell. The engine reports cursor_col in
545
- // true cell coordinates (a wide glyph moves curX by 2), so col must
546
- // count this cell too — otherwise a visible cursor positioned after a
547
- // wide char lands one cell too far right per preceding wide char.
548
- col++;
549
- continue;
550
- }
551
- if (col === cursorAt) {
552
- flush();
553
- const w = measureChar(ch, isBold, isItalic);
554
- const spacing = cellWidth - w;
555
- const span = document.createElement("span");
556
- span.className = cursorClassName();
557
- span.textContent = ch;
558
- if (spacing !== defaultSpacing) {
559
- span.style.letterSpacing = `${spacing}px`;
560
- }
561
- out.push(span);
562
- col++;
582
+ // The spacer occupies the wide char's second cell. (Column arithmetic
583
+ // for the caret lives in glyphAt, which mirrors this advance rule —
584
+ // the engine reports cursor_col in true cell coordinates, a wide
585
+ // glyph moving curX by 2.)
563
586
  continue;
564
587
  }
565
588
  const w = measureChar(ch, isBold, isItalic);
@@ -571,7 +594,6 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
571
594
  prevSpacing = spacing;
572
595
  }
573
596
  buffer += ch;
574
- col++;
575
597
  }
576
598
  flush();
577
599
  // Wrap this run's spans in an <a> when it has a hyperlink URL — but only if
@@ -607,18 +629,6 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
607
629
  }
608
630
  }
609
631
  }
610
- if (cursorAt >= 0 && col <= cursorAt) {
611
- while (col < cursorAt) {
612
- const span = document.createElement("span");
613
- span.textContent = " ";
614
- out.push(span);
615
- col++;
616
- }
617
- const cursor = document.createElement("span");
618
- cursor.className = cursorClassName();
619
- cursor.textContent = " ";
620
- out.push(cursor);
621
- }
622
632
  if (out.length === 0) {
623
633
  const span = document.createElement("span");
624
634
  span.textContent = "\u00a0";
@@ -721,7 +731,6 @@ function flushRenderInner(): void {
721
731
  // Refresh cursor state from the window.
722
732
  const win = store.getWindow();
723
733
  const newCursorAbs = win.base + win.cursorRow;
724
- const prevCursorAbs = cursorAbs;
725
734
  cursorAbs = newCursorAbs;
726
735
  cursorCol = win.cursorCol;
727
736
  cursorHidden = win.cursorHidden;
@@ -729,8 +738,14 @@ function flushRenderInner(): void {
729
738
  setCursorBlink(win.cursorBlink);
730
739
 
731
740
  // Alt screen: render the ephemeral grid instead of the absolute buffer.
741
+ // Returning here skips the dirtyLines queueing below — safe by invariant:
742
+ // main-buffer rows never repaint while alt is active, and the alt-exit
743
+ // branch below rebuilds EVERYTHING via queueRowsViewportFirst, so any line
744
+ // dirtied during the alt session is repainted at exit.
732
745
  if (store.isAlt()) {
733
- renderAlt(store.getAltRows());
746
+ const altRows = store.getAltRows();
747
+ renderAlt(altRows);
748
+ positionCursorOverlay(altRows[win.cursorRow]);
734
749
  if (onCursorMove) {
735
750
  onCursorMove();
736
751
  }
@@ -741,6 +756,7 @@ function flushRenderInner(): void {
741
756
  // viewport-first (shared with rebuild()) so the visible viewport fills
742
757
  // before deep scrollback on a large-history alt-exit.
743
758
  altRendered = false;
759
+ altPrevRows = [];
744
760
  output.replaceChildren();
745
761
  rowEls.clear();
746
762
  renderQueue.clear();
@@ -753,28 +769,15 @@ function flushRenderInner(): void {
753
769
  renderQueue.add(abs);
754
770
  }
755
771
 
756
- // The cursor rows are built regardless of the budget so the inline cursor span
757
- // is always current; a huge backlog must never make the caret lag. But skip
758
- // rebuilding the current cursor row when neither the cursor's visual state nor
759
- // the row content changed: an unconditional replaceChildren() would discard a
760
- // text selection on that row, defeating the byte-identical selection-preserving
761
- // guarantee the store idempotency gives every other row.
762
- if (prevCursorAbs !== newCursorAbs && prevCursorAbs >= 0) {
763
- upsertRow(prevCursorAbs);
764
- renderQueue.delete(prevCursorAbs);
765
- }
766
- const cursorVisualChanged =
767
- prevCursorAbs !== newCursorAbs ||
768
- cursorCol !== prevCursorCol ||
769
- cursorHidden !== prevCursorHidden ||
770
- cursorStyleVal !== prevCursorStyleVal;
771
- if (cursorVisualChanged || renderQueue.has(newCursorAbs)) {
772
+ // The cursor's row is built regardless of the budget — the caret overlay
773
+ // positions off the row element's offsetTop, so a huge backlog must never
774
+ // leave it floating over a not-yet-built row. Content-driven only: cursor
775
+ // MOTION no longer touches row DOM (the overlay carries the caret), so a
776
+ // selection anywhere — including on the cursor row — survives typing.
777
+ if (renderQueue.has(newCursorAbs) || !rowEls.has(newCursorAbs)) {
772
778
  upsertRow(newCursorAbs);
779
+ renderQueue.delete(newCursorAbs);
773
780
  }
774
- renderQueue.delete(newCursorAbs);
775
- prevCursorCol = cursorCol;
776
- prevCursorHidden = cursorHidden;
777
- prevCursorStyleVal = cursorStyleVal;
778
781
 
779
782
  // Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
780
783
  // to the next frame (scheduled below) so one big burst never blocks paint.
@@ -797,6 +800,8 @@ function flushRenderInner(): void {
797
800
  scheduleFlush();
798
801
  }
799
802
 
803
+ positionCursorOverlay(store.getLine(cursorAbs));
804
+
800
805
  if (onCursorMove) {
801
806
  onCursorMove();
802
807
  }
@@ -839,8 +844,7 @@ function upsertRow(abs: number): void {
839
844
  }
840
845
  return;
841
846
  }
842
- const cursorAt = !cursorHidden && abs === cursorAbs ? cursorCol : -1;
843
- const spans = buildRowSpans(runs, cursorAt);
847
+ const spans = buildRowSpans(runs);
844
848
  let el = rowEls.get(abs);
845
849
  if (el === undefined) {
846
850
  el = document.createElement("div");
@@ -879,20 +883,44 @@ function rowAbs(el: HTMLElement): number {
879
883
 
880
884
  // --- Alt screen (ephemeral grid; no history) ---
881
885
  let altRendered = false;
886
+ // The alt row arrays rendered by the last flush, by grid index. Row identity
887
+ // is the store's change signal (applyScreen swaps exactly the changed rows'
888
+ // arrays; getAltRows returns the live references), so `prev[y] === rows[y]`
889
+ // means row y's DOM is already current. Cleared on alt exit and re-init.
890
+ let altPrevRows: readonly (readonly WireRun[])[] = [];
882
891
 
883
- function renderAlt(rows: WireRun[][]): void {
884
- altRendered = true;
892
+ function renderAlt(rows: readonly (readonly WireRun[])[]): void {
885
893
  rowEls.clear();
886
- const els: HTMLDivElement[] = [];
894
+ // Full (re)build: first alt frame, a grid-height change (resize), or a
895
+ // desynced DOM (defensive; e.g. a consumer poked the subtree).
896
+ if (!altRendered || output.children.length !== rows.length) {
897
+ altRendered = true;
898
+ const els: HTMLDivElement[] = [];
899
+ for (const runs of rows) {
900
+ const div = document.createElement("div");
901
+ div.className = "term-row";
902
+ div.replaceChildren(...buildRowSpans(runs));
903
+ els.push(div);
904
+ }
905
+ output.replaceChildren(...els);
906
+ altPrevRows = rows.slice();
907
+ return;
908
+ }
909
+ // Reconcile in place: rebuild only rows whose array identity changed. A
910
+ // full-screen TUI that repaints everything (htop) rebuilds every row either
911
+ // way; one that repaints a few lines (vim editing, a progress bar) now
912
+ // touches only those rows' DOM — measured ~50x less flush CPU in the render
913
+ // bench's partial-update scenario, and the browser skips layout for
914
+ // untouched rows.
887
915
  for (let y = 0; y < rows.length; y++) {
888
- const div = document.createElement("div");
889
- div.className = "term-row";
890
- const cursorAt = !cursorHidden && y === cursorAbs - store.getWindow().base ? cursorCol : -1;
891
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length
892
- div.replaceChildren(...buildRowSpans(rows[y]!, cursorAt));
893
- els.push(div);
916
+ if (altPrevRows[y] === rows[y]) {
917
+ continue;
918
+ }
919
+ const div = output.children[y] as HTMLDivElement;
920
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length === children.length
921
+ div.replaceChildren(...buildRowSpans(rows[y]!));
894
922
  }
895
- output.replaceChildren(...els);
923
+ altPrevRows = rows.slice();
896
924
  }
897
925
 
898
926
  /** Pin the viewport to the bottom iff the user is following. The scroll
@@ -906,13 +934,24 @@ const CURSOR_BLINK_MS = 530;
906
934
  let blinkInterval: ReturnType<typeof setInterval> | null = null;
907
935
  let blinkEnabled = true;
908
936
 
937
+ // hiddenDoc reports whether the page is currently background/hidden. The blink
938
+ // interval is gated on it: rAF-driven flushes already freeze in a hidden tab,
939
+ // but a plain setInterval keeps firing (throttled), so an idle hidden terminal
940
+ // would keep toggling a class — pointless wakeups that cost battery on mobile.
941
+ function hiddenDoc(): boolean {
942
+ return typeof document !== "undefined" && document.visibilityState === "hidden";
943
+ }
944
+
909
945
  function startCursorBlink(): void {
910
- if (blinkInterval !== null) {
946
+ if (blinkInterval !== null || !blinkEnabled || hiddenDoc()) {
911
947
  return;
912
948
  }
913
- output.classList.remove("cursor-blink-off");
949
+ // The blink class lives on termWrap: the caret overlay is a termWrap child
950
+ // (not inside output), and the `.cursor-blink-off .term-cursor` descendant
951
+ // selector must reach it.
952
+ termWrap.classList.remove("cursor-blink-off");
914
953
  blinkInterval = setInterval(() => {
915
- output.classList.toggle("cursor-blink-off");
954
+ termWrap.classList.toggle("cursor-blink-off");
916
955
  }, CURSOR_BLINK_MS);
917
956
  }
918
957
 
@@ -921,7 +960,30 @@ function stopCursorBlink(): void {
921
960
  clearInterval(blinkInterval);
922
961
  blinkInterval = null;
923
962
  }
924
- output.classList.remove("cursor-blink-off");
963
+ termWrap.classList.remove("cursor-blink-off");
964
+ }
965
+
966
+ // Pause the interval while hidden; resume (cursor solid, phase reset) when the
967
+ // tab is foregrounded again. Registered once at module load — init() re-runs
968
+ // on re-attach, and a per-init registration would stack listeners.
969
+ if (typeof document !== "undefined") {
970
+ document.addEventListener("visibilitychange", () => {
971
+ // Pre-init (no termWrap yet) there is nothing to pause or resume.
972
+ if ((termWrap as HTMLElement | undefined) === undefined) {
973
+ return;
974
+ }
975
+ if (hiddenDoc()) {
976
+ // Stop the timer only: blinkEnabled keeps the server-driven state so
977
+ // foregrounding can restart without waiting for the next mode frame.
978
+ if (blinkInterval !== null) {
979
+ clearInterval(blinkInterval);
980
+ blinkInterval = null;
981
+ }
982
+ termWrap.classList.remove("cursor-blink-off");
983
+ return;
984
+ }
985
+ startCursorBlink();
986
+ });
925
987
  }
926
988
 
927
989
  /** Called from the flush when cursorBlink state changes. */
@@ -945,6 +1007,11 @@ function setCursorBlink(enabled: boolean): void {
945
1007
  */
946
1008
  export function updateFontMetrics(): void {
947
1009
  const cs = window.getComputedStyle(termWrap);
1010
+ // Refresh the overlay-positioning padding cache from the same computed
1011
+ // style (one staleness contract for all cached box metrics).
1012
+ padLeft = parseFloat(cs.paddingLeft);
1013
+ padTop = parseFloat(cs.paddingTop);
1014
+ padValid = true;
948
1015
  const fontSize = cs.fontSize;
949
1016
  const family = cs.fontFamily;
950
1017
  fontString = `${fontSize} ${family}`;
@@ -978,17 +1045,54 @@ export function computeSize(): { cols: number; rows: number } {
978
1045
  return { cols, rows };
979
1046
  }
980
1047
 
1048
+ // rowTopInTermWrap returns a row element's top in termWrap's coordinate space
1049
+ // (the space every absolutely-positioned overlay — caret, predicted cursor,
1050
+ // composition view — resolves against). A bare `offsetTop` is NOT that: it is
1051
+ // relative to the element's offsetParent, and the UI package's CSS makes
1052
+ // `.term-output` a positioned element (position: relative, for its own
1053
+ // reasons), so rows report offsets in output-space,
1054
+ // one padding/offset short of termWrap-space (the caret floated 4px above
1055
+ // every glyph under the real stylesheet — invisible to the harness, whose
1056
+ // unpositioned #out made both spaces coincide). Walk the offsetParent chain
1057
+ // and accumulate until termWrap so the math is correct under EITHER
1058
+ // stylesheet, and under any future wrapper the UI grows between the two.
1059
+ function rowTopInTermWrap(el: HTMLElement): number {
1060
+ let top = 0;
1061
+ let node: Element | null = el;
1062
+ while (node instanceof HTMLElement && node !== termWrap) {
1063
+ top += node.offsetTop;
1064
+ node = node.offsetParent;
1065
+ // offsetTop is measured from the offsetParent's padding edge, but an
1066
+ // intermediate parent's own offsetTop locates its BORDER box — add its
1067
+ // top border so the accumulation stays exact if a wrapper ever grows one
1068
+ // (both current stylesheets use border: 0 here). termWrap itself is
1069
+ // excluded: absolute children resolve from its padding edge already.
1070
+ if (node instanceof HTMLElement && node !== termWrap) {
1071
+ top += node.clientTop;
1072
+ }
1073
+ }
1074
+ // Chain ended off termWrap (display:none, detached, or termWrap unpositioned
1075
+ // with the row offset-rooted elsewhere): fall back to rect delta, which is
1076
+ // space-independent. scrollTop re-bases the viewport-relative delta into
1077
+ // content space.
1078
+ if (node !== termWrap) {
1079
+ return (
1080
+ el.getBoundingClientRect().top - termWrap.getBoundingClientRect().top + termWrap.scrollTop
1081
+ );
1082
+ }
1083
+ return top;
1084
+ }
1085
+
981
1086
  /**
982
- * Returns the cursor's pixel position relative to the output element, plus
983
- * the current cell height, for positioning custom overlays (predicted-cursor,
984
- * IME composition, etc.). Uses the cursor row's actual DOM offset.
1087
+ * Returns the cursor's pixel position relative to the scroll container
1088
+ * (termWrap) — the coordinate space of the absolutely-positioned overlays
1089
+ * (predicted-cursor, IME composition view) — plus the current cell height.
1090
+ * Uses the cursor row's actual DOM offset.
985
1091
  */
986
1092
  export function getCursorPx(): { left: number; top: number; cellH: number } {
987
- const cs = window.getComputedStyle(termWrap);
988
- const padL = parseFloat(cs.paddingLeft);
989
- const padT = parseFloat(cs.paddingTop);
1093
+ const { padL, padT } = termPadding();
990
1094
  const el = rowEls.get(cursorAbs);
991
- const top = el ? el.offsetTop : padT;
1095
+ const top = el ? rowTopInTermWrap(el) : padT;
992
1096
  return {
993
1097
  left: Math.round(padL + cursorCol * cellWidth),
994
1098
  top: Math.round(top),
@@ -996,6 +1100,83 @@ export function getCursorPx(): { left: number; top: number; cellH: number } {
996
1100
  };
997
1101
  }
998
1102
 
1103
+ // --- Caret overlay ---
1104
+ //
1105
+ // The real cursor is a single absolutely-positioned element appended to
1106
+ // termWrap (the positioned scroll container, same coordinate system as the
1107
+ // predicted-cursor overlay), NOT a restyled span inside the cursor row. Rows
1108
+ // stay pure content: cursor motion repositions this element and never
1109
+ // rewrites row DOM, so a native selection survives typing — including on the
1110
+ // cursor row itself — and the per-keystroke row rebuild is gone.
1111
+ let cursorEl: HTMLElement | null = null;
1112
+
1113
+ function ensureCursorEl(): HTMLElement {
1114
+ if (cursorEl === null) {
1115
+ cursorEl = document.createElement("div");
1116
+ cursorEl.setAttribute("aria-hidden", "true");
1117
+ termWrap.appendChild(cursorEl);
1118
+ }
1119
+ return cursorEl;
1120
+ }
1121
+
1122
+ // glyphAt returns the character occupying `col` in a row's runs, advancing
1123
+ // columns exactly like buildRowSpans (each char one cell; the \uFFFF
1124
+ // continuation placeholder occupies the wide char's second cell). A miss —
1125
+ // cursor past end of text, empty row, or a continuation cell — reads as a
1126
+ // space (the block cursor then paints an inverted blank, matching a real
1127
+ // terminal).
1128
+ function glyphAt(runs: readonly WireRun[] | undefined, col: number): string {
1129
+ if (!runs) {
1130
+ return " ";
1131
+ }
1132
+ let c = 0;
1133
+ for (const run of runs) {
1134
+ if (!run.t) {
1135
+ continue;
1136
+ }
1137
+ for (const ch of run.t) {
1138
+ if (c === col) {
1139
+ return ch === "\uFFFF" ? " " : ch;
1140
+ }
1141
+ c++;
1142
+ }
1143
+ }
1144
+ return " ";
1145
+ }
1146
+
1147
+ // positionCursorOverlay moves/styles the caret overlay for the current cursor
1148
+ // state. `runs` is the cursor row's content (main: store.getLine; alt: the
1149
+ // grid row) — the block style copies the glyph under the cursor so the
1150
+ // inverted cell looks exactly like the old inline-span cursor. Called at the
1151
+ // end of every flush; hidden when the cursor is hidden or the screen is empty.
1152
+ function positionCursorOverlay(runs: readonly WireRun[] | undefined): void {
1153
+ const el = ensureCursorEl();
1154
+ if (cursorHidden || cursorAbs < 0) {
1155
+ el.className = "term-cursor-overlay";
1156
+ return;
1157
+ }
1158
+ const win = store.getWindow();
1159
+ const { padL, padT } = termPadding();
1160
+ const rowEl = rowEls.get(cursorAbs);
1161
+ // Alt-screen rows are not registered in rowEls; their grid is uniform, so
1162
+ // the row offset derives from the window-relative row index. Main-screen
1163
+ // rows exist by the time this runs (the flush force-builds the cursor row).
1164
+ // rowTopInTermWrap (not bare offsetTop) so the top is in termWrap space
1165
+ // regardless of which ancestor is the rows' offsetParent.
1166
+ const top = rowEl ? rowTopInTermWrap(rowEl) : padT + (cursorAbs - win.base) * cellHeight;
1167
+ const ch = glyphAt(runs, cursorCol);
1168
+ // A wide glyph (CJK, emoji) owns two cells; the overlay covers both, like
1169
+ // the old inline cursor span did via its continuation-spacer adjustment.
1170
+ const wide = measureChar(ch, false, false) > cellWidth * 1.5;
1171
+ el.textContent = ch === " " ? "\u00a0" : ch;
1172
+ el.className = `term-cursor-overlay visible ${cursorClassName()}`;
1173
+ el.style.left = `${Math.round(padL + cursorCol * cellWidth)}px`;
1174
+ el.style.top = `${Math.round(top)}px`;
1175
+ el.style.width = `${wide ? cellWidth * 2 : cellWidth}px`;
1176
+ el.style.height = `${cellHeight}px`;
1177
+ el.style.lineHeight = `${cellHeight}px`;
1178
+ }
1179
+
999
1180
  let predCursorEl: HTMLElement | null = null;
1000
1181
 
1001
1182
  // Create the predicted-cursor overlay the renderer owns. Appended to termWrap
@@ -1026,10 +1207,9 @@ export function setPredictedCursor(row: number, col: number, active: boolean): v
1026
1207
  el.classList.remove("visible");
1027
1208
  return;
1028
1209
  }
1029
- const cs = window.getComputedStyle(termWrap);
1030
- const padL = parseFloat(cs.paddingLeft);
1210
+ const { padL, padT } = termPadding();
1031
1211
  const rowEl = rowEls.get(predAbs);
1032
- const top = rowEl ? rowEl.offsetTop : parseFloat(cs.paddingTop) + row * cellHeight;
1212
+ const top = rowEl ? rowTopInTermWrap(rowEl) : padT + row * cellHeight;
1033
1213
  el.style.left = `${Math.round(padL + col * cellWidth)}px`;
1034
1214
  el.style.top = `${Math.round(top)}px`;
1035
1215
  el.style.width = `${cellWidth}px`;