@cplieger/web-terminal-engine 3.8.0 → 3.10.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/README.md CHANGED
@@ -134,4 +134,4 @@ This package depends on `document`, `HTMLElement`, `MessageChannel`, and other D
134
134
 
135
135
  ## License
136
136
 
137
- GPL-3.0 — see [LICENSE](../LICENSE).
137
+ MPL-2.0. See [LICENSE](../LICENSE).
package/jsr.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.8.0",
3
+ "version": "3.10.0",
4
+ "license": "MPL-2.0",
4
5
  "exports": {
5
6
  ".": "./src/index.ts"
6
7
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.8.0",
3
+ "version": "3.10.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
@@ -32,7 +32,7 @@
32
32
  "qs": "^6.15.2"
33
33
  },
34
34
  "description": "Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol",
35
- "license": "GPL-3.0-or-later",
35
+ "license": "MPL-2.0",
36
36
  "exports": {
37
37
  ".": "./src/index.ts",
38
38
  "./wire-compatibility.json": "./wire-compatibility.json"
package/src/render.ts CHANGED
@@ -234,6 +234,10 @@ function termPadding(): { padL: number; padT: number } {
234
234
  let flushDrainedThisPass = 0;
235
235
  let renderNoProgressStreak = 0;
236
236
  const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
237
+ // The stall canary's latch (see the reschedule rule near flushRender). Reset by
238
+ // init, and by any flush that ends in a healthy state, so it suppresses a repeat
239
+ // of ONE stall rather than every stall for the life of the process.
240
+ let unnamedStallReported = false;
237
241
  // Whether the CURRENT flush performed a full reset (server restart / epoch
238
242
  // change). Read by restoreReadAnchor after the flush: a re-anchor across a
239
243
  // reset would match unrelated content from the new index space (R2 review).
@@ -339,6 +343,11 @@ export function init(opts: {
339
343
  }
340
344
  flushDrainedThisPass = 0;
341
345
  renderNoProgressStreak = 0;
346
+ // The stall canary latches so it cannot spam, and init is the attachment
347
+ // boundary, so the latch resets here with every other piece of pass state. A
348
+ // latch that survived would make the canary a once-per-PROCESS report, silent
349
+ // for every later attachment and for every genuinely new stall.
350
+ unnamedStallReported = false;
342
351
  // Attachment boundary: a view restore armed against the PREVIOUS surface must
343
352
  // not survive it, or its timer outlives teardown and writes scrollTop on a
344
353
  // detached element. Same discipline scroll.init applies to its own arms.
@@ -959,6 +968,104 @@ function scheduleFlush(): void {
959
968
  pendingFrame = requestAnimationFrame(flushRender);
960
969
  }
961
970
 
971
+ /**
972
+ * The renderer's half of a scroll-position event. Wire this to
973
+ * `scroll.init({ onScrollPosition })` and nothing else: it is the ONE hook the
974
+ * renderer needs from a position change, so a consumer cannot half-wire it.
975
+ *
976
+ * Two jobs, in this order. Re-evaluate the demand-paging trigger, because the
977
+ * reader may have approached a gap. Then finish a drain that stopped with rows
978
+ * still queued, which is reachable on an idle session: the bounded error path in
979
+ * `flushRender`'s catch deliberately stops rescheduling, and no inbound frame
980
+ * arrives to restart it.
981
+ *
982
+ * It replaced a pair of exported calls (`maybeFetchHistory` plus a public
983
+ * `resumeDrain`) because that pair was a silent-omission trap of exactly the
984
+ * shape the paged-scrollback docs warn about: a consumer upgrading the engine and
985
+ * keeping its old one-line wiring got paging and no drain recovery, with no type
986
+ * error and no runtime complaint. `maybeFetchHistory` stays exported for the
987
+ * transport's own retry path, which is a different event.
988
+ */
989
+ export function handleScrollPosition(): void {
990
+ maybeFetchHistory();
991
+ resumeDrain();
992
+ }
993
+
994
+ /**
995
+ * Resume a drain that still has rows queued. Private: reached only through
996
+ * `handleScrollPosition`, so there is one wiring obligation rather than two.
997
+ *
998
+ * Three refusals, and each one is load-bearing rather than defensive:
999
+ *
1000
+ * **An empty queue.** `flushRender` runs `applyPendingRestore`,
1001
+ * `restoreReadAnchor` and `stickToBottomIfFollowing` unconditionally; only the
1002
+ * drain is queue-gated. So a flush scheduled from a scroll handler would run the
1003
+ * position invariants at moments the renderer never previously flushed at, and
1004
+ * because `atBottom` tolerates `BOTTOM_TOLERANCE_PX` while `stickToBottom` pins
1005
+ * on any non-zero gap, a downward scroll landing just short of the tail on an
1006
+ * IDLE session would snap to the bottom one frame later. It would also expire an
1007
+ * armed view restore early through `RESTORE_OWN_WRITE_EPSILON_PX`.
1008
+ *
1009
+ * **Alt screen.** Alt is a NAMED suspension of the drain (see the reschedule rule
1010
+ * below), and a resumption path that ignores it is not a resumption path. The
1011
+ * drain would survive anyway, because `flushRenderInner`'s alt branch returns
1012
+ * before the drain, but the flush around it would still call `renderAlt` (which
1013
+ * replaces the whole output subtree, destroying an in-alt selection) and run all
1014
+ * three position invariants. Leaning on the body of a function to make a
1015
+ * scheduling decision safe is how the next edit to that body becomes a bug. Alt
1016
+ * exit re-queues everything through `queueRowsViewportFirst`, so refusing costs
1017
+ * nothing.
1018
+ *
1019
+ * **A give-up already retried.** The catch's give-up exists so a deterministically
1020
+ * throwing row cannot become a 60fps loop. Retrying it from a scroll event would
1021
+ * move that loop outside the module rather than remove it: a flick is one scroll
1022
+ * event per frame, and `scroll.ts`'s position callback fires for the browser's own
1023
+ * clamps too, so a stuck row would log two errors per frame for the length of a
1024
+ * gesture. One retry per give-up is enough to serve the case this function exists
1025
+ * for (the reader scrolls, the surface completes) without reopening the loop the
1026
+ * cap closed. The retry is re-earned by anything that clears the streak: real
1027
+ * forward progress, an empty queue, a rebuild, or a re-attach. NOT by the give-up
1028
+ * log — a retry that throws gives up again, and re-arming there would restore the
1029
+ * per-gesture loop one indirection further out.
1030
+ *
1031
+ * Residual, accepted: `scroll.ts` fires its position seam before its own
1032
+ * announced-shrink early return, so a programmatic clamp (a cap eviction) can spend
1033
+ * the retry with no user action. The exposure is small because any successful flush
1034
+ * re-arms it, and the alternative (a time bound) adds a clock to a path whose whole
1035
+ * purpose is to be cheap.
1036
+ */
1037
+ function resumeDrain(): void {
1038
+ if (renderQueue.size === 0 || store.isAlt()) {
1039
+ return;
1040
+ }
1041
+ // A frame is already coming, so there is nothing to resume and nothing to spend.
1042
+ // Without this the retry is burned in a state where it buys NOTHING: the catch
1043
+ // reaches `streak === MAX` from its own progress-less branch and schedules a frame
1044
+ // on the way out, so for one frame the streak is at the cap with a flush pending.
1045
+ // A scroll landing there would set the streak past the cap while `scheduleFlush`
1046
+ // no-ops on the occupied slot, and the real give-up one frame later would then
1047
+ // find its one retry already gone. The spend must be gated on the give-up having
1048
+ // HAPPENED, and an empty frame slot is what distinguishes that.
1049
+ if (pendingFrame !== undefined) {
1050
+ return;
1051
+ }
1052
+ if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
1053
+ if (renderNoProgressStreak > MAX_RENDER_NO_PROGRESS_RETRIES) {
1054
+ return;
1055
+ }
1056
+ // Spend the one retry by carrying it in the streak itself, rather than in a
1057
+ // second flag beside it. A flag was tried and it leaked: it had to be cleared
1058
+ // at every site that clears the streak, it was cleared at three of five, and
1059
+ // the two misses (`rebuild`, and a clean pass) are the common ones — so one
1060
+ // give-up plus one scroll disabled the resume for the rest of the attachment,
1061
+ // invisibly, because that state presents to the canary as a plain give-up. With
1062
+ // the count carrying it there is nothing to keep in sync: every existing
1063
+ // `renderNoProgressStreak = 0` re-arms the retry by construction.
1064
+ renderNoProgressStreak = MAX_RENDER_NO_PROGRESS_RETRIES + 1;
1065
+ }
1066
+ scheduleFlush();
1067
+ }
1068
+
962
1069
  // --- Read-position anchoring (manual scroll anchoring) ---
963
1070
  //
964
1071
  // A flush can change the content height ABOVE the reading position: rows evicted
@@ -1241,6 +1348,54 @@ function flushRender(): void {
1241
1348
  // beside them — so the flush is the other place the trigger must run
1242
1349
  // (docs/paged-scrollback.md §5.4).
1243
1350
  maybeFetchHistory();
1351
+ reportUnnamedDrainStall();
1352
+ }
1353
+
1354
+ // --- The reschedule rule ---
1355
+ //
1356
+ // After a flush, owed rows are in exactly one of four states. The set is
1357
+ // exhaustive by construction here rather than by review of each return site,
1358
+ // which is what a queue left non-empty with an empty `pendingFrame` slot costs:
1359
+ // `scheduleFlush` is a single slot, so every later request from every source is
1360
+ // silently dropped and nothing drains.
1361
+ //
1362
+ // 1. nothing owed - the queue is empty.
1363
+ // 2. scheduled - a frame will drain it.
1364
+ // 3. suspended, named edge - alt screen. `renderAlt` replaces the whole output
1365
+ // subtree, so continuing the drain would inject
1366
+ // main-buffer rows into the alt grid; the alt-exit
1367
+ // branch re-queues everything via
1368
+ // queueRowsViewportFirst, so nothing is lost. The
1369
+ // resumption edge is alt exit. A future third
1370
+ // suspension point must declare its edge here.
1371
+ // 4. stopped and reported - the bounded error path gave up after
1372
+ // MAX_RENDER_NO_PROGRESS_RETRIES and logged. It is a
1373
+ // guard on the process, so it refuses rather than
1374
+ // looping; the next inbound frame retries, or one
1375
+ // scroll does (resumeDrain, once per give-up).
1376
+ //
1377
+ // Anything else is a stall nobody owns. Reported once PER EPISODE: the latch
1378
+ // clears as soon as a flush ends in a healthy state, so a repeat of one stall
1379
+ // stays quiet while a genuinely new stall later is still reported. Log spam would
1380
+ // be its own defect, and so would a canary that goes deaf after the first report.
1381
+ function reportUnnamedDrainStall(): void {
1382
+ if (renderQueue.size === 0 || pendingFrame !== undefined || store.isAlt()) {
1383
+ // A healthy end: the episode (if any) is over, so re-arm the canary.
1384
+ unnamedStallReported = false;
1385
+ return;
1386
+ }
1387
+ if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
1388
+ // Named and already logged by the catch. Not a healthy state, so the latch is
1389
+ // left as it is rather than re-armed.
1390
+ return;
1391
+ }
1392
+ if (unnamedStallReported) {
1393
+ return;
1394
+ }
1395
+ unnamedStallReported = true;
1396
+ console.warn(
1397
+ `vterm: ${String(renderQueue.size)} rows queued with no scheduled frame and no named suspension`,
1398
+ );
1244
1399
  }
1245
1400
 
1246
1401
  function flushRenderInner(): void {
package/src/scroll.ts CHANGED
@@ -265,6 +265,15 @@ 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.
276
+ *
268
277
  * A native-scroll-anchoring adjustment (Chrome/Firefox lowering scrollTop as
269
278
  * rows are removed above the viewport) also moves the position and also arms
270
279
  * this; that is correct rather than incidental, since it is likewise the
@@ -9,7 +9,7 @@ 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 7-field
12
+ * terminal.SessionInfo — the two are kept in lockstep by hand (single 8-field
13
13
  * type; flip to wiregen if this surface grows).
14
14
  *
15
15
  * Three of the fields are title-shaped and they are not interchangeable:
@@ -46,6 +46,28 @@ export interface SessionInfo {
46
46
  * empty means the session has no user-set name. */
47
47
  readonly pinnedTitle?: string;
48
48
  readonly createdAt: string;
49
+ /** the session's position in the display order every viewer of this server
50
+ * shares: 0-based, dense, and unique across the live set. Order is a property
51
+ * of the SESSION SET rather than of one browser, so two devices agree on the
52
+ * arrangement and a reorder made on one appears on the other. Set it with
53
+ * `PUT /api/sessions/order`, which takes every live id in the wanted order.
54
+ *
55
+ * Sort by this FIELD, not by the sequence sessions arrived in. The REST list
56
+ * and the status stream are both served in this order, but a consumer that
57
+ * merges them (subscribing before its bootstrap list resolves, which is how to
58
+ * avoid double-adopting a session) sees neither sequence intact.
59
+ *
60
+ * Absent from a server before 3.9.0, and from a status event carrying
61
+ * `removed` (the session has left the order). Treat absent as "this server has
62
+ * no shared order" and fall back to `createdAt`; do not read it as position 0,
63
+ * which belongs to a real session.
64
+ *
65
+ * One reorder arrives as one status event PER MOVED SESSION, so until you have
66
+ * applied a whole tick two sessions can hold the same position. Apply the tick,
67
+ * then sort. And never derive an order you WRITE BACK from a partly applied
68
+ * view: a list built from that hybrid still names the live set exactly, so the
69
+ * server accepts it and it becomes the arrangement every device sees. */
70
+ readonly order?: number;
49
71
  /** true once the session has emitted a genuine activity signal — OSC 9;4
50
72
  * progress (kiro-cli, Claude Code, …) or a classified OSC 9 notification.
51
73
  * Sticky for the session's life. Consumers reveal the per-tab activity dot