@cplieger/web-terminal-engine 6.0.3 → 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.3",
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.3",
3
+ "version": "6.0.4",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
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)`. */
package/src/store.ts CHANGED
@@ -199,7 +199,7 @@ function evictionBatch(maxLines: number): number {
199
199
  }
200
200
 
201
201
  /**
202
- * Demand-paged-scrollback residency constants (docs/paged-scrollback.md §3).
202
+ * Demand-paged-scrollback residency constants.
203
203
  *
204
204
  * The store holds a small RESIDENT TAIL plus a disposable BROWSE CACHE of
205
205
  * pages fetched on demand. The two budgets are enforced by separate mechanisms
@@ -357,7 +357,7 @@ export class LineStore {
357
357
  private altDirty = false;
358
358
  private resetPending = false;
359
359
 
360
- // --- demand-paged scrollback (docs/paged-scrollback.md §5.1, §5.3) ---
360
+ // --- demand-paged scrollback ---
361
361
 
362
362
  /**
363
363
  * The RESIDENT TAIL cap, and the one deliberately mutable piece of residency
@@ -425,15 +425,10 @@ export class LineStore {
425
425
  private browseActivityMs = 0;
426
426
 
427
427
  /**
428
- * Whether the last resumeAck declared paging. Kept because the RENDERER needs
429
- * it and cannot derive it: §5.4's top-of-store marker says something different
430
- * about the history above what is held depending on whether a fetch can bring
431
- * it back.
432
- *
433
- * A BOOLEAN, false until an ack says otherwise, not a tri-state with a
434
- * pre-ack "unknown". The pre-ack instant and a declared `unsupported` want the
435
- * same answer — assert only what the client's own bookkeeping proves — so a
436
- * third state would be a distinction nothing reads.
428
+ * Whether the last resumeAck declared paging; the renderer needs it for the
429
+ * top-of-store marker and cannot derive it. A boolean, false until an ack says
430
+ * otherwise: before the first ack and under `unsupported` the answer is the
431
+ * same, so a third state would be read by nothing.
437
432
  */
438
433
  private paging = false;
439
434
 
@@ -503,7 +498,7 @@ export class LineStore {
503
498
  * consumer-tunable through the renderer/UI plumb.
504
499
  */
505
500
  constructor(maxLines?: number) {
506
- // Constructor-knob semantics (docs/paged-scrollback.md §5.3): an OMITTED
501
+ // Constructor-knob semantics: an OMITTED
507
502
  // cap means "engine's choice", so it holds the legacy value until paging is
508
503
  // declared and then flips to the small target. A SUPPLIED cap is an
509
504
  // explicit memory decision by the consumer and holds in EVERY capability
@@ -531,31 +526,12 @@ export class LineStore {
531
526
  }
532
527
 
533
528
  /**
534
- * The lowest absolute index at or above which this store's content has NOT
535
- * been confirmed as committed history by the server, or +Infinity when
536
- * everything held is confirmed.
537
- *
538
- * The distinction this tracks is real and the map cannot express it. A row
539
- * arriving in a SCREEN frame lands at `base + y`, which is a screen POSITION
540
- * the application repaints in place, so the content held at that absolute
541
- * index is what the app most recently drew there — provisional until the row
542
- * scrolls off and the server commits it. A row arriving in a SCROLL or history
543
- * frame is the opposite: the server has committed it, at that index, for good.
544
- *
545
- * One number rather than a per-row flag or a second key set. The rows above
546
- * this floor are always a contiguous suffix of the store, because a screen
547
- * frame's window is contiguous and lowers the floor to its base, while a
548
- * durable frame that reaches the floor from below raises it past the range it
549
- * delivered. `paged-scrollback.md` §5.3 records why a parallel structure over
550
- * the same map is the shape to avoid: the one that was tried, a maintained
551
- * companion count, was wrong at four mutation sites and reached -96.
552
- *
553
- * Movement is deliberately asymmetric. Lowering is unconditional, because a
554
- * screen frame is proof its window is provisional. Raising happens only when a
555
- * durable frame reaches the floor from at or below it, so a chunk landing
556
- * above a still-unconfirmed gap leaves the floor where it is. That direction
557
- * is the safe one to be wrong in: too low costs a few replayed rows, too high
558
- * is the defect this exists to prevent.
529
+ * Lowest index the server has not confirmed as committed (+Infinity when all
530
+ * is). A SCREEN-frame row is a repaintable position, provisional until it
531
+ * scrolls off; a SCROLL or history row is committed. Those rows form a
532
+ * contiguous suffix, so one number suffices. It lowers on any screen frame and
533
+ * rises only when a durable frame reaches it from below: too low replays a few
534
+ * rows, too high is the defect this prevents.
559
535
  */
560
536
  private unconfirmedFrom = Number.POSITIVE_INFINITY;
561
537
 
@@ -661,27 +637,12 @@ export class LineStore {
661
637
  * changed window row at its absolute index (base + y).
662
638
  */
663
639
  applyScreen(msg: ScreenMessage): void {
664
- // ED3 FIRST, BEFORE the screen-mode dispatch. The signal is a property of the
665
- // SESSION, not of the buffer the frame happens to describe. The server raises
666
- // a pending flag in the PTY path and attaches it to whatever frame it builds
667
- // next, with NO alt gate — and it force-appends a row so the flag can never be
668
- // dropped, which on an alt tick forces an ALT payload carrying it.
669
- //
670
- // Handling it inside the main-screen branch made every alt-active ED3 a total
671
- // no-op: not one of §5.5's three effects ran. `clear; vim foo` typed as one
672
- // line is enough (clear's terminfo emits CSI 3 J, vim's smcup follows in the
673
- // same PTY burst, both inside one flush interval), as is `reset` inside any
674
- // full-screen app. The erased transcript stayed resident and — because the
675
- // server's ring Clear() preserves `committed`, so post-ED3 lines keep
676
- // numbering from where the erased ones stopped — it spliced CONTIGUOUSLY into
677
- // later output with no index gap, no gap marker and no trim marker. The
678
- // in-flight window also stayed open, so a reply already on the wire put the
679
- // erased rows back through the one path exempt from guard 2.
680
- //
681
- // `msg.base` is the correct bound on either buffer: in alt it is
682
- // `committedBefore` (alt accrues no history, so the base stays frozen), which
683
- // is one past the newest committed line, so "below base" is the whole
684
- // scrollback either way.
640
+ // ED3 FIRST, before the screen-mode dispatch: the server attaches the flag
641
+ // to whatever frame it builds next, alt included, so handling it only on
642
+ // the main path made an alt-active ED3 (`clear; vim foo`, `reset` in a
643
+ // full-screen app) a no-op whose erased transcript spliced contiguously into
644
+ // later output. `msg.base` bounds either buffer: in alt it is frozen at
645
+ // `committedBefore`, one past the newest committed line.
685
646
  if (msg.scrollbackCleared) {
686
647
  this.applyScrollbackCleared(msg.base);
687
648
  }
@@ -867,9 +828,9 @@ export class LineStore {
867
828
  }
868
829
  }
869
830
 
870
- // --- demand-paged scrollback: public surface (docs/paged-scrollback.md) ---
831
+ // --- demand-paged scrollback: public surface ---
871
832
 
872
- /** Whether the last resumeAck declared demand paging (§5.4's `supported`). */
833
+ /** Whether the last resumeAck declared demand paging. */
873
834
  pagingDeclared(): boolean {
874
835
  return this.paging;
875
836
  }
@@ -879,7 +840,7 @@ export class LineStore {
879
840
  return this.serverOldest;
880
841
  }
881
842
 
882
- /** The lowest index still worth requesting (§4.5). */
843
+ /** The lowest index still worth requesting. */
883
844
  pagingFloorIndex(): number {
884
845
  return this.pagingFloor;
885
846
  }
@@ -957,7 +918,7 @@ export class LineStore {
957
918
  }
958
919
  // `gapHigh > pagingFloor`, deliberately NOT gapLow: after a ring exhaustion
959
920
  // followed by a later tail trim, the frontier's low edge EQUALS the floor,
960
- // and that reopened frontier must stay fetchable (§5.4).
921
+ // and that reopened frontier must stay fetchable.
961
922
  const live = candidates.filter((g) => g.hi > this.pagingFloor && g.hi > g.lo);
962
923
  const near = live.filter((g) => abs >= g.lo - threshold && abs <= g.hi + threshold);
963
924
  near.sort((a, b) => Math.abs(abs - a.hi) - Math.abs(abs - b.hi));
@@ -990,7 +951,7 @@ export class LineStore {
990
951
  // A correlated frame wider than the current slot — a timed-out larger reply
991
952
  // racing a shrunken retry — has its out-of-window lines routed through the
992
953
  // ordinary guards instead, where they are typically refused below the
993
- // watermark (§5.1). Without the clip, a stale oversized reply would apply
954
+ // watermark. Without the clip, a stale oversized reply would apply
994
955
  // content the client did not ask for on this attempt.
995
956
  const s = this.solicitedPending;
996
957
  if (s === null) {
@@ -1030,7 +991,7 @@ export class LineStore {
1030
991
  }
1031
992
  if (applied.length === 0) {
1032
993
  // An empty or fully-refused reply changes no classification. The caller
1033
- // still owns the floor raise for an empty reply (§4.3).
994
+ // still owns the floor raise for an empty reply.
1034
995
  this.enforceCap();
1035
996
  return;
1036
997
  }
@@ -1101,19 +1062,12 @@ export class LineStore {
1101
1062
  * safe too.
1102
1063
  */
1103
1064
  private enforceBrowseBudget(target: number, viewportAbs: number): void {
1104
- // §5.3's viewport exemption, and it is REACHABLE — an earlier comment here
1105
- // claimed arithmetic made it dead, which was measurably wrong.
1106
- //
1107
- // The shape that reaches it: the resume-ack pass uses the small
1108
- // PREFETCH_THRESHOLD target, and the band is 2 * PREFETCH_THRESHOLD + 1 wide,
1109
- // so a cache SPANNING the reader but smaller than the band is entirely
1110
- // exempt while still exceeding the target. Two retained runs either side of
1111
- // the reader's position do it: 400 rows at [0,400), 400 at [600,1000), the
1112
- // reader in the hole at 500, and a jump ack classifies all 800 against a
1113
- // target of 500. Nothing is evictable, the pass stops, and the store keeps
1114
- // 800 — the bounded overshoot the design prefers to blanking the rows under
1115
- // the reader. Under BROWSE_CACHE_CAP the same arithmetic never bites, which
1116
- // is what the dead-code claim was really about.
1065
+ // The viewport exemption is reachable: the resume-ack pass targets
1066
+ // PREFETCH_THRESHOLD while the band is 2 * PREFETCH_THRESHOLD + 1 wide, so
1067
+ // runs either side of a reader in a hole (400 at [0,400), 400 at
1068
+ // [600,1000), reader at 500) are all exempt yet over a 500 target. The pass
1069
+ // then stops and keeps them, the bounded overshoot preferred to blanking
1070
+ // rows under the reader.
1117
1071
  const exemptLo = viewportAbs - PREFETCH_THRESHOLD;
1118
1072
  const exemptHi = viewportAbs + PREFETCH_THRESHOLD + 1;
1119
1073
  const exempt = (abs: number) => abs >= exemptLo && abs < exemptHi;
@@ -1152,18 +1106,11 @@ export class LineStore {
1152
1106
  }
1153
1107
 
1154
1108
  /**
1155
- * The resume-ack transition: ONE store operation carrying every decision the
1156
- * ack implies, in a fixed order (docs/paged-scrollback.md §4.5).
1157
- *
1158
- * It is one call rather than four because the order is load-bearing and the
1159
- * inputs live in three different layers — the connection decodes the ack, the
1160
- * store owns residency, the renderer owns the viewport. Split across
1161
- * callbacks, an implementer can interleave them; here they cannot.
1162
- *
1163
- * `committed`/`serverOldest` are NULLABLE AS A PAIR: a server too old to
1164
- * carry the ack's length-gated bounds tail sends neither, and inventing zeros
1165
- * would lower the floor and forge a jump. Such an ack still runs the epoch
1166
- * reset and the capability read, and skips the two steps that have no inputs.
1109
+ * The resume-ack transition as ONE store operation, because the order of its
1110
+ * steps is load-bearing and its inputs span three layers (connection, store,
1111
+ * renderer). `committed`/`serverOldest` are NULLABLE AS A PAIR: an older
1112
+ * server sends neither, and inventing zeros would lower the floor and forge a
1113
+ * jump, so such an ack runs only the epoch reset and the capability read.
1167
1114
  */
1168
1115
  applyResumeAck(ack: {
1169
1116
  /** The boot epoch changed: everything retained is from another server. */
@@ -1246,24 +1193,12 @@ export class LineStore {
1246
1193
  // as a key set there is nothing to compare — the containment test below
1247
1194
  // reads the set itself and has one answer regardless of which step grew it.
1248
1195
  if (reclassified) {
1249
- // CONTAINMENT: a FOLLOWING viewport is outside every reclassified band by
1250
- // definition — it is looking at the live tail, not at cache — so the
1251
- // disposable band drains to the small target. A reader in HISTORY keeps the
1252
- // full cache instead, and the TTL cleans up later.
1253
- //
1254
- // Following is the WHOLE question, and the store asked a second one twice,
1255
- // in two different structures. It tested whether the row UNDER the reader
1256
- // was cache — a proxy that fails in exactly the shape
1257
- // paging exists for: a reader whose position is an index the store does not
1258
- // currently hold. Both doors to that shape are ordinary — a hole inside the
1259
- // cache, and an ARMED RESTORE anchor (§7.2/§7.3 of
1260
- // scroll-position-fidelity.md — the renderer deliberately passes the position
1261
- // the reader is REGAINING, whose row may since have been dropped). Either
1262
- // way the reader is unambiguously reading history and the proxy answered
1263
- // "no", draining their depth to the small target: measured 801 rows kept
1264
- // instead of 2420. The range version got that case right by accident (a hull
1265
- // spans holes) and the key version got it wrong by accident; asking only
1266
- // what the design means removes both.
1196
+ // CONTAINMENT: a FOLLOWING viewport is looking at the live tail, so the
1197
+ // disposable band drains to the small target; a reader in HISTORY keeps
1198
+ // the full cache and the TTL cleans up. Ask only `following`: whether the
1199
+ // row under the reader is cache fails for a hole in the cache and for an
1200
+ // armed restore anchor whose row was dropped, and drained such a reader
1201
+ // to 801 rows instead of 2420.
1267
1202
  this.enforceBrowseBudget(
1268
1203
  ack.following ? PREFETCH_THRESHOLD : BROWSE_CACHE_CAP,
1269
1204
  ack.viewportAbs,
@@ -1274,13 +1209,10 @@ export class LineStore {
1274
1209
 
1275
1210
  /**
1276
1211
  * The cap flip: set the effective tail cap to the supported target and
1277
- * reclassify the excess tail as browse cache. Returns whether anything moved.
1278
- *
1212
+ * reclassify the excess tail as browse cache; returns whether anything moved.
1279
1213
  * The band keeps the newest `max(supportedTarget, windowHeight)` tail lines
1280
- * and NEVER crosses `win.base` — a LIVE window row is never reclassified,
1281
- * because the browse budget may evict it and the window must not be
1282
- * evictable. (§5.2's replay-jump band deliberately DOES include the old
1283
- * window rows, but only because it retires the descriptor first.)
1214
+ * and never crosses `win.base`, because browse cache is evictable and a live
1215
+ * window row must not be.
1284
1216
  */
1285
1217
  private confirmPaging(): boolean {
1286
1218
  this.effectiveTailCap = this.supportedTarget;
@@ -1303,45 +1235,12 @@ export class LineStore {
1303
1235
  }
1304
1236
 
1305
1237
  /**
1306
- * Predict where the incoming replay will START, and if it lands above what
1307
- * this socket told the server it had, reclassify the stranded band as browse
1308
- * cache before any frame of the batch applies.
1309
- *
1310
- * Two causes produce the same shape and both are covered: the new bounded
1311
- * replay's clamp, and the plain eviction gap EVERY server produces when its
1312
- * ring has moved past the client's `haveThrough`. Scoping detection to the
1313
- * clamp alone would miss the shape that already ships.
1314
- *
1315
- * The band deliberately includes the OLD window rows and RETIRES the window
1316
- * descriptor, because the batch's own window frame re-establishes it at the
1317
- * new base — those rows stop being window rows the moment it lands.
1318
- *
1319
- * The band top is `sentHaveThrough` and deliberately does NOT follow the wire
1320
- * value down now that `replayBoundary()` supplies it. The two questions are
1321
- * different: the wire value asks "what will I not ask for", the band asks "what
1322
- * did I claim to hold before the server answered". Rows above the sent value
1323
- * arrived from the server as committed content, so they are real history and
1324
- * must not be reclassified as disposable cache; rows this store holds only
1325
- * provisionally sit at or above the boundary and are covered by the replay,
1326
- * which now starts at `boundary + 1` precisely because of it.
1327
- *
1328
- * One residual, deliberately not addressed here. When the replay start is
1329
- * CLAMPED above the boundary (`committed - replayMax`, reachable when a
1330
- * background tab printed more than roughly `tailCap` lines), the provisional
1331
- * rows are neither replayed nor banded, so they stay tail-classified and lose
1332
- * the browse budget's TTL sweep. Widening the band to cover them would change a
1333
- * pinned invariant that two existing cases assert, so it is recorded in
1334
- * docs/resume-watermark.md rather than changed in passing.
1335
- *
1336
- * The retirement leaves the same transition's later steps reading a window of
1337
- * height 0, so state precisely what that costs rather than forbidding it: the
1338
- * only window-derived bound they touch is `enforceCap`'s, which loses the live
1339
- * screen's eviction protection for that one pass. It is harmless HERE and not
1340
- * by luck — after the jump the tail holds only the keys above
1341
- * `sentHaveThrough`, so `tailCount` is far below the bound and the pass returns
1342
- * at its first gate; and were it to run, the walk is oldest-first while the
1343
- * screen rows are the newest. A future step that evaluates a window bound with
1344
- * consequences must re-establish the descriptor first.
1238
+ * Predict where the replay will START and, if above the sent `haveThrough`
1239
+ * (a replay clamp or a plain eviction gap), reclassify the stranded band as
1240
+ * browse cache before the batch applies. The band's top is `sentHaveThrough`
1241
+ * (rows above it are committed history). It retires the window descriptor, so
1242
+ * a later step in this transition that relies on a window bound must restore
1243
+ * it first.
1345
1244
  */
1346
1245
  private predictReplayJump(
1347
1246
  committed: number,
@@ -1488,25 +1387,18 @@ export class LineStore {
1488
1387
  this.resetPending = true;
1489
1388
  // CONTENT-derived paging state goes with the content: a new boot epoch shares
1490
1389
  // no absolute indices with the old one, so the cache, the in-flight window and
1491
- // the floor are all meaningless at once (docs/paged-scrollback.md §5.5).
1390
+ // the floor are all meaningless at once.
1492
1391
  this.browse.clear();
1493
1392
  this.solicitedPending = null;
1494
1393
  this.pagingFloor = 0;
1495
1394
  this.browseActivityMs = 0;
1496
1395
  // CAPABILITY is deliberately NOT reset here. It describes the SERVER on the
1497
1396
  // other end of the socket, not the content, and only a resumeAck can restate
1498
- // it — so a reset that cleared it left the store waiting for an event that
1499
- // may never come. That was safe for the documented caller (applyResumeAck's
1500
- // epoch step, whose very next step re-reads the ack), and wrong for the two
1501
- // public ones that predate paging: `render.resetScreen()` and
1502
- // `render.resetScrollback()`, which a consumer is explicitly invited to call
1503
- // on alt-screen entry, nowhere near a resume. Until the next reconnect that
1504
- // left `pagingDeclared()` false while the transport was still perfectly able
1505
- // to page, so §5.4's top marker asserted "earlier output trimmed" about
1506
- // history the trigger was about to fetch, and the tail cap reverted to the
1507
- // compatibility value — discarding the residency reduction that is the whole
1508
- // point of the feature on the device it exists for. `applyResumeAck` now
1509
- // states the capability explicitly instead of relying on a reset to clear it.
1397
+ // it. `render.resetScreen()` and `render.resetScrollback()` reach this on
1398
+ // alt-screen entry, nowhere near a resume; clearing it there would leave
1399
+ // `pagingDeclared()` false until the next reconnect, so the top marker would
1400
+ // claim "earlier output trimmed" about fetchable history and the tail cap
1401
+ // would revert to the compatibility value.
1510
1402
  }
1511
1403
 
1512
1404
  /**
@@ -1533,17 +1425,11 @@ export class LineStore {
1533
1425
  return null;
1534
1426
  }
1535
1427
  const cap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : this.effectiveTailCap;
1536
- // Persist the LIVE TAIL only, excluding browse cache BY CLASSIFICATION
1537
- // rather than by contiguity (docs/paged-scrollback.md §5.2). Walk down from
1538
- // `highest` and stop at the first index that is absent OR a browse member:
1539
- // a fetched page can sit FLUSH against the tail with no numeric gap, so a
1540
- // contiguity test alone would serialize it. Excluding it is deliberate —
1541
- // the cache is disposable by construction (recovery is one page fetch), and
1542
- // persisting it would spend storage writes on data the design defines as
1543
- // throwaway and hydrate interior holes into a fresh store.
1544
- //
1545
- // The result is that a hydrated store is always ONE contiguous tail, which
1546
- // is what keeps its derived `everEvictedThrough = oldest - 1` honest.
1428
+ // Persist the LIVE TAIL only: walk down from `highest` and stop at the first
1429
+ // absent index OR browse member, since a fetched page can sit flush against
1430
+ // the tail with no numeric gap. Cache is disposable (one page fetch), and a
1431
+ // hydrated store must be ONE contiguous tail to keep its derived
1432
+ // `everEvictedThrough = oldest - 1` honest.
1547
1433
  const tail: number[] = [];
1548
1434
  for (let abs = this.highest; abs >= 0 && tail.length < cap; abs--) {
1549
1435
  if (!this.lines.has(abs) || this.browse.has(abs)) {
@@ -1732,22 +1618,12 @@ export class LineStore {
1732
1618
  if (!Number.isInteger(abs) || abs < 0) {
1733
1619
  return;
1734
1620
  }
1735
- // Guard 2: not below what we have permanently evicted (stale re-send) —
1736
- // EXCEPT inside the current live window, and EXCEPT inside the window of a
1737
- // request we currently have in flight. The window is the terminal's
1738
- // writable region and is never stale: under the real protocol its base
1739
- // only advances, so a window index at or below everEvictedThrough is
1740
- // unreachable — but a malformed or hostile frame whose base RETREATS
1741
- // must degrade to a drawn screen, not to window rows silently dropped
1742
- // forever (the property test drives exactly that shape). Same doctrine
1743
- // as enforceCap's "the live window is never evictable".
1744
- //
1745
- // The SOLICITED exception is what makes demand paging possible at all:
1746
- // a page is by definition a re-fetch of history below the watermark, so
1747
- // the watermark alone cannot decide staleness any more. What keeps it
1748
- // safe is that the exception is scoped to one in-flight request window —
1749
- // an UNSOLICITED line below the watermark is still refused, exactly as
1750
- // before (docs/paged-scrollback.md §5.1).
1621
+ // Guard 2: refuse an index at or below the permanent-eviction watermark (a
1622
+ // stale re-send), EXCEPT inside the live window (a hostile frame whose base
1623
+ // retreats must still draw, as enforceCap never evicts the window) and
1624
+ // EXCEPT inside the in-flight request window: a page is by definition a
1625
+ // re-fetch below the watermark, and an UNSOLICITED line there is still
1626
+ // refused.
1751
1627
  if (abs <= this.everEvictedThrough && !this.inWindow(abs) && !this.isSolicited(abs)) {
1752
1628
  return;
1753
1629
  }
@@ -1794,28 +1670,12 @@ export class LineStore {
1794
1670
  return s !== null && abs >= s.lo && abs < s.hi;
1795
1671
  }
1796
1672
 
1797
- // applyScrollbackCleared handles ED3 (erase scrollback) in full: the app told
1798
- // the terminal to discard its saved lines, and an inline TUI (kiro-cli) does it
1799
- // on every resize redraw. Window rows (>= base) are kept and refreshed by the
1800
- // frame carrying the signal. everEvictedThrough is left untouched: the app
1801
- // discarded the lines deliberately (not a cap trim), so no "earlier output
1802
- // trimmed" marker fits.
1803
- //
1804
- // Dropping the lines is only ONE of the three effects §5.5 requires, and the
1805
- // other two are what stop the erased history coming back:
1806
- //
1807
- // - The in-flight request window is CANCELLED. It is the store's standing
1808
- // permission to admit lines below its stale-re-send watermark, so a reply
1809
- // already in flight when the app erased its scrollback would otherwise
1810
- // re-apply the very rows the app just discarded — through the one path that
1811
- // is exempt from the staleness guard.
1812
- // - The paging FLOOR snaps to the cleared bound: nothing at or below it is
1813
- // worth requesting any more. Without it the trigger re-fetches the erased
1814
- // range from a server that still holds it, and the user watches erased
1815
- // output scroll back into view.
1816
- //
1817
- // Both run even when this client holds no line below `base`: an in-flight
1818
- // request and a stale floor are not conditional on local residency.
1673
+ // applyScrollbackCleared handles ED3 (which an inline TUI such as kiro-cli
1674
+ // emits on every resize): drop lines below `base`, leave everEvictedThrough
1675
+ // alone (a deliberate erase is not a trim), CANCEL the in-flight request
1676
+ // (its reply would re-admit the erased rows through the guard-2 exemption),
1677
+ // and snap the paging floor to `base` (or the trigger re-fetches them). The
1678
+ // last two run even when no line below `base` is held.
1819
1679
  private applyScrollbackCleared(base: number): void {
1820
1680
  this.solicitedPending = null;
1821
1681
  this.raisePagingFloor(base);
@@ -1895,7 +1755,7 @@ export class LineStore {
1895
1755
  }
1896
1756
 
1897
1757
  private enforceCap(): void {
1898
- // AMENDMENT 1 (docs/paged-scrollback.md §5.3): the gate and the victim
1758
+ // The gate and the victim
1899
1759
  // target read `tailCount`, not `lines.size`. Browse cache lives under its
1900
1760
  // OWN budget, enforced at page-apply time, so live output must not be able
1901
1761
  // to evict a page the reader is browsing — at the default batch that would
package/src/types.ts CHANGED
@@ -260,11 +260,9 @@ export type ControlMessage =
260
260
  haveThrough: number;
261
261
  protocolVersion: number;
262
262
  /**
263
- * Bound the replay to the newest N missing lines. Optional: omitted means
264
- * "no bound", which is every pre-paging client's behavior and what an
265
- * old server does regardless. Honored only by a server that declares
266
- * paging, and clamped by BOTH sides to the same constant so the value
267
- * sent equals the value honored (docs/paged-scrollback.md §4.5).
263
+ * Ask for a replay smaller than the server's own bound. Clamped by BOTH
264
+ * sides to the same constant (MAX_REPLAY_LINES) so the value sent
265
+ * equals the value honored.
268
266
  */
269
267
  replayMax?: number;
270
268
  }
@@ -1,7 +1,7 @@
1
1
  // Binary wire decoder. Mirrors wire_binary.go on the server.
2
2
  //
3
- // All multi-byte integers are little-endian. See the Go terminal package's
4
- // wire_binary.go for the exact frame layout.
3
+ // All multi-byte integers are little-endian. The frame layouts are in
4
+ // docs/wire-protocol.md "Byte layout" at the repository root.
5
5
 
6
6
  import type {
7
7
  WireRun,