@cplieger/web-terminal-engine 6.0.2 → 6.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
@@ -144,17 +144,17 @@ Two engines share nothing: each holds its own DOM handles, timers, listeners, so
144
144
 
145
145
  ## API
146
146
 
147
- - **`createTerminalEngine(options)`** — builds the five parts below over `output` and `termWrap`, wires them together, routes `screen`, `scroll` and `modes` frames to the renderer before your `onMessage` sees them, and returns a `TerminalEngine` (`renderer`, `scroll`, `connection`, `mouse`, `modes`, `dispose`). Options: `callbacks` (a `ConnectionCallbacks`), `wsPath`, `sessionIdKey`, `maxLines`, `onCursorMove`, `onUserScrollChange`, `initialModes`. It neither connects nor measures fonts; call `renderer.updateFontMetrics()` when your font is ready and `connection.connect()` to start.
148
- - **`createRenderer(options)` → `Renderer`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames over `output` inside `termWrap`, with an injected `scroll` controller and `modes` state. `maxLines` is the retained-line cap of its store (memory-constrained consumers pass a smaller budget; history above the cap is evicted from the top in batches, and the live screen is never evicted, so a cap at or below the terminal height keeps the full screen with no scrollback). `handleScreen`, `handleScroll`, `handleScrollPosition`, `updateFontMetrics`, `computeSize`, `cellSize`, `gridSize`, `getCursorPx`, `setPredictedCursor`, `bind`, `boundStore`, `captureViewMemory`, `resetScreen`, `resetScrollback`, `getHighestIndex`, `getReplayBoundary`, `noteResumeBounds`, `updateReverseVideo`, `dispose`.
149
- - **`keyboard`** — Translates `KeyboardEvent` to terminal byte sequences. `mapKeyboardEvent(ev, modes)`, `bracketTextForPaste(text, modes)`, `kittyDisambiguateActive(modes)`, `prepareTextForTerminal`, `ctrlByteFor`, plus the shared logical-key encodings (`plainCursorKeySeq`, `plainEscapeSeq`) the toolbar module reuses. Every mode-dependent function takes the mode state as an argument; an engine's `modes` satisfies it.
150
- - **`toolbar`** — On-screen mobile toolbar widget. `bindMobileToolbar({toolbar, send, modes, 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, read from the required `modes`), returning a `MobileToolbarController` exposing `applyStickyCtrl`, `setCtrlArmed`, `isCtrlArmed`, and `dispose`.
151
- - **`createMouseController(options)` → `MouseController`**, **`encodeSGR`** — SGR 1006 mouse encoder attached to `termElement()`. The options are a `MouseInputHandler` plus `modes`; the controller exposes `resyncGesture`, `disarmGesture` and `dispose`. Auto-gates on `mouseMode > 0`. `MouseInputHandler` carries two optional members, both defaulting to today's behaviour: `gridElement` names the element whose box IS the grid (the scroll container's own box includes its padding and scrollbar gutter, so it is not that box), and `gridSize` supplies the dimensions of the grid ON SCREEN — `renderer.gridSize`, not the size this client measured — so every report is clamped into it and rows are measured up from the bottom edge (the screen window is the tail of the content). Its required `sendReport(data)` returns whether the report reached a live socket; wire it to `connection.sendEphemeral`. Four consequences of mouse input being best-effort: a release is emitted only for a press that was DELIVERED (a release whose press never arrived is its own desync, because the application never learned the button went down); a drag reports a held button only while the application believes that button is down, so motion for an undelivered gesture is dropped under mode 1002 and reports as no-button under 1003; `resyncGesture()`, called by the connection once the resumeAck has declared the server's capabilities (not from `onOpen`, where the ephemeral channel is not yet known, so the release would take the reliable fallback), emits one release for any button the last reading says is now up, so a drag is not left pressed across a reconnect; and motion is coalesced to one report per animation frame, latest wins, while press, release and wheel are never coalesced (a wheel report is a notch count against a screen, so coalescing loses scroll distance). `resyncGesture` shares one gate with the handlers, and each of them enforces it: a gesture stranded by the application turning tracking off is FORGOTTEN by whichever one next observes tracking off, because a release nothing asked for reaches the PTY as keystrokes. tmux answers the same way at attach, clearing `mouse_drag_flag` as it disables the modes. `disarmGesture()` forgets a held gesture without reporting anything — call it when pointing one terminal's socket at another session, since the record belongs to the session that saw the press. Focus reporting is NOT here: it is transport state, and it lives in `connection.setClientFocus`.
152
- - **`createScrollController(options)` → `ScrollController`** — Auto-follow tracker for the scroll container `scrollEl`, with optional `onUserScrollChange` and `onScrollPosition` callbacks. `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`, `currentScrollTop`, `restoreView`, `adjustForContentShift`, `dispose`, 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.
153
- - **`createModeState(initial?)` → `ModeState`**, **`POWER_ON_MODES`** — DEC private mode state, one per terminal (the connection writes it from the server's `ModesMessage` and restores the target session's snapshot on `setSession`). `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isMousePixels`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`, `getKeyboardFlags`, `applySnapshot`, `snapshot`, `dispose`. `POWER_ON_MODES` is frozen; copy it (`{ ...POWER_ON_MODES, reverseVideo: true }`) before changing a field.
154
- - **`decodeWireBinary(buf)`**: Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
155
- - **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).
156
- - **`createConnection(options)` → `Connection`**, **`MAX_REPLAY_LINES`**, **`MAX_OUTBOX_BYTES`**: Client → server WebSocket lifecycle: owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer (outbox + server-restart detection). The options are the `renderer` it feeds, the `modes` it writes, the `mouse` it resyncs, your `callbacks`, and the optional `wsPath` (default `"/ws"`) and `sessionIdKey`. `connect`, `sendBinary(bytes)`, `sendEphemeral(text)`, `setClientFocus(focused)`, `sendResize`, `reconnectNow`, `setSession`, `forgetSession`, `adoptPersistedEpoch`, `serverEpochOf`, `currentSessionId`, `historyBudget`, `requestHistory`, `disconnect`, `dispose`. `sendEphemeral` carries BEST-EFFORT input (mouse reports): it sends only when a socket is live right now, returns whether it did, and never touches the outbox or the byte counters, so a report is forgotten rather than replayed against a screen the resume has since repainted. Against a server that declares neither capability it falls back to `sendBinary`, because an unrecognized control is dropped post-latch and total silence is a worse failure than a stale report — on that path the report rides the reliable outbox and can be retransmitted after a reconnect. `setClientFocus` reports the terminal widget's focus: it sends the `focus` control when the server declared it derives the DEC 1004 answer (from attachment state, every attached client's report, and its own `terminal.WithKeepUnfocused`), and otherwise drops the report. The client writes no focus bytes in either case, because one client cannot answer for a session two devices may be attached to and a client-written `CSI I` would override a keep-unfocused policy it cannot see; the next resumeAck re-asserts the current value once the capabilities are known. `ConnectionCallbacks` exposes `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`/`onProcessExit`, `onWireVersionMismatch`, the definitive `onWireIncompatible`, `onResumeBounds`, a `computeSize()` provider, and the optional `getReplayMax` and `initialSize` providers; a member may call `dispose()` on the connection that invoked it. An explicit below-floor server revision or close code 4002 stops automatic reconnects until `disconnect()` or a page reload. Version-silent and future-revision servers remain tolerated. The connection decodes frames internally, applies modes frames to its `modes`, and answers the resume and history questions from its `renderer`, so a consumer only needs to dispatch screen/scroll to the renderer (which `createTerminalEngine` does for you). Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
157
- - **`controlFrame(msg)` / `wsURL(proto, host, path?)`** — Low-level helpers for the client → server protocol (0x00-prefixed JSON control frames, WebSocket URL building). Used internally by `connection`; exported for advanced consumers.
147
+ - `createTerminalEngine(options)` builds the five parts below over `output` and `termWrap` and wires them together. It routes `screen`, `scroll` and `modes` frames to the renderer before your `onMessage` sees them. It returns a `TerminalEngine`, whose members are `renderer`, `scroll`, `connection`, `mouse`, `modes` and `dispose`. The options are `callbacks`, which is a `ConnectionCallbacks`, plus `wsPath`, `sessionIdKey`, `maxLines`, `onCursorMove`, `onUserScrollChange` and `initialModes`. It neither connects nor measures fonts. Call `renderer.updateFontMetrics()` when your font is ready and `connection.connect()` to start.
148
+ - `createRenderer(options)` returns a `Renderer`, the DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames over `output` inside `termWrap`, with an injected `scroll` controller and `modes` state. `maxLines` is the retained-line cap of its store. Memory-constrained consumers pass a smaller budget. History above the cap is evicted from the top in batches, and the live screen is never evicted, so a cap at or below the terminal height keeps the full screen with no scrollback. Its methods are `handleScreen`, `handleScroll`, `handleScrollPosition`, `updateFontMetrics`, `computeSize`, `cellSize`, `gridSize`, `getCursorPx`, `setPredictedCursor`, `bind`, `boundStore`, `captureViewMemory`, `resetScreen`, `resetScrollback`, `getHighestIndex`, `getReplayBoundary`, `noteResumeBounds`, `updateReverseVideo` and `dispose`.
149
+ - `keyboard` translates `KeyboardEvent` to terminal byte sequences. `mapKeyboardEvent(ev, modes)`, `bracketTextForPaste(text, modes)`, `kittyDisambiguateActive(modes)`, `prepareTextForTerminal`, `ctrlByteFor`, plus the shared logical-key encodings `plainCursorKeySeq` and `plainEscapeSeq` that the toolbar module reuses. Every mode-dependent function takes the mode state as an argument, and an engine's `modes` satisfies it.
150
+ - `toolbar` is the on-screen mobile toolbar widget. `bindMobileToolbar({toolbar, send, modes, 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, read from the required `modes`. It returns a `MobileToolbarController` exposing `applyStickyCtrl`, `setCtrlArmed`, `isCtrlArmed` and `dispose`.
151
+ - `createMouseController(options)` returns a `MouseController`, and `encodeSGR` is the SGR 1006 mouse encoder it uses, attached to `termElement()`. The options are a `MouseInputHandler` plus `modes`. The controller exposes `resyncGesture`, `disarmGesture` and `dispose`, and auto-gates on `mouseMode > 0`. `MouseInputHandler` carries two optional members, both defaulting to today's behaviour. `gridElement` names the element whose box IS the grid. The scroll container's own box includes its padding and scrollbar gutter, so it is not that box. `gridSize` supplies the dimensions of the grid ON SCREEN, from `renderer.gridSize` rather than the size this client measured. Every report is clamped into it, and rows are measured up from the bottom edge, because the screen window is the tail of the content. Its required `sendReport(data)` returns whether the report reached a live socket, so wire it to `connection.sendEphemeral`. Mouse input is best-effort, with four consequences. A release is emitted only for a press that was DELIVERED, because a release whose press never arrived is its own desync and the application never learned the button went down. A drag reports a held button only while the application believes that button is down, so motion for an undelivered gesture is dropped under mode 1002 and reports as no-button under 1003. `resyncGesture()` emits one release for any button the last reading says is now up, so a drag is not left pressed across a reconnect. The connection calls it once the resumeAck has declared the server's capabilities, rather than from `onOpen`. At `onOpen` the ephemeral channel is not yet known, so the release would take the reliable fallback. Motion is coalesced to one report per animation frame, latest wins, while press, release and wheel are never coalesced, because a wheel report is a notch count against a screen and coalescing loses scroll distance. `resyncGesture` shares one gate with the handlers, and each of them enforces it. A gesture stranded by the application turning tracking off is FORGOTTEN by whichever one next observes tracking off, because a release nothing asked for reaches the PTY as keystrokes. tmux answers the same way at attach, clearing `mouse_drag_flag` as it disables the modes. `disarmGesture()` forgets a held gesture without reporting anything. Call it when pointing one terminal's socket at another session, since the record belongs to the session that saw the press. Focus reporting is NOT here. It is transport state, and it lives in `connection.setClientFocus`.
152
+ - `createScrollController(options)` returns a `ScrollController`, the auto-follow tracker for the scroll container `scrollEl`, with optional `onUserScrollChange` and `onScrollPosition` callbacks. Its methods are `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`, `currentScrollTop`, `restoreView`, `adjustForContentShift` and `dispose`, plus two renderer seams. `noteContentShrink(scrollTopBefore)` says a row removal rather than a gesture caused the scroll event about to arrive. `reconcileScrollRange()` moves the offset back inside the container's range when the container did not reconcile the shrink itself. The second seam 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, and WebKit does not. Without the correction, an iOS viewport is left parked past the end of the content after an application clears the screen. It then shows background with the content above it until the reader scrolls.
153
+ - `createModeState(initial?)` returns a `ModeState`, and `POWER_ON_MODES` is its frozen default. The mode state is the DEC private mode state, one per terminal. The connection writes it from the server's `ModesMessage` and restores the target session's snapshot on `setSession`. Its readers are `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isMousePixels`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`, `getKeyboardFlags`, `applySnapshot`, `snapshot` and `dispose`. `POWER_ON_MODES` is frozen, so copy it before changing a field, for example `{ ...POWER_ON_MODES, reverseVideo: true }`.
154
+ - `decodeWireBinary(buf)` is the top-level decoder for the binary WebSocket frames. It returns a `ServerMessage` or `null` for an invalid or truncated frame.
155
+ - The wire compatibility constants `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).
156
+ - `createConnection(options)` returns a `Connection`, and `MAX_REPLAY_LINES` and `MAX_OUTBOX_BYTES` are its exported bounds. It is the client-to-server WebSocket lifecycle. It owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer, which is an outbox plus server-restart detection. The options are the `renderer` it feeds, the `modes` it writes, the `mouse` it resyncs, your `callbacks`, and the optional `wsPath` (default `"/ws"`) and `sessionIdKey`. Its methods are `connect`, `sendBinary(bytes)`, `sendEphemeral(text)`, `setClientFocus(focused)`, `sendResize`, `reconnectNow`, `setSession`, `forgetSession`, `adoptPersistedEpoch`, `serverEpochOf`, `currentSessionId`, `historyBudget`, `requestHistory`, `disconnect` and `dispose`. `sendEphemeral` carries best-effort input such as mouse reports. It sends only when a socket is live right now, returns whether it did, and never touches the outbox or the byte counters. A report is therefore forgotten rather than replayed against a screen the resume has since repainted. Against a server that declares neither capability it falls back to `sendBinary`, because an unrecognized control is dropped post-latch and total silence is a worse failure than a stale report. On that path the report rides the reliable outbox and can be retransmitted after a reconnect. `setClientFocus` reports the terminal widget's focus. It sends the `focus` control when the server declared it derives the DEC 1004 answer, and otherwise drops the report. The server derives that answer from attachment state, every attached client's report, and its own `terminal.WithKeepUnfocused`. The client writes no focus bytes in either case. One client cannot answer for a session two devices may be attached to, and a client-written `CSI I` would override a keep-unfocused policy it cannot see. The next resumeAck re-asserts the current value once the capabilities are known. `ConnectionCallbacks` exposes `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`/`onProcessExit`, `onWireVersionMismatch`, the definitive `onWireIncompatible`, `onResumeBounds`, a `computeSize()` provider, and the optional `getReplayMax` and `initialSize` providers. A member may call `dispose()` on the connection that invoked it. An explicit below-floor server revision or close code 4002 stops automatic reconnects until `disconnect()` or a page reload. Version-silent and future-revision servers remain tolerated. The connection decodes frames internally, applies modes frames to its `modes`, and answers the resume and history questions from its `renderer`. A consumer only needs to dispatch screen and scroll frames to the renderer, which `createTerminalEngine` does for you. Prefer this over wiring `WebSocket` plus `decodeWireBinary` by hand unless you need full control.
157
+ - `controlFrame(msg)` and `wsURL(proto, host, path?)` are low-level helpers for the client-to-server protocol. `controlFrame` builds the 0x00-prefixed JSON control frames and `wsURL` builds the WebSocket URL. The `connection` uses them internally, and they are exported for advanced consumers.
158
158
 
159
159
  Wire types (`WireRun`, `ScreenMessage`, `ScrollMessage`, `ModesMessage`, `TitleMessage`, `ResumeAckMessage`, `ServerMessage`, `ControlMessage`) are re-exported from the package root and match the Go server's wire format byte-for-byte.
160
160
 
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "6.0.2",
3
+ "version": "6.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": "6.0.2",
3
+ "version": "6.0.4",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
@@ -21,19 +21,19 @@
21
21
  "@stryker-mutator/vitest-runner": "10.0.0",
22
22
  "@types/pngjs": "6.0.5",
23
23
  "@typescript/native": "npm:typescript@7.0.2",
24
- "@vitest/browser": "5.0.2",
25
- "@vitest/browser-playwright": "5.0.2",
26
- "@vitest/coverage-v8": "5.0.2",
24
+ "@vitest/browser": "5.0.3",
25
+ "@vitest/browser-playwright": "5.0.3",
26
+ "@vitest/coverage-v8": "5.0.3",
27
27
  "esbuild": "0.28.2",
28
28
  "eslint": "10.11.0",
29
29
  "fast-check": "4.10.2",
30
- "knip": "6.38.0",
30
+ "knip": "6.39.0",
31
31
  "playwright": "1.63.0",
32
32
  "pngjs": "7.0.0",
33
33
  "prettier": "3.9.9",
34
34
  "typescript": "npm:@typescript/typescript6@6.0.2",
35
35
  "typescript-eslint": "8.71.0",
36
- "vitest": "5.0.2"
36
+ "vitest": "5.0.3"
37
37
  },
38
38
  "overrides": {
39
39
  "qs": "^6.15.2"
package/src/connection.ts CHANGED
@@ -122,7 +122,7 @@ const HISTORY_MIN_PAGE = 125;
122
122
  * value the server will actually honor. The equality is load-bearing: the
123
123
  * replay-jump prediction computes `committed - sentReplayMax`, so a server
124
124
  * honoring something smaller would place the real replay start above the
125
- * prediction and leave a genuine jump undetected (docs/paged-scrollback.md §4.5).
125
+ * prediction and leave a genuine jump undetected.
126
126
  */
127
127
  export const MAX_REPLAY_LINES = 2000;
128
128
 
@@ -473,23 +473,17 @@ export function createConnection(opts: ConnectionOptions): Connection {
473
473
  let activeId: string | null = null;
474
474
  // managed = a consumer selected sessions through setSession, so the URL
475
475
  // carries ?session=<id>. Unmanaged keeps the bare wsPath and a sessionStorage
476
- // id, which survives iOS tab-suspend and BFCache so a reload resumes rather
477
- // than orphaning its outbox.
476
+ // id, which survives iOS tab-suspend, BFCache and reload.
478
477
  let managed = false;
479
478
 
480
- // In managed mode the routing id is SHARED by every device attached to the
481
- // session, but the server-side input ledger must be PER SENDER: with a shared
482
- // key, device B's input advances the ledger and the ack reaches device A,
483
- // whose applyAck trims bytes the server never received from A. So the resume
484
- // frame carries `<serverSessionId>#<clientInstanceId>` while the URL keeps the
485
- // routing id. Page-lifetime and NOT persisted: a reload is a fresh sender with
486
- // an empty outbox. Lazy so a cryptoless environment throws on first CONNECT.
479
+ // Several live pages can share one routing id (every device on a managed
480
+ // session; a duplicated tab copies sessionStorage), but the server's input
481
+ // ledger is per sender, so the resume key carries this page-lifetime suffix.
482
+ // Never persisted: a reloaded page has an empty outbox and nothing to resume.
483
+ // Lazy so a cryptoless environment throws on first connect, not at init.
487
484
  let clientInstanceId: string | null = null;
488
485
 
489
486
  function resumeKey(st: ResumeState): string {
490
- if (!managed) {
491
- return st.id;
492
- }
493
487
  clientInstanceId ??= generateSessionId();
494
488
  return `${st.id}#${clientInstanceId}`;
495
489
  }
@@ -519,7 +513,7 @@ export function createConnection(opts: ConnectionOptions): Connection {
519
513
  let probeSentAt = 0; // Date.now() the outstanding probe ping was sent; 0 = none
520
514
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
521
515
 
522
- // --- demand-paged scrollback: per-socket fetch state (docs/paged-scrollback.md) ---
516
+ // --- demand-paged scrollback: per-socket fetch state ---
523
517
 
524
518
  let history: HistoryState = newHistoryState();
525
519
 
@@ -870,7 +864,7 @@ export function createConnection(opts: ConnectionOptions): Connection {
870
864
  }
871
865
 
872
866
  // An anchor computed from the full page size while the length is shrunken
873
- // serves a range that ends far from the reader (docs/paged-scrollback.md §4.2).
867
+ // serves a range that ends far from the reader.
874
868
  function historyBudget(): number {
875
869
  return isDisposed() ? 0 : history.effMax;
876
870
  }
@@ -1102,7 +1096,7 @@ export function createConnection(opts: ConnectionOptions): Connection {
1102
1096
  // The server's reply is a function of the exact haveThrough and
1103
1097
  // replayMax sent, so the replay-jump prediction is computed from the
1104
1098
  // values remembered on the socket, never from store state a frame
1105
- // between send and ack could have moved (docs/paged-scrollback.md §4.5).
1099
+ // between send and ack could have moved.
1106
1100
  resetForNewSocket();
1107
1101
  const sentHaveThrough = renderer.getReplayBoundary();
1108
1102
  // ALWAYS a number: the server clamps to the same ceiling
@@ -1286,7 +1280,7 @@ export function createConnection(opts: ConnectionOptions): Connection {
1286
1280
  // all run BEFORE the ledger-lost returns below: a ledger loss is not a
1287
1281
  // capability event, and the long-absence attach that loses its ledger
1288
1282
  // is exactly the one that needs its focus asserted and carries a replay
1289
- // jump (docs/paged-scrollback.md §4.5).
1283
+ // jump.
1290
1284
  caps = {
1291
1285
  ephemeralInput: msg.ephemeralInput === true,
1292
1286
  serverFocus: msg.serverFocus === true,
@@ -1364,8 +1358,8 @@ export function createConnection(opts: ConnectionOptions): Connection {
1364
1358
  if (typeof msg.inputAck === "number") {
1365
1359
  applyAck(st, msg.inputAck);
1366
1360
  }
1367
- // PRE-ACK CONTENT SUPPRESSION (docs/paged-scrollback.md §4.5): on a busy
1368
- // session a live frame can arrive before this socket's resumeAck, and its
1361
+ // PRE-ACK CONTENT SUPPRESSION: on a busy session a live frame can arrive
1362
+ // before this socket's resumeAck, and its
1369
1363
  // rows would mutate the store under a stale residency cap. Field-aware:
1370
1364
  // the inputAck was applied above, ED3 is a consumed one-shot the resume
1371
1365
  // batch hard-codes false and is forwarded as a rows-less clear, `bell`
package/src/intervals.ts CHANGED
@@ -1,27 +1,9 @@
1
1
  /**
2
- * Gap geometry over the store's retained absolute indices.
3
- *
4
- * The store's held lines form an INTERVAL SET (docs/paged-scrollback.md §5.2):
5
- * a live tail plus zero or more browse-cache runs below it, with gaps between
6
- * them. This module answers only where the GAPS are, and it derives them from
7
- * the retained KEY SET.
8
- *
9
- * It must read the key set, not the cache's membership: a hole can exist inside
10
- * the live tail — an `unsupported` eviction-gap resume legally leaves two
11
- * disjoint tail runs — and geometry drawn from cache membership alone would
12
- * join adjacent rows across such a hole with no marker, silently splicing
13
- * unrelated regions of output.
14
- *
15
- * BROWSE MEMBERSHIP is deliberately not modelled here. The store keeps it as a
16
- * plain `Set` of keys, because every question it is asked (is the reader on
17
- * cache, how many rows, which to evict) is a per-key question, and answering
18
- * those from ranges meant walking numeric spans the store need not even hold —
19
- * the replay-jump band spans every index a reconnecting client is missing.
20
- * Classification also cannot be DERIVED from geometry: a fetched page can sit
21
- * flush against the tail with no numeric gap at all.
22
- *
23
- * Every interval is half-open: `[lo, hi)` holds `hi - lo` indices and `hi` is
24
- * one past the last. Sets are SORTED and DISJOINT with no touching pair.
2
+ * Gap geometry over the store's retained absolute indices (a live tail plus
3
+ * browse-cache runs), derived from the retained KEY SET rather than cache
4
+ * membership: an `unsupported` eviction-gap resume can leave a hole inside the
5
+ * tail that membership would silently splice across. Intervals are half-open
6
+ * `[lo, hi)`; sets are sorted and disjoint with no touching pair.
25
7
  */
26
8
 
27
9
  /** A half-open range of absolute line indices: `[lo, hi)`. */