@cplieger/web-terminal-engine 6.2.0 → 6.4.0-dev.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "6.2.0",
3
+ "version": "6.4.0-dev.11",
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.2.0",
3
+ "version": "6.4.0-dev.11",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
@@ -16,24 +16,24 @@
16
16
  "devDependencies": {
17
17
  "@cplieger/web-terminal-engine-v3": "npm:@cplieger/web-terminal-engine@2.8.0",
18
18
  "@eslint/js": "10.0.1",
19
- "@playwright/test": "1.64.0",
19
+ "@playwright/test": "1.64.1",
20
20
  "@stryker-mutator/core": "10.0.0",
21
21
  "@stryker-mutator/typescript-checker": "10.0.0",
22
22
  "@stryker-mutator/vitest-runner": "10.0.0",
23
23
  "@types/pngjs": "6.0.5",
24
24
  "@typescript/native": "npm:typescript@7.0.2",
25
- "@vitest/browser-playwright": "5.0.3",
26
- "@vitest/coverage-v8": "5.0.3",
25
+ "@vitest/browser-playwright": "5.0.5",
26
+ "@vitest/coverage-v8": "5.0.5",
27
27
  "esbuild": "0.28.2",
28
- "eslint": "10.12.0",
28
+ "eslint": "10.13.0",
29
29
  "fast-check": "4.10.2",
30
- "knip": "6.40.0",
31
- "playwright": "1.64.0",
30
+ "knip": "6.41.0",
31
+ "playwright": "1.64.1",
32
32
  "pngjs": "7.0.0",
33
- "prettier": "3.9.9",
33
+ "prettier": "3.9.10",
34
34
  "typescript": "npm:@typescript/typescript6@6.0.2",
35
- "typescript-eslint": "8.71.1",
36
- "vitest": "5.0.3"
35
+ "typescript-eslint": "8.71.2",
36
+ "vitest": "5.0.5"
37
37
  },
38
38
  "overrides": {
39
39
  "qs": "^6.15.2"
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export { wsURL } from "./wsurl.js";
40
40
  export { WS_PATH, SESSIONS_PATH, SESSION_EVENTS_PATH } from "./routes.js";
41
41
  export { connectStatusStream } from "./status-stream.js";
42
42
  export type {
43
+ KnownStatus,
43
44
  SessionInfo,
44
45
  SessionStatus,
45
46
  StatusStream,
package/src/keyboard.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Keyboard event → terminal byte sequence mapping, mirroring xterm.js's
2
- // evaluateKeyboardEvent (src/common/input/Keyboard.ts) so vim, readline, Ink
3
- // and the host application get the keys they expect. Modifier digit per the
2
+ // evaluateKeyboardEvent (src/common/input/Keyboard.ts) so vim, readline and
3
+ // full-screen programs get the keys they expect. Modifier digit per the
4
4
  // xterm/VT520 convention: 1=Shift, 2=Alt, 4=Ctrl, 8=Meta, summed then +1 for
5
5
  // CSI 1;{n}{letter}. DECCKM and DECKPAM come from the injected mode state,
6
6
  // which the server's modes frame keeps current.
@@ -162,9 +162,9 @@ const KEYPAD_SS3: Record<string, string | undefined> = {
162
162
 
163
163
  // -- Kitty keyboard protocol (progressive enhancement) ----------------------
164
164
  // Under an active kitty flag, keys that do NOT produce text are encoded per the
165
- // kitty protocol so an app that enabled it (Codex via crossterm) gets
166
- // unambiguous key events. Only the disambiguate flag (0x1) is honored; the
167
- // server masks off 0x2/0x4/0x8/0x10, so the CSI ?u query reports only 0x1.
165
+ // kitty protocol so an app that enabled it gets unambiguous key events. Only
166
+ // the disambiguate flag (0x1) is honored; the server masks off
167
+ // 0x2/0x4/0x8/0x10, so the CSI ?u query reports only 0x1.
168
168
  // Text-producing keys still flow through the hidden textarea as text (this
169
169
  // encoder returns "ignore" for them), per the spec and the IME model.
170
170
 
@@ -339,10 +339,10 @@ function anyModifier(ev: KeyboardEvent): boolean {
339
339
  }
340
340
 
341
341
  /**
342
- * The kitty "unicode-key-code": the UNSHIFTED (lower-case / base-layout)
343
- * codepoint of the key. Letters and digits are read from ev.code (the physical
344
- * key), so a composed Alt/Option character on macOS still maps to its base key;
345
- * other single-character keys fall back to the case-folded ev.key.
342
+ * The kitty "unicode-key-code", read as the UNSHIFTED US-layout codepoint of
343
+ * the physical key: letters, digits and KITTY_CODE_BASE's punctuation come from
344
+ * ev.code, so a composed Option character or a shifted symbol maps to its base
345
+ * key; any other single-character key falls back to the case-folded ev.key.
346
346
  */
347
347
  function kittyKeyCodepoint(ev: KeyboardEvent): number {
348
348
  const code = ev.code;
@@ -431,6 +431,12 @@ function encodeKittyDisambiguate(ev: KeyboardEvent): KeyboardResult {
431
431
  if (ev.metaKey && !ev.ctrlKey && !ev.altKey) {
432
432
  return { kind: "ignore" };
433
433
  }
434
+ // A desktop AltGr character arrives with ctrlKey and altKey clear, so it stays
435
+ // text: on Windows, Chromium and Firefox swap Ctrl+Alt for AltGraph whenever
436
+ // the chord types a character (https://chromestatus.com/feature/5059438092222464,
437
+ // https://searchfox.org/mozilla-central/source/widget/windows/KeyboardLayout.cpp).
438
+ // AltGraph must not exempt a key here: Firefox sets it on every macOS Option
439
+ // chord (widget/cocoa/nsCocoaUtils.mm), and Ctrl+Option+a is 97;7u.
434
440
  if (ev.ctrlKey || ev.altKey || ev.metaKey) {
435
441
  const cp = kittyKeyCodepoint(ev);
436
442
  if (cp > 0) {
@@ -480,15 +486,6 @@ export function mapKeyboardEvent(ev: KeyboardEvent, modes: KeyboardModes): Keybo
480
486
  }
481
487
  }
482
488
 
483
- // Browser-reserved meta combos (Cmd+R, Cmd+T, Cmd+Q, Cmd+W on Mac;
484
- // Ctrl+R, Ctrl+T, Ctrl+W with no Shift on others). Let the browser
485
- // handle them. Heuristic: only take Ctrl+letter when Shift is NOT
486
- // held AND the letter is one of the ones xterm consumes (a-z),
487
- // skipping the few that browsers commonly reserve.
488
- // (We deliberately pass Ctrl+R / Ctrl+T / Ctrl+W through to the PTY
489
- // — the host application's Ctrl+T transcript view depends on it. Users on macOS
490
- // who want browser-reserved combos can use Cmd instead.)
491
-
492
489
  // Shift+PageUp / Shift+PageDown — local scrollback navigation
493
490
  // (matches xterm.js KeyboardResultType.PAGE_UP/PAGE_DOWN).
494
491
  if (ev.key === "PageUp" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
@@ -590,7 +587,9 @@ export function mapKeyboardEvent(ev: KeyboardEvent, modes: KeyboardModes): Keybo
590
587
  return { kind: "ignore" }; // let `input` event handle plain space
591
588
  }
592
589
 
593
- // Single printable character with modifiers.
590
+ // Single printable character with modifiers. Neither branch takes Meta, so
591
+ // the browser keeps Cmd+R/T/W/C/V; Ctrl+R/T/W go to the PTY, where readline
592
+ // binds them.
594
593
  if (ev.key.length === 1) {
595
594
  const ch = ev.key;
596
595
 
package/src/render.ts CHANGED
@@ -330,8 +330,8 @@ export function createRenderer(opts: RendererOptions): Renderer {
330
330
  let historyBudgetFn: (() => number) | null = opts.historyBudget ?? null;
331
331
 
332
332
  // Rows awaiting a DOM (re)build, drained at most MAX_ROWS_PER_FRAME per frame:
333
- // a session restore or `cat bigfile` dumps thousands of lines in one frame,
334
- // and building them in one rAF hangs a constrained device. The drain is
333
+ // a session's transcript replay or `cat bigfile` dumps thousands of lines in
334
+ // one frame, and building them in one rAF hangs a constrained device. The drain is
335
335
  // VIEWPORT-FIRST (live-window rows, then scrollback newest->oldest so the
336
336
  // backlog fills upward, offscreen above the pinned viewport), and the cursor
337
337
  // row is always built so the caret never lags.
@@ -1352,10 +1352,9 @@ export function createRenderer(opts: RendererOptions): Renderer {
1352
1352
  if (el === null) {
1353
1353
  return null;
1354
1354
  }
1355
+ // Non-negative: rowAtViewportTop answers only with a CONTENT row, and
1356
+ // isContentRow requires a parsed index that resolves back to that element.
1355
1357
  const abs = rowAbs(el);
1356
- if (abs < 0) {
1357
- return null;
1358
- }
1359
1358
  return {
1360
1359
  abs,
1361
1360
  screenTop: el.offsetTop - scroll.currentScrollTop(),
@@ -1468,10 +1467,10 @@ export function createRenderer(opts: RendererOptions): Renderer {
1468
1467
  abs >= gap.hi
1469
1468
  ? Math.max(gap.lo, gap.hi - budget, floor) // approaching from BELOW the gap's top
1470
1469
  : Math.max(gap.lo, floor); // approaching from above: start at the gap's low edge
1470
+ // At least 1, so no caller has to re-check it: every gap the store returns
1471
+ // satisfies `hi > lo` and `hi > pagingFloor`, and `budget` is at least 1,
1472
+ // which puts `fromAbs` strictly below `gap.hi` on both approaches.
1471
1473
  const maxLines = Math.min(budget, gap.hi - fromAbs);
1472
- if (maxLines < 1) {
1473
- return;
1474
- }
1475
1474
  requestHistoryFn(fromAbs, maxLines);
1476
1475
  }
1477
1476
 
@@ -7,6 +7,11 @@
7
7
 
8
8
  import { nextBackoffDelay } from "./reconnect.js";
9
9
 
10
+ /** KnownStatus is a status the server derives itself, one per Go terminal.Status*
11
+ * constant. */
12
+ export type KnownStatus =
13
+ "working" | "idle" | "input" | "done" | "exited" | "crashed" | "failed" | "warning";
14
+
10
15
  /** SessionInfo is one session's wire shape: the JSON object the session REST
11
16
  * API (GET/POST /api/sessions) returns per session, kept in lockstep with the Go
12
17
  * terminal.SessionInfo by hand.
@@ -16,26 +21,19 @@ import { nextBackoffDelay } from "./reconnect.js";
16
21
  * a CLIENT-DERIVED automatic title a client asked the server to remember. */
17
22
  export interface SessionInfo {
18
23
  readonly id: string;
19
- /** the session's current status. "working"/"idle"/"exited"/"crashed" are
20
- * derived by the server; "input"/"done" are latched from a classified OSC 9
21
- * notification; "failed" is OSC 9;4 progress state 2 and "warning" is state 4
22
- * (iTerm2 semantics), both of which PERSIST until the program changes the
23
- * progress state. "exited" and "crashed" split the session's end: "exited" is
24
- * an ordinary one (status 0, or any exit the server itself caused — a closed
25
- * session, the idle reaper, a server shutdown), "crashed" is a non-zero exit
26
- * status or a terminating signal the program was not asked for, so a routine
27
- * restart never renders as a failure. Neither is a progress state: they
28
- * outrank everything, and nothing clears them. A consumer must tolerate an
29
- * UNKNOWN status string from a newer server: the stream is parsed, not
30
- * validated, and an unrecognised value is forwarded rather than dropped
31
- * (forward compatibility across the wire floor). */
32
- readonly status:
33
- "working" | "idle" | "input" | "done" | "exited" | "crashed" | "failed" | "warning";
24
+ /** the session's current status. The server derives a KnownStatus from the
25
+ * program's OSC 7501 report, or from OSC 9;4 progress while it has sent none
26
+ * since a hard reset (RIS); "exited" and "crashed" mark the process's end
27
+ * and nothing clears them. A classified OSC 9 notification latches whatever
28
+ * string the server's classifier returns, and a newer server may add values,
29
+ * so a consumer must handle any other string: the stream forwards it
30
+ * unchanged. The engine's docs/sessions.md gives every mapping. */
31
+ readonly status: KnownStatus | (string & {});
34
32
  readonly title: string;
35
33
  /** the session's short public name, safe to put in a URL: 1 to 64 characters
36
34
  * of `[A-Za-z0-9_-]`, unique among live sessions, and NOT a capability
37
35
  * (nothing attaches with it; only `id` does). The server mints a random one
38
- * and a host may replace it (web-terminal-kiro sets the kiro-cli thread id).
36
+ * and a host may replace it (for example with its program's own session id).
39
37
  * Absent from a server before 6.2 and from a status event carrying
40
38
  * `removed`. Untrusted wire text: validate it before use. */
41
39
  readonly alias?: string;
@@ -72,11 +70,11 @@ export interface SessionInfo {
72
70
  * view: a list built from that hybrid still names the live set exactly, so the
73
71
  * server accepts it and it becomes the arrangement every device sees. */
74
72
  readonly order?: number;
75
- /** true once the session has emitted a genuine activity signal — OSC 9;4
76
- * progress (kiro-cli, Claude Code, …) or a classified OSC 9 notification.
77
- * Sticky for the session's life. Consumers reveal the per-tab activity dot
78
- * only when this is set; a program that emits no OSC 9 signal (a plain shell)
79
- * keeps its tab dot hidden. */
73
+ /** true once the session has emitted a genuine activity signal — an OSC 7501
74
+ * report, OSC 9;4 progress or a classified OSC 9 notification.
75
+ * Sticky for the session's life, a hard reset (RIS) included. Consumers
76
+ * reveal the per-tab activity dot only when this is set; a program that
77
+ * emits none of the three (a plain shell) keeps its tab dot hidden. */
80
78
  readonly reportsActivity?: boolean;
81
79
  /** the session's SECONDARY activity: a host-reported background activity that
82
80
  * OUTLIVES the turn, carried beside `status` and never merged into it. The
@@ -84,15 +82,25 @@ export interface SessionInfo {
84
82
  * (stopped and resumable, nobody is being asked) and `"input"` (a background
85
83
  * task is blocked on the user); absent or an empty string means no secondary
86
84
  * activity, and a consumer must tolerate an UNKNOWN value from a newer server
87
- * by rendering nothing for it.
88
- *
89
- * Typed `string`, deliberately NOT a union like `status`: that union's own doc
90
- * already admits it must tolerate values outside itself, which forces a cast at
91
- * every forward-compatible site. Absent from a server that reports none. */
85
+ * by rendering nothing for it. Absent from a server that reports none. */
92
86
  readonly activity?: string;
93
87
  /** how many sources produced `activity`: >= 1 whenever `activity` is non-empty,
94
88
  * 0 when it is empty or the host does not count them. */
95
89
  readonly activityCount?: number;
90
+ /** numbers the occurrence of the session's status. A sweep raises it by one
91
+ * when it emits a changed status, a status a classified notification latched
92
+ * since the last sweep, or an input, done or failed status after an OSC 7501
93
+ * report since then created a record, changed one's state or kind, or
94
+ * cleared one, and so changed which record the status came from, or its
95
+ * state or kind, at that report. A report keeping a record's state and kind
96
+ * raises it only by changing the status. It never falls for one id; 0,
97
+ * absent on the wire, means nothing numbered yet. */
98
+ readonly statusSeq?: number;
99
+ /** the highest `statusSeq` any reader acknowledged through
100
+ * `PUT /api/sessions/{id}/seen`. Never falls for one id and never exceeds
101
+ * `statusSeq`; a status that calls for attention is unread while
102
+ * `statusSeq > seenSeq`. */
103
+ readonly seenSeq?: number;
96
104
  }
97
105
 
98
106
  /** SessionStatus is one session's current status as carried on the status
@@ -101,9 +109,10 @@ export interface SessionInfo {
101
109
  export interface SessionStatus extends SessionInfo {
102
110
  /** true when the session is gone (closed or reaped); the consumer drops it. */
103
111
  readonly removed?: boolean;
104
- /** the OSC 9;4 percentage: -1 when absent or unknown (no sequence seen, or a
105
- * state that carries none — clear/indeterminate), else 0-100. Paired with
106
- * `status` to render a determinate bar. */
112
+ /** the session's percentage: the OSC 7501 report's `progress` when it
113
+ * carries one, else the OSC 9;4 percentage. -1 when absent or unknown (no
114
+ * sequence seen, or a state that carries none — clear/indeterminate), else
115
+ * 0-100. Paired with `status` to render a determinate bar. */
107
116
  readonly progressValue?: number;
108
117
  /** the OSC 9 notification message, delivered to EVERY subscriber whether or
109
118
  * not the server has a status classifier installed. A notification is an
package/src/store.ts CHANGED
@@ -433,25 +433,12 @@ export class LineStore {
433
433
  private paging = false;
434
434
 
435
435
  /**
436
- * The highest index the APPLICATION erased (ED3), or -1. A second watermark
437
- * beside `everEvictedThrough` because the two answer different questions and
438
- * only one of them is a trim.
439
- *
440
- * `everEvictedThrough` means "we dropped this to stay inside a memory budget",
441
- * and it drives the "earlier output trimmed" affordance. ED3 is not that: the
442
- * app discarded its own scrollback, so a marker would be permanent noise on
443
- * kiro-cli (which clears on every resize redraw), which is why ED3 deliberately
444
- * leaves it alone. That left NOTHING refusing an erased index, and a page reply
445
- * whose data timeout had already released single-flight comes back
446
- * UNCORRELATED — through the ordinary scroll path, classified TAIL, so outside
447
- * the cache budget and the TTL both. It never leaves.
448
- *
449
- * Refusing an erased index is unconditionally safe within an epoch: the server's
450
- * ring `Clear()` preserves `committed`, so an erased absolute index is never
451
- * reused, and no legitimate later frame can carry one. The companion reprint an
452
- * app emits in the same tick lands at NEW indices above everything the client
453
- * held, which is why this watermark tracks what was actually DROPPED rather than
454
- * the cleared bound — the bound would also refuse that reprint.
436
+ * The highest index the application erased (ED3), or -1. Not
437
+ * `everEvictedThrough`, which marks budget trims and drives the "earlier output
438
+ * trimmed" marker that ED3 must not show. Without it a late, uncorrelated page
439
+ * reply lands as TAIL, outside the cache budget and TTL, and never leaves. Safe
440
+ * within an epoch: the ring's `Clear()` keeps `committed`, so no erased index is
441
+ * reused. It tracks what was dropped; the cleared bound would refuse the reprint.
455
442
  */
456
443
  private erasedThrough = -1;
457
444
 
@@ -536,23 +523,12 @@ export class LineStore {
536
523
  private unconfirmedFrom = Number.POSITIVE_INFINITY;
537
524
 
538
525
  /**
539
- * The resume REPLAY-EXCLUSION BOUNDARY: the highest index this client will not
540
- * ask the server to re-send. Send this as `haveThrough`, never `highestIndex()`.
541
- *
542
- * The two differ by whatever the store holds provisionally, and that
543
- * difference is a correctness bug once it reaches the wire. While a store has
544
- * no socket (a background tab) it observes no base advance, so its window rows
545
- * keep the content the app drew there, and claiming down to the window's
546
- * bottom row tells the server "no need to re-send those" about rows whose
547
- * committed content this client has never seen. The server replays strictly
548
- * above `haveThrough`, so the provisional copies are never corrected and
549
- * surface as scrollback under the new window: measured as a frozen kiro-cli
550
- * composer box parked above the live region after almost every tab switch.
551
- *
552
- * NOT a residency fact and NOT "the highest confirmed row": the index this
553
- * returns may be one the store never held or has since evicted. The only
554
- * guarantee is `replayBoundary() <= highestIndex()`, with equality whenever
555
- * nothing held is provisional.
526
+ * The resume replay-exclusion boundary: the highest index this client will not
527
+ * ask the server to re-send. Send it as `haveThrough`, never `highestIndex()`: a
528
+ * background tab's window rows hold what the app drew there, and claiming them
529
+ * means their committed content is never re-sent, so a stale input box surfaces
530
+ * as scrollback. It may name an index never held or since evicted; the only
531
+ * guarantee is `replayBoundary() <= highestIndex()`, equal when none is provisional.
556
532
  */
557
533
  replayBoundary(): number {
558
534
  if (this.highest < 0) {
@@ -690,30 +666,21 @@ export class LineStore {
690
666
  // Re-check the cap AFTER the window settled: a screen frame can LOWER the
691
667
  // retention bound without applying a single line — a height shrink whose
692
668
  // rows are byte-identical short-circuits applyLine on the idempotency
693
- // guard, and applyLine is otherwise the only enforceCap caller. The R3
694
- // adversarial rounds' interleaved property test failed on exactly this
695
- // shape (~60% of seeds) until the bound was re-checked per FRAME, not
696
- // only per content change.
669
+ // guard, and applyLine is otherwise the only enforceCap caller.
697
670
  this.enforceCap();
698
671
  }
699
672
 
700
673
  /** Apply a decoded scroll/history frame: each line at firstIndex + i. */
701
674
  applyScroll(msg: ScrollMessage): void {
702
675
  if (this.alt) {
703
- // History strictly below the frozen MAIN window is safe to store while
704
- // the alternate screen is active: the alt display renders from altRows,
705
- // never from the abs store, and lines below win.base can only be
706
- // main-screen scrollback — a resume batch's replay, or a post-resume
707
- // durable chunk that raced an alt flip (the 2026-08 write-ordering
708
- // race's last residual, closed here). They surface at alt exit's store
709
- // rebuild. Anything AT or ABOVE the frozen base still violates the
710
- // protocol invariant (the server never emits live scroll during alt)
711
- // and is dropped rather than corrupting the frozen window region. With
712
- // no main window ever seen (a fresh tab attaching straight into an
713
- // in-alt session) there is no region to protect, so everything is
714
- // accepted exactly as on the main-screen path — the alt-exit full
715
- // frame overwrites the window region either way. applyLine's guard
716
- // set applies to whatever passes.
676
+ // In alt, rows below the frozen MAIN window base are main-screen history
677
+ // (a resume replay, or a durable chunk that raced an alt flip) and are
678
+ // stored: the alt display renders from altRows, and they surface at alt
679
+ // exit's rebuild. Rows AT or ABOVE the base break the protocol (no live
680
+ // scroll during alt) and are dropped to keep the frozen window intact.
681
+ // With no main window ever seen (attaching straight into alt) there is
682
+ // nothing to protect, so all rows are accepted; the alt-exit full frame
683
+ // overwrites the window region either way.
717
684
  const base = this.win.height > 0 ? this.win.base : Number.POSITIVE_INFINITY;
718
685
  for (let i = 0; i < msg.lines.length; i++) {
719
686
  const abs = msg.firstIndex + i;
@@ -1133,8 +1100,7 @@ export class LineStore {
1133
1100
  * The store used to derive it from `viewportAbs >= win.base`, and every
1134
1101
  * failure of that derivation was a real defect. It read a window descriptor
1135
1102
  * that step 4 of this very transition RETIRES, so the answer was wrong for
1136
- * every predicted replay jump (r2's confirmed high, patched by capturing the
1137
- * value earlier — a fix that left the hazard in place). With no descriptor at
1103
+ * every predicted replay jump. With no descriptor at
1138
1104
  * all, which is the DOMINANT shape here (this ack is the first frame of its
1139
1105
  * batch, so it precedes the window frame, and a hydrated store persists no
1140
1106
  * window), the fallback was wrong in both directions: "not following" strands
@@ -1669,8 +1635,8 @@ export class LineStore {
1669
1635
  return s !== null && abs >= s.lo && abs < s.hi;
1670
1636
  }
1671
1637
 
1672
- // applyScrollbackCleared handles ED3 (which an inline TUI such as kiro-cli
1673
- // emits on every resize): drop lines below `base`, leave everEvictedThrough
1638
+ // applyScrollbackCleared handles ED3 (which an inline TUI can emit on every
1639
+ // resize): drop lines below `base`, leave everEvictedThrough
1674
1640
  // alone (a deliberate erase is not a trim), CANCEL the in-flight request
1675
1641
  // (its reply would re-admit the erased rows through the guard-2 exemption),
1676
1642
  // and snap the paging floor to `base` (or the trigger re-fetches them). The
@@ -1754,33 +1720,21 @@ export class LineStore {
1754
1720
  }
1755
1721
 
1756
1722
  private enforceCap(): void {
1757
- // The gate and the victim
1758
- // target read `tailCount`, not `lines.size`. Browse cache lives under its
1759
- // OWN budget, enforced at page-apply time, so live output must not be able
1760
- // to evict a page the reader is browsing — at the default batch that would
1761
- // be up to 93 cache lines per applied line.
1723
+ // The gate and the victim target read `tailCount`, not `lines.size`: browse
1724
+ // cache has its OWN budget, enforced at page-apply time, so live output must
1725
+ // not evict a page the reader is browsing (up to 93 cache lines per applied
1726
+ // line at the default batch).
1762
1727
  if (this.tailCount <= this.tailBound) {
1763
1728
  return;
1764
1729
  }
1765
1730
  const target = this.tailBound - evictionBatch(this.tailBound) + 1;
1766
- // ONE oldest-first eviction walk that SKIPS the live window. The window
1767
- // is never evictable — the screen is documented as a fixed,
1768
- // always-present block, so the cap is a HISTORY budget floored at the
1769
- // live screen (a cap at or below the terminal height keeps the full
1770
- // screen with zero scrollback). Content ABOVE the window (a resume
1771
- // replay delivered before its window frame; a malformed stream that
1772
- // never advances the base) is ordinary evictable history too: the
1773
- // cursor hops over the window and keeps walking, so the bound
1774
- // size <= max(maxLines, window height) holds whatever shape the frames
1775
- // arrive in. Evicting that band OLDEST-first keeps the NEWEST lines —
1776
- // the ones adjacent to the incoming window — so after the window
1777
- // advances the retained set converges back to one contiguous block
1778
- // (R3 adversarial finding: the earlier newest-first tail trim parked a
1779
- // permanent interior hole under the live screen that resume, which
1780
- // replays only above haveThrough, could never backfill).
1781
- // everEvictedThrough advances over every TAIL eviction, including the
1782
- // hopped band — safe for the window frame that follows a replay because
1783
- // apply-line guard 2 exempts the live window (see applyLine).
1731
+ // ONE oldest-first eviction walk that SKIPS the live window: the cap is a
1732
+ // history budget floored at the screen height. Content above the window (a
1733
+ // replay delivered before its window frame, a malformed stream) is evictable,
1734
+ // so size <= max(maxLines, window height) holds for any frame order. Oldest-
1735
+ // first keeps the lines next to the window; a newest-first trim leaves a hole
1736
+ // under the live screen that resume cannot backfill. everEvictedThrough also
1737
+ // covers the hopped band, safe because applyLine's guard 2 exempts the window.
1784
1738
  const winBottom = this.win.height > 0 ? this.win.base + this.win.height - 1 : -1;
1785
1739
  const hasWindow = this.win.height > 0;
1786
1740
  let cursor = this.oldest;
@@ -1867,7 +1821,7 @@ export class LineStore {
1867
1821
  if (hopped) {
1868
1822
  // The cursor hopped the window or a browse interval, so the minimum
1869
1823
  // retained key is not the cursor: recompute both bounds once per pass
1870
- // (bounded scan; once per PASS, never per victim — R3 perf finding).
1824
+ // (bounded scan; once per PASS, never per victim).
1871
1825
  let min = -1;
1872
1826
  let max = -1;
1873
1827
  for (const k of this.lines.keys()) {
@@ -1945,8 +1899,8 @@ export class LineStore {
1945
1899
  // the alt frame. This keeps the retention bound (max(maxLines, window
1946
1900
  // height)) and the guard-2 window exemption defined over the MAIN screen,
1947
1901
  // the region alt exit must restore, rather than over a phantom range
1948
- // mixing the main base with the alt height (R4 review, all three models:
1949
- // a smaller alt frame otherwise appeared to lower the bound below the
1902
+ // mixing the main base with the alt height (a smaller alt frame otherwise
1903
+ // appeared to lower the bound below the
1950
1904
  // protected main rows, and the exemption described rows that were not the
1951
1905
  // writable screen).
1952
1906
  const next: WindowState = {
package/src/types.ts CHANGED
@@ -61,7 +61,7 @@ export interface ScreenMessage {
61
61
  /**
62
62
  * True when the app issued ED3 (`CSI 3 J`, "erase saved lines") this frame:
63
63
  * the client drops its scrollback history (all lines below `base`), matching
64
- * a real terminal. Inline TUIs (kiro-cli) emit ED3 on every resize redraw to
64
+ * a real terminal. Inline TUIs can emit ED3 on every resize redraw to
65
65
  * discard the previous frame; honoring it stops stale frames accumulating.
66
66
  */
67
67
  scrollbackCleared?: boolean;