@cplieger/web-terminal-engine 3.10.2 → 4.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/README.md CHANGED
@@ -106,7 +106,7 @@ curl -fsSL https://jsr.io/@cplieger/web-terminal-engine/<version>/wire-compatibi
106
106
  ```json
107
107
  {
108
108
  "schemaVersion": 1,
109
- "generatedBy": "web/src/wire-manifest.ts",
109
+ "generatedBy": "web/src/test-helpers/wire-manifest.ts",
110
110
  "wireCompatibility": {
111
111
  "protocolVersion": 4,
112
112
  "minimumServerProtocolVersion": 3,
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.10.2",
3
+ "version": "4.0.1",
4
4
  "license": "MPL-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -14,7 +14,8 @@
14
14
  ],
15
15
  "exclude": [
16
16
  "src/**/*.test.ts",
17
- "src/**/__test-helpers__/**"
17
+ "src/**/*-setup.ts",
18
+ "src/**/test-helpers/**"
18
19
  ]
19
20
  }
20
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.10.2",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
@@ -67,8 +67,8 @@
67
67
  "files": [
68
68
  "src/",
69
69
  "!src/**/*.test.ts",
70
- "!src/**/test-helpers",
71
- "!src/**/fc-strict-setup.ts",
70
+ "!src/**/*-setup.ts",
71
+ "!src/**/test-helpers/**",
72
72
  "jsr.json",
73
73
  "LICENSE",
74
74
  "wire-compatibility.json"
package/src/render.ts CHANGED
@@ -494,6 +494,11 @@ function queueRowsViewportFirst(): void {
494
494
  * history; the existing per-frame budget spreads a large backlog across frames
495
495
  * so the switch never janks. Used by bind(); also safe to call directly to
496
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.
497
502
  */
498
503
  export function rebuild(): void {
499
504
  // The wipe is the largest content shrink this module performs, and it happens
@@ -515,6 +520,9 @@ export function rebuild(): void {
515
520
  // Fresh surface: a stale give-up streak from a prior store must not deny the
516
521
  // rebuilt surface its full transient-retry budget.
517
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();
518
526
  // Alt screen paints from the ephemeral grid in the flush, so it needs no
519
527
  // absolute-index queueing here.
520
528
  if (!store.isAlt()) {
@@ -524,6 +532,60 @@ export function rebuild(): void {
524
532
  scheduleFlush();
525
533
  }
526
534
 
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
+ }
588
+
527
589
  /**
528
590
  * Highest absolute line index the client holds, or -1 if empty. This is the
529
591
  * resume `haveThrough` value (it replaces the old DOM-row count). Exposed so
package/src/scroll.ts CHANGED
@@ -265,14 +265,16 @@ function writePreservingFollow(next: number): void {
265
265
  * absolutely-positioned overlay) — and a lingering arm swallows the next real
266
266
  * gesture.
267
267
  *
268
- * That last case is live in this codebase rather than illustrative: the caret
269
- * overlay, the predicted cursor, the IME composition view and the consumer's
270
- * hidden textarea all sit inside the scroll container carrying a `top` in CONTENT
271
- * coordinates. Nothing resets them on a wipe: the renderer's `init` drops the two
272
- * it owns (the caret and the predicted cursor), a full consumer remount recreates
273
- * the other two, and `rebuild` does neither. So a wipe can leave scrollHeight held
274
- * above the built content by a stale overlay, the clamp never happens, and there
275
- * is nothing for this arm to cover. See `docs/tab-switch-repaint.md` §6.2.
268
+ * That last case used to be live here rather than illustrative, and it is why
269
+ * the caller's ORDER matters: the caret, the predicted cursor, the IME view and
270
+ * the consumer's hidden textarea all sit inside the scroll container carrying a
271
+ * `top` in CONTENT coordinates, so any one of them left at the old offset holds
272
+ * scrollHeight above the built content and the clamp never happens. `rebuild`
273
+ * now collapses all four as part of its wipe, before it calls this
274
+ * (`render.ts` collapseContentSpaceOverlays), so the clamp it announces is real.
275
+ * A caller that removes content without collapsing whatever else is anchored in
276
+ * that space arms nothing and gets no announcement. See
277
+ * `docs/tab-switch-repaint.md` §6.2.
276
278
  *
277
279
  * A native-scroll-anchoring adjustment (Chrome/Firefox lowering scrollTop as
278
280
  * rows are removed above the viewport) also moves the position and also arms
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedBy": "web/src/wire-manifest.ts",
3
+ "generatedBy": "web/src/test-helpers/wire-manifest.ts",
4
4
  "wireCompatibility": {
5
5
  "protocolVersion": 4,
6
6
  "minimumServerProtocolVersion": 3,
@@ -1,77 +0,0 @@
1
- // Generator for the published, language-neutral wire-compatibility manifest
2
- // (`web/wire-compatibility.json`).
3
- //
4
- // Why it exists: the wire revision and directional floor are published as
5
- // TypeScript constants (wire-compatibility.ts) and as Go constants
6
- // (terminal.WireProtocolVersion / MinSupportedClientWireVersion). A consumer
7
- // that is neither — a Dockerfile, a shell release gate, a Python CI script —
8
- // had no machine-readable surface to read them from and resorted to scraping
9
- // the TypeScript source with `sed`, which breaks silently on any reformat.
10
- //
11
- // This module renders that surface FROM the existing WIRE_COMPATIBILITY export,
12
- // so the numbers keep exactly one source of truth in the TypeScript half. The
13
- // rendered text is checked into the repo (see the drift guard in
14
- // wire-manifest.test.ts) because the publish pipeline runs no build step: npm
15
- // and JSR publish the working tree as-is, so a manifest generated at publish
16
- // time would never exist in the tarball.
17
- //
18
- // The manifest deliberately does NOT carry the package version. `npm pkg set
19
- // version` injects that at publish time, after checkout, with no regeneration
20
- // step — an embedded version would be frozen at the repo's placeholder (0.1.0)
21
- // in every published artifact. Consumers read the package version from
22
- // package.json / jsr.json, which the pipeline does rewrite.
23
- //
24
- // Public-artifact contract: see the "Wire compatibility manifest" section of
25
- // web/README.md for what a consumer may rely on and what counts as a breaking
26
- // change.
27
-
28
- import { WIRE_COMPATIBILITY, type WireCompatibility } from "./wire-compatibility.js";
29
-
30
- /**
31
- * Schema revision of the manifest FILE, independent of the wire protocol
32
- * revision it reports. A consumer must reject a `schemaVersion` it does not
33
- * understand rather than guess at the shape. Bumped only for a breaking
34
- * change to the manifest's own layout (a removed or repurposed field).
35
- */
36
- export const WIRE_MANIFEST_SCHEMA_VERSION = 1;
37
-
38
- /** Path of the checked-in artifact this module renders, relative to `web/`. */
39
- export const WIRE_MANIFEST_PATH = "wire-compatibility.json";
40
-
41
- /** Shape of the generated wire-compatibility manifest. */
42
- export interface WireManifest {
43
- /** Schema revision of this file's layout (see WIRE_MANIFEST_SCHEMA_VERSION). */
44
- readonly schemaVersion: number;
45
- /** Informational provenance note; not part of the parsed contract. */
46
- readonly generatedBy: string;
47
- /** The same values the TypeScript `WIRE_COMPATIBILITY` export carries. */
48
- readonly wireCompatibility: WireCompatibility;
49
- }
50
-
51
- /**
52
- * Builds the manifest object from the live TypeScript constants. Nesting the
53
- * protocol values under `wireCompatibility` keeps file-level metadata
54
- * (`schemaVersion`) in a separate namespace from the payload, so a future
55
- * metadata field can never collide with a protocol field name.
56
- */
57
- export function buildWireManifest(): WireManifest {
58
- return {
59
- schemaVersion: WIRE_MANIFEST_SCHEMA_VERSION,
60
- generatedBy: "web/src/wire-manifest.ts",
61
- wireCompatibility: {
62
- protocolVersion: WIRE_COMPATIBILITY.protocolVersion,
63
- minimumServerProtocolVersion: WIRE_COMPATIBILITY.minimumServerProtocolVersion,
64
- incompatibleCloseCode: WIRE_COMPATIBILITY.incompatibleCloseCode,
65
- },
66
- };
67
- }
68
-
69
- /**
70
- * Renders the exact bytes of the checked-in manifest: two-space indentation and
71
- * a trailing newline, which is what `prettier --check` requires of a JSON file
72
- * in this repo. Comparing rendered text (not parsed objects) is what makes the
73
- * drift guard able to fail on a hand edit that happens to parse the same.
74
- */
75
- export function renderWireManifest(): string {
76
- return `${JSON.stringify(buildWireManifest(), null, 2)}\n`;
77
- }