@cplieger/web-terminal-engine 5.0.1 → 5.0.4

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/README.md CHANGED
@@ -81,7 +81,7 @@ document.addEventListener("keydown", (ev) => {
81
81
  - **`keyboard`** — Translates `KeyboardEvent` to terminal byte sequences. `mapKeyboardEvent`, `bracketTextForPaste`, `prepareTextForTerminal`, `ctrlByteFor`, plus the shared logical-key encodings (`plainCursorKeySeq`, `plainEscapeSeq`) the toolbar module reuses. Honors `applicationCursor`, `applicationKeypad`, `bracketedPaste`.
82
82
  - **`toolbar`** — On-screen mobile toolbar widget (moved out of `keyboard` in v3). `bindMobileToolbar({toolbar, send, ids?})` wires `pointerdown` handlers for an on-screen Ctrl/arrows/Tab/Enter/Esc toolbar (with sticky-Ctrl semantics and kitty/DECCKM-aware arrows byte-identical to the physical-key path), returning a `MobileToolbarController` exposing `applyStickyCtrl`, `setCtrlArmed`, `isCtrlArmed`, and `dispose`.
83
83
  - **`mouse`** — SGR 1006 mouse + focus reporting encoder. `init`, `encodeSGR`, `MouseInputHandler`. Auto-gates on `mouseMode > 0`.
84
- - **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`.
84
+ - **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`, `currentScrollTop`, `restoreView`, `adjustForContentShift`, plus two renderer seams: `noteContentShrink(scrollTopBefore)` says a row removal rather than a gesture caused the scroll event about to arrive, and `reconcileScrollRange()` moves the offset back inside the container's range when the container did not reconcile the shrink itself. The second exists because clamping an offset the content shrank out from under is an implementation behaviour and not a specified one: Blink and Gecko reconcile during layout, WebKit does not, and without the correction an iOS viewport is left parked past the end of the content after an application clears the screen, showing background with the content above it until the reader scrolls.
85
85
  - **`modes`** — DEC private mode state (synced from server's `ModesMessage`). `setModes`, `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`.
86
86
  - **`decodeWireBinary(buf)`**: Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
87
87
  - **Wire compatibility metadata**: `WIRE_PROTOCOL_VERSION`, `MIN_SUPPORTED_SERVER_WIRE_VERSION`, `WIRE_INCOMPATIBLE_CLOSE_CODE`, and `WIRE_COMPATIBILITY` publish this client release's directional contract. The same values ship as a language-neutral JSON artifact for non-TypeScript consumers — see [Wire compatibility manifest](#wire-compatibility-manifest).
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.0.1",
3
+ "version": "5.0.4",
4
4
  "license": "MPL-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.0.1",
3
+ "version": "5.0.4",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
package/src/connection.ts CHANGED
@@ -42,6 +42,7 @@ import * as modes from "./modes.js";
42
42
  // anchor — the shape that leaves the rows under the reader blank while the far
43
43
  // end of the gap fills.
44
44
  import { PAGE_SIZE } from "./store.js";
45
+ import * as render from "./render.js";
45
46
  import type { ControlMessage, ScrollMessage, ServerMessage } from "./types.js";
46
47
  import { INITIAL_DELAY_MS, nextBackoffDelay } from "./reconnect.js";
47
48
 
@@ -528,8 +529,47 @@ export interface Callbacks {
528
529
 
529
530
  let cb: Callbacks | null = null;
530
531
 
532
+ /**
533
+ * The members this module answers for ITSELF, from the renderer.
534
+ *
535
+ * Every one of them is a fact about local state, not a decision: what this client
536
+ * already holds, how much replay it wants, where to route a resume ack or a history
537
+ * reply. The renderer is the layer that knows, because several carry a viewport
538
+ * reading only it can take, and both modules belong to this library. A consumer
539
+ * that supplied them was choosing between engine accessors on the engine's behalf,
540
+ * which is how `getHaveThrough` came to be wired to the highest index HELD rather
541
+ * than the highest the server had confirmed: the resume then claimed rows the
542
+ * application had merely drawn, and a frozen copy of its input box appeared in
543
+ * scrollback on almost every reattach.
544
+ *
545
+ * A consumer may still override any of them, and `getReplayMax` is the one where
546
+ * that is expected: how much history a client wants resident is its policy, not the
547
+ * engine's fact. The rest exist as overrides only because removing them from
548
+ * `Callbacks` would break a published surface; treat them as deprecated and prefer
549
+ * supplying nothing.
550
+ *
551
+ * Read lazily, per resume, rather than captured at init: the renderer's answers are
552
+ * functions of whichever store is currently bound, which a tab switch changes.
553
+ */
554
+ function engineDefaults(): Partial<Callbacks> {
555
+ return {
556
+ getHaveThrough: render.getReplayBoundary,
557
+ getReplayMax: render.replayMaxForResume,
558
+ onResumeTransition: render.applyResumeTransition,
559
+ onResumeBounds: render.noteResumeBounds,
560
+ onHistoryReply: render.handleHistoryReply,
561
+ noteSolicited: render.noteSolicited,
562
+ clearSolicited: render.clearSolicited,
563
+ onHistoryRetry: render.maybeFetchHistory,
564
+ };
565
+ }
566
+
531
567
  export function init(callbacks: Callbacks): void {
532
- cb = callbacks;
568
+ // Consumer last: an explicit member wins, an absent one gets the engine's own.
569
+ // The alternative, leaving them absent, is not neutral — the resume send reads
570
+ // `cb?.getHaveThrough?.() ?? -1`, so silence asks for a full bounded replay on
571
+ // every attach, which is the worst answer available rather than the safe one.
572
+ cb = { ...engineDefaults(), ...callbacks };
533
573
  if (callbacks.wsPath !== undefined) {
534
574
  wsPath = callbacks.wsPath;
535
575
  }
package/src/render.ts CHANGED
@@ -1393,6 +1393,12 @@ function flushRender(): void {
1393
1393
  // silently switching auto-follow off (scroll.ts's header; §1.3).
1394
1394
  if (removedRowsThisPass) {
1395
1395
  scroll.noteContentShrink(scrollTopBefore);
1396
+ // And correct the offset when the container left it past the end of the
1397
+ // shrunken content instead of reconciling it (scroll.ts's header: the third
1398
+ // arithmetic state). Before the three invariants below, so they measure a
1399
+ // geometry the container agrees with rather than one it is still holding an
1400
+ // impossible offset into.
1401
+ scroll.reconcileScrollRange();
1396
1402
  }
1397
1403
  // Three position invariants, applied after every DOM mutation and in this
1398
1404
  // order. An armed view restore goes first because it OWNS the position while
@@ -1497,6 +1503,16 @@ function flushRenderInner(): void {
1497
1503
  }
1498
1504
  gapMarkerEls.clear();
1499
1505
  cursorAbs = -1;
1506
+ // The rows are not the only thing in content space, and this wipe is as
1507
+ // total as rebuild()'s. Collapse the four overlays with them HERE rather
1508
+ // than relying on the tail's positionCursorOverlay/onCursorMove, so the
1509
+ // shrink this pass announces is the real one: while an overlay still holds
1510
+ // the container's scrollable overflow at its old offset, both the
1511
+ // announcement and the range correction measure a phantom height and read
1512
+ // "already at the bottom" over zero rows of content. See
1513
+ // collapseContentSpaceOverlays for the measurement, and note that the tail
1514
+ // is exactly what does not run on the three paths listed there.
1515
+ collapseContentSpaceOverlays();
1500
1516
  } else {
1501
1517
  for (const abs of ch.evictedLines) {
1502
1518
  const el = rowEls.get(abs);
@@ -2056,6 +2072,19 @@ function renderAlt(rows: readonly (readonly WireRun[])[]): void {
2056
2072
  div.replaceChildren(...buildRowSpans(runs));
2057
2073
  els.push(div);
2058
2074
  }
2075
+ // This replaceChildren REMOVES rows, and on the first alt frame it removes
2076
+ // the entire main-buffer scrollback in favour of one screen of grid: the
2077
+ // largest shrink in this module after rebuild()'s wipe. The alt branch in
2078
+ // flushRenderInner returns before the shared bookkeeping, so the flag is set
2079
+ // here or not at all, and without it the pass reached neither
2080
+ // noteContentShrink (leaving the clamp to be inferred from its lossy
2081
+ // arithmetic signature, which can silently switch auto-follow off) nor
2082
+ // reconcileScrollRange (leaving the viewport parked past the grid).
2083
+ //
2084
+ // Over-announcing is harmless by mechanism: the resize and desynced-DOM
2085
+ // cases reach this branch too, and both seams are position-gated, so a
2086
+ // rebuild that moves nothing and strands nothing announces nothing.
2087
+ removedRowsThisPass = true;
2059
2088
  output.replaceChildren(...els);
2060
2089
  altPrevRows = rows.slice();
2061
2090
  return;
package/src/scroll.ts CHANGED
@@ -87,6 +87,44 @@
87
87
  // follow and a lingering arm cannot swallow a later real gesture. It is also
88
88
  // cleared unconditionally by the first event that arrives, so it cannot outlive
89
89
  // one frame even if that guarantee were ever broken.
90
+
91
+ // A shrink asks TWO independent questions, and for years this module asked only
92
+ // the first.
93
+ //
94
+ // 1. Did the offset MOVE? That identifies the clamp's scroll event, so the
95
+ // follow state survives it (shrinkArmed, above).
96
+ // 2. Is the offset still OUT OF RANGE? That says the container has not
97
+ // reconciled it at all, and the viewport is parked past the end of the
98
+ // content (rangeCorrectionOwed, below).
99
+ //
100
+ // distanceFromBottom() has three arithmetic states and only two were consumed:
101
+ // positive means content below (pin down), zero means the tail (nothing to do),
102
+ // and NEGATIVE means the offset is beyond the content's end. The third was a
103
+ // gap: `stickToBottom`'s `> 0` test is false for it, so the one invariant that
104
+ // could rescue the view declined to act, and nothing else writes the offset.
105
+ // The reader then sees the container's background with the content above the
106
+ // visible region, until either the growing content reaches the parked offset or
107
+ // they scroll and the container reconciles.
108
+ //
109
+ // The negative state is reachable because "the offset is clamped when the
110
+ // content shrinks" is an implementation behaviour, not a specified one. CSSOM
111
+ // View clamps a programmatic scroll at write time; neither it nor CSS Overflow 3
112
+ // says WHEN a UA must reconcile an offset already established when the
113
+ // scrollable overflow rectangle shrinks under it. Blink and Gecko reconcile
114
+ // during layout, so the read that follows a row removal already reports the new
115
+ // maximum. WebKit does not hold the offset inside the range at all: this repo
116
+ // separately records that Safari "updates scrollTop PAST the maximum during an
117
+ // overscroll bounce" (render.ts, the removedRowsThisPass comment), and iOS
118
+ // Safari has been reported leaving an offset outside the valid range until a
119
+ // manual scroll nudges the browser into fixing it
120
+ // (stackoverflow.com/q/79752870, September 2025 — that report is about a
121
+ // programmatic scroll during momentum rather than a shrink, so it corroborates
122
+ // the state rather than proving this path into it).
123
+ //
124
+ // So the arithmetic is treated as authoritative and the browser is not:
125
+ // reconcileScrollRange writes the offset the geometry says is the maximum. The
126
+ // correction is gated on a caller's announced row removal, which is what keeps
127
+ // an overscroll bounce (out of range with no content change) out of it.
90
128
  const BOTTOM_TOLERANCE_PX = 24;
91
129
  // An upward move only disengages follow when a real gap is left below it.
92
130
  // Bigger than 0 to absorb fractional-layout rounding in the shrink-clamp case
@@ -106,6 +144,12 @@ let preserveFollowOnce = false;
106
144
  // position: the next event is that clamp, not a gesture. Consumed (and in all
107
145
  // cases cleared) by the first event to arrive.
108
146
  let shrinkArmed = false;
147
+ // Armed by noteContentShrink when a caller's own row removal left the offset
148
+ // PAST the end of the content, i.e. the container did not reconcile it.
149
+ // Consumed by reconcileScrollRange in the same pass. Independent of shrinkArmed:
150
+ // one says the offset moved, this one says it did not move far enough, and a
151
+ // partial reconciliation sets both.
152
+ let rangeCorrectionOwed = false;
109
153
  let onFollowChange: ((scrolledUp: boolean) => void) | null = null;
110
154
  let onPosition: (() => void) | null = null;
111
155
  let scrollHandler: (() => void) | null = null;
@@ -117,6 +161,27 @@ function distanceFromBottom(): number {
117
161
  return scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
118
162
  }
119
163
 
164
+ /**
165
+ * The largest offset the container can hold: the zero point of
166
+ * distanceFromBottom, written out. Every write that means "the bottom" targets
167
+ * this rather than `scrollHeight`.
168
+ *
169
+ * The over-scroll write it replaces (`scrollTop = scrollHeight`, an extra
170
+ * clientHeight past the end) delegated the arithmetic to the container's own
171
+ * clamp. That is the one thing this module can no longer assume: the container
172
+ * this UI runs on is reported to leave an offset outside the valid range, and a
173
+ * write is not obviously exempt from that (the report behind
174
+ * rangeCorrectionOwed is about a programmatic scroll). Writing the maximum
175
+ * needs no clamp to be correct, is identical on a conforming container, and
176
+ * keeps one definition of "the bottom" for the reads and the writes both.
177
+ */
178
+ function bottomOffset(): number {
179
+ if (!scrollEl) {
180
+ return 0;
181
+ }
182
+ return Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
183
+ }
184
+
120
185
  function atBottom(): boolean {
121
186
  return distanceFromBottom() <= BOTTOM_TOLERANCE_PX;
122
187
  }
@@ -157,6 +222,7 @@ export function init(opts: {
157
222
  lastScrollTop = scrollEl.scrollTop;
158
223
  preserveFollowOnce = false;
159
224
  shrinkArmed = false;
225
+ rangeCorrectionOwed = false;
160
226
  scrollHandler = () => {
161
227
  if (!scrollEl) {
162
228
  return;
@@ -281,6 +347,12 @@ function writePreservingFollow(next: number): void {
281
347
  * this; that is correct rather than incidental, since it is likewise the
282
348
  * library's own mutation moving the viewport and not the user.
283
349
  *
350
+ * It answers a SECOND question at the same time, and the two are independent
351
+ * (see the header): whether the offset is still out of range after the removal.
352
+ * If it is, the container has not reconciled it and reconcileScrollRange owes a
353
+ * write. A partial reconciliation arms both, which is why neither test returns
354
+ * early on the other.
355
+ *
284
356
  * @param scrollTopBefore The container's offset read before the mutation.
285
357
  */
286
358
  export function noteContentShrink(scrollTopBefore: number): void {
@@ -290,19 +362,77 @@ export function noteContentShrink(scrollTopBefore: number): void {
290
362
  if (scrollEl.scrollTop < scrollTopBefore) {
291
363
  shrinkArmed = true;
292
364
  }
365
+ // The epsilon keeps its original job here too: scrollHeight and clientHeight
366
+ // are integer-rounded while scrollTop is fractional, so a correctly
367
+ // reconciled offset can read a fraction of a pixel past the end. Correcting
368
+ // that would write on every shrink pass for no visible gain.
369
+ if (distanceFromBottom() < -CLAMP_EPSILON_PX) {
370
+ rangeCorrectionOwed = true;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Move the offset back inside the container's range when a caller's own row
376
+ * removal left it past the end of the content. A no-op unless noteContentShrink
377
+ * armed it in this pass, so it is safe to call after every removal.
378
+ *
379
+ * A RENDERER SEAM. It is public because the whole scroll namespace is
380
+ * re-exported, not because a consumer has a reason to call it; an out-of-band
381
+ * call finds nothing armed and does nothing.
382
+ *
383
+ * Call it in the same pass as noteContentShrink and BEFORE the position
384
+ * invariants (the view restore, the read anchor, the bottom pin), so those
385
+ * measure a geometry the container agrees with. Deliberately NOT folded into
386
+ * either neighbour: `stickToBottom` is follow-gated by contract and a HOLDING
387
+ * reader whose history was discarded is stranded over empty space just the same
388
+ * (the read anchor stands down for a discard, so the pin cannot own this), and
389
+ * `noteContentShrink` is named for a read and the flush tail's own ordering
390
+ * comment depends on it not writing.
391
+ *
392
+ * The destination is the maximum offset, which for a reader who was HOLDING is
393
+ * the tail with follow still off. That is this design's ratified degradation for
394
+ * a reading position whose lines no longer exist (docs/scroll-position-fidelity.md
395
+ * §3.4, §5), not a new decision.
396
+ *
397
+ * The write goes through writePreservingFollow, so the scroll event it produces
398
+ * cannot re-derive the state: a following reader stays following, and a holding
399
+ * reader stays holding at the bottom.
400
+ *
401
+ * One accepted overlap, recorded so it is not read as an oversight. On a
402
+ * container that reports an offset past the maximum during an overscroll bounce,
403
+ * a bounce that coincides with a row-removing pass (cap eviction under heavy
404
+ * streaming) satisfies the gate, and this write then cuts the bounce short at
405
+ * the offset it was settling towards anyway. Refusing the correction whenever
406
+ * the offset was ALREADY out of range before the mutation would avoid that, at
407
+ * the price of skipping the repair for the whole rest of the session whenever
408
+ * the two coincide. A cut animation lasts one frame; a stranded viewport lasts
409
+ * until the user scrolls. The bounce loses.
410
+ */
411
+ export function reconcileScrollRange(): void {
412
+ if (!scrollEl || !rangeCorrectionOwed) {
413
+ return;
414
+ }
415
+ rangeCorrectionOwed = false;
416
+ writePreservingFollow(bottomOffset());
293
417
  }
294
418
 
295
419
  /**
296
420
  * Pin the viewport to the bottom iff the user is following. Called by the
297
421
  * renderer after each flush. A no-op when holding (scrolled up) or already at
298
422
  * the bottom, so it never fights the user and never scrolls redundantly.
423
+ *
424
+ * Only the positive branch of distanceFromBottom is this function's business: an
425
+ * offset PAST the end of the content is a geometry error rather than a following
426
+ * reader who fell behind, and reconcileScrollRange owns it (see the header).
427
+ * Correcting it here instead would fight an overscroll bounce, which presents
428
+ * the same arithmetic with nothing wrong.
299
429
  */
300
430
  export function stickToBottom(): void {
301
431
  if (!scrollEl || !following) {
302
432
  return;
303
433
  }
304
434
  if (distanceFromBottom() > 0) {
305
- scrollEl.scrollTop = scrollEl.scrollHeight;
435
+ scrollEl.scrollTop = bottomOffset();
306
436
  }
307
437
  }
308
438
 
@@ -314,7 +444,7 @@ export function scrollToBottom(): void {
314
444
  if (!scrollEl) {
315
445
  return;
316
446
  }
317
- scrollEl.scrollTop = scrollEl.scrollHeight;
447
+ scrollEl.scrollTop = bottomOffset();
318
448
  setFollowing(true);
319
449
  }
320
450