@cplieger/web-terminal-engine 3.0.7 → 3.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/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.0.7",
3
+ "version": "3.2.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.0.7",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
@@ -21,13 +21,16 @@
21
21
  "esbuild": "0.28.1",
22
22
  "eslint": "10.7.0",
23
23
  "fast-check": "4.9.0",
24
- "happy-dom": "20.11.0",
24
+ "happy-dom": "20.11.1",
25
25
  "pngjs": "7.0.0",
26
26
  "prettier": "3.9.6",
27
27
  "typescript": "npm:@typescript/typescript6@6.0.2",
28
28
  "typescript-eslint": "8.65.0",
29
29
  "vitest": "4.1.10"
30
30
  },
31
+ "overrides": {
32
+ "qs": "^6.15.2"
33
+ },
31
34
  "description": "Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol",
32
35
  "license": "GPL-3.0-or-later",
33
36
  "exports": {
package/src/render.ts CHANGED
@@ -669,8 +669,80 @@ function scheduleFlush(): void {
669
669
  pendingFrame = requestAnimationFrame(flushRender);
670
670
  }
671
671
 
672
+ // --- Read-position anchoring (manual scroll anchoring) ---
673
+ //
674
+ // A flush can change the content height ABOVE the reading position: rows evicted
675
+ // from the top of history once the retention cap is reached, and the trim marker
676
+ // appearing. Every such change moves whatever the user is reading, unless the
677
+ // viewport is shifted by the same amount.
678
+ //
679
+ // Chrome and Firefox do that natively (scroll anchoring, `overflow-anchor`).
680
+ // WebKit does not implement it at all, so on Safari — including the iPad, where
681
+ // this UI mostly runs — a user scrolled up to read had their position dragged
682
+ // one line per evicted row for as long as output kept arriving.
683
+ //
684
+ // The anchor is the first row at or below the viewport top, found by binary
685
+ // search over the container's children: they are in document order, so their
686
+ // offsetTop is monotonic. ~13 reads for a full 5000-row buffer, and only while
687
+ // the user is actually scrolled up — a following viewport takes this path not at
688
+ // all, because the bottom pin owns its position.
689
+ interface ReadAnchor {
690
+ el: HTMLElement;
691
+ /** Where the row sat ON SCREEN: offsetTop minus the scroll offset. Measuring
692
+ * the screen position rather than the container offset is what makes the
693
+ * correction idempotent — see restoreReadAnchor. */
694
+ screenTop: number;
695
+ }
696
+
697
+ function captureReadAnchor(): ReadAnchor | null {
698
+ if (!scroll.isUserScrolledUp()) {
699
+ return null; // following: stickToBottom owns the position
700
+ }
701
+ const kids = output.children;
702
+ if (kids.length === 0) {
703
+ return null;
704
+ }
705
+ const offset = scroll.currentScrollTop();
706
+ let lo = 0;
707
+ let hi = kids.length - 1;
708
+ let found: HTMLElement | null = null;
709
+ while (lo <= hi) {
710
+ const mid = (lo + hi) >> 1;
711
+ const el = kids[mid] as HTMLElement;
712
+ if (el.offsetTop >= offset) {
713
+ found = el;
714
+ hi = mid - 1;
715
+ } else {
716
+ lo = mid + 1;
717
+ }
718
+ }
719
+ // Everything is above the viewport top (a shrink already clamped past the
720
+ // last row): anchor on the last row rather than giving up.
721
+ const el = found ?? (kids[kids.length - 1] as HTMLElement);
722
+ return { el, screenTop: el.offsetTop - offset };
723
+ }
724
+
725
+ function restoreReadAnchor(anchor: ReadAnchor | null): void {
726
+ if (anchor?.el.parentElement !== output) {
727
+ return; // not holding, or the anchor row was itself evicted (mass trim)
728
+ }
729
+ // Correct by how far the row DRIFTED ON SCREEN, not by how much the content
730
+ // above it changed. The two differ exactly when the browser already did this
731
+ // itself: Chrome and Firefox have native scroll anchoring, so their offsetTop
732
+ // change comes with a matching scrollTop change and the screen position is
733
+ // already right — this then measures zero drift and does nothing. Correcting
734
+ // the content delta instead would double-compensate there and throw the view
735
+ // the other way. On Safari, which has no native anchoring, the drift is the
736
+ // whole content delta and this is the only thing that fixes it.
737
+ const drift = anchor.el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
738
+ scroll.adjustForContentShift(drift);
739
+ }
740
+
672
741
  function flushRender(): void {
673
742
  pendingFrame = undefined;
743
+ // Measured BEFORE any DOM mutation, so the post-mutation read below reveals
744
+ // exactly how much the content above the reading position moved.
745
+ const anchor = captureReadAnchor();
674
746
  try {
675
747
  flushRenderInner();
676
748
  // A clean pass means the error condition (if any) has cleared, so give a
@@ -704,6 +776,12 @@ function flushRender(): void {
704
776
  console.error("vterm: giving up render retry after repeated no-progress errors");
705
777
  }
706
778
  }
779
+ // Two position invariants, applied after every DOM mutation and in this order:
780
+ // hold the reader's line against content that shifted above it, then (only if
781
+ // following) pin to the new bottom. They are mutually exclusive by
782
+ // construction — the anchor is null while following — so the order is for
783
+ // readability, not correctness.
784
+ restoreReadAnchor(anchor);
707
785
  // Single auto-follow invariant, applied after every DOM mutation.
708
786
  stickToBottomIfFollowing();
709
787
  }
package/src/scroll.ts CHANGED
@@ -10,8 +10,13 @@
10
10
  // failure mode; see the #web-terminal-engine steering doc, "Design rationale").
11
11
  //
12
12
  // The renderer calls stickToBottom() once after each flush: if following, pin
13
- // to the new bottom; if holding, do nothing and let native scroll anchoring
14
- // (overflow-anchor) hold the reading position when history is inserted above.
13
+ // to the new bottom; if holding, do nothing. Holding the reading position when
14
+ // content ABOVE it changes height (top-of-history eviction at the retention cap,
15
+ // the trim marker appearing) is adjustForContentShift's job, called by the
16
+ // renderer around its DOM mutations. That used to be left to native scroll
17
+ // anchoring (overflow-anchor), which WebKit has never shipped — so on Safari and
18
+ // iPadOS the read position slid up one line per evicted row while the user was
19
+ // scrolled up reading.
15
20
  // Appending content at the bottom does not fire a scroll event, so following
16
21
  // stays true across new output and the post-flush pin lands correctly. Pinning
17
22
  // to the bottom produces a scroll event whose recomputation yields
@@ -148,6 +153,34 @@ export function currentScrollTop(): number {
148
153
  return scrollEl ? scrollEl.scrollTop : 0;
149
154
  }
150
155
 
156
+ /**
157
+ * Shift the viewport by a content-height change that happened ABOVE the reading
158
+ * position, so the user keeps looking at the same line. A no-op while following
159
+ * (the bottom pin owns the position then) and when the delta is zero.
160
+ *
161
+ * This is scroll anchoring, done by hand. Chrome and Firefox do it natively
162
+ * (`overflow-anchor`), and this module's design leaned on that: "if holding, do
163
+ * nothing and let native scroll anchoring hold the reading position when history
164
+ * is inserted above". WebKit has never shipped scroll anchoring, so on Safari
165
+ * — including iPadOS, where this UI mostly lives — nothing compensated, and
166
+ * every row evicted from the top of history while the user was scrolled up
167
+ * reading slid their reading position one line further up. Over a streaming
168
+ * agent session at the retention cap that reads as "the view scrolls itself
169
+ * while I am trying to read".
170
+ *
171
+ * `following` is deliberately NOT touched, and neither is `lastScrollTop`: the
172
+ * state stays derived purely from the resulting scroll event, exactly as it is
173
+ * for the bottom pin. Both directions recompute to holding (an upward shift
174
+ * leaves a gap below; a downward one is not at the bottom), so there is no churn
175
+ * and no follow flip.
176
+ */
177
+ export function adjustForContentShift(deltaPx: number): void {
178
+ if (!scrollEl || following || deltaPx === 0) {
179
+ return;
180
+ }
181
+ scrollEl.scrollTop += deltaPx;
182
+ }
183
+
151
184
  /**
152
185
  * Restore a previously saved scroll offset (per-view scroll memory: a tabbed
153
186
  * shell re-entering a tab whose user had scrolled up to read). The offset is
@@ -9,16 +9,28 @@ import { nextBackoffDelay } from "./reconnect.js";
9
9
 
10
10
  /** SessionInfo is one session's wire shape: the JSON object the session REST
11
11
  * API (GET/POST /api/sessions) returns per session. Mirrors the Go
12
- * terminal.SessionInfo — the two are kept in lockstep by hand (single 6-field
13
- * type; flip to wiregen if this surface grows). */
12
+ * terminal.SessionInfo — the two are kept in lockstep by hand (single 7-field
13
+ * type; flip to wiregen if this surface grows).
14
+ *
15
+ * Three of the fields are title-shaped and they are not interchangeable:
16
+ * `title` is the RESOLVED display title, `pinnedTitle` is the USER's name (set
17
+ * and cleared only by a human action, outranking everything else), and
18
+ * `clientTitle` is a CLIENT-DERIVED automatic title a client asked the server to
19
+ * remember. */
14
20
  export interface SessionInfo {
15
21
  readonly id: string;
16
22
  readonly status: "working" | "idle" | "input" | "done" | "exited";
17
23
  readonly title: string;
18
- /** the raw client-set title (before the OSC fallback baked into `title`); a
19
- * consumer that treats the program's OSC window title as unreliable reads
20
- * this instead of `title`. */
24
+ /** the raw client-derived automatic title (before the precedence baked into
25
+ * `title`); a consumer that treats the program's OSC window title as
26
+ * unreliable reads this instead of `title`. */
21
27
  readonly clientTitle?: string;
28
+ /** the user's pinned name, set through PUT /api/sessions/{id}/pinned-title and
29
+ * cleared through DELETE on the same path. It outranks every automatic source
30
+ * in `title`; it is carried raw so a UI can tell that a pin EXISTS (to offer
31
+ * the automatic name again) rather than only seeing its effect. Absent or
32
+ * empty means the session has no user-set name. */
33
+ readonly pinnedTitle?: string;
22
34
  readonly createdAt: string;
23
35
  /** true once the session has emitted a genuine activity signal — OSC 9;4
24
36
  * progress (kiro-cli, Claude Code, …) or a classified OSC 9 notification.