@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 +1 -1
- package/package.json +10 -10
- package/src/index.ts +1 -0
- package/src/keyboard.ts +18 -19
- package/src/render.ts +7 -8
- package/src/status-stream.ts +38 -29
- package/src/store.ts +38 -84
- package/src/types.ts +1 -1
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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.
|
|
26
|
-
"@vitest/coverage-v8": "5.0.
|
|
25
|
+
"@vitest/browser-playwright": "5.0.5",
|
|
26
|
+
"@vitest/coverage-v8": "5.0.5",
|
|
27
27
|
"esbuild": "0.28.2",
|
|
28
|
-
"eslint": "10.
|
|
28
|
+
"eslint": "10.13.0",
|
|
29
29
|
"fast-check": "4.10.2",
|
|
30
|
-
"knip": "6.
|
|
31
|
-
"playwright": "1.64.
|
|
30
|
+
"knip": "6.41.0",
|
|
31
|
+
"playwright": "1.64.1",
|
|
32
32
|
"pngjs": "7.0.0",
|
|
33
|
-
"prettier": "3.9.
|
|
33
|
+
"prettier": "3.9.10",
|
|
34
34
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
35
|
-
"typescript-eslint": "8.71.
|
|
36
|
-
"vitest": "5.0.
|
|
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
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
|
|
3
|
-
//
|
|
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
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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"
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* other single-character
|
|
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
|
|
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
|
|
package/src/status-stream.ts
CHANGED
|
@@ -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.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
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 (
|
|
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
|
|
76
|
-
*
|
|
77
|
-
* Sticky for the session's life
|
|
78
|
-
* only when this is set; a program that
|
|
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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
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
|
|
540
|
-
* ask the server to re-send. Send
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
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
|
|
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
|
|
1673
|
-
//
|
|
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
|
-
//
|
|
1759
|
-
//
|
|
1760
|
-
//
|
|
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
|
|
1767
|
-
//
|
|
1768
|
-
//
|
|
1769
|
-
//
|
|
1770
|
-
//
|
|
1771
|
-
//
|
|
1772
|
-
//
|
|
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
|
|
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 (
|
|
1949
|
-
//
|
|
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
|
|
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;
|