@cplieger/web-terminal-ui 7.0.2 → 7.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/css/30-tabs.css +0 -21
- package/package.json +9 -7
- package/src/features/context-menu.ts +10 -0
- package/src/features/tabs/attention.ts +19 -4
- package/src/features/tabs/index.ts +4 -5
- package/src/features/tabs/ink-centre.ts +3 -3
- package/src/features/tabs/notify.ts +1 -1
- package/src/features/tabs/switch-anim.ts +1 -1
- package/src/kernel/kernel.ts +2 -1
- package/src/kernel/loading-status.ts +28 -10
- package/src/kernel/navigation.ts +19 -0
- package/src/kernel/scrollback-storage.ts +10 -6
- package/src/kernel/scrollback.ts +105 -44
- package/src/kernel/style-contract.ts +1 -1
- package/src/kernel/types.ts +22 -3
- package/src/presets/tabbed.ts +14 -18
package/css/30-tabs.css
CHANGED
|
@@ -355,27 +355,6 @@
|
|
|
355
355
|
color: var(--text);
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
-
/* On an inactive tab the close appears only on hover, kept in the layout via
|
|
359
|
-
visibility so the tab width never jumps; on the active tab it is always shown. */
|
|
360
|
-
:where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
|
|
361
|
-
visibility: hidden;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
:where(.wt-root) .wt-tab:not(.wt-tab-active):hover .wt-tab-close {
|
|
365
|
-
visibility: visible;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/* Coarse pointer (an iPad using the strip): no hover fires, so an inactive tab's close
|
|
369
|
-
would be unreachable without first activating the tab. Reveal every tab's close there,
|
|
370
|
-
matching the always-visible mobile switcher closes. No narrow gate needed: on the
|
|
371
|
-
narrow-coarse phone the whole strip is display:none (31-switcher.css), so this rule
|
|
372
|
-
is moot there. */
|
|
373
|
-
@media (pointer: coarse) {
|
|
374
|
-
:where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
|
|
375
|
-
visibility: visible;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
358
|
/* The add-tab (+) button: a fixed bar item OUTSIDE the tab scroller (it never
|
|
380
359
|
scrolls away with an overflowing list; the shrink-wrapped scroller keeps it
|
|
381
360
|
visually trailing the last tab while the tabs fit — see .wt-tab-scroll). Its
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -50,19 +50,21 @@
|
|
|
50
50
|
"@stryker-mutator/core": "10.0.0",
|
|
51
51
|
"@stryker-mutator/typescript-checker": "10.0.0",
|
|
52
52
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
53
|
-
"@types/node": "26.
|
|
53
|
+
"@types/node": "26.4.0",
|
|
54
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
55
|
+
"@vitest/browser": "4.1.11",
|
|
56
|
+
"@vitest/browser-playwright": "4.1.11",
|
|
55
57
|
"@vitest/coverage-v8": "4.1.11",
|
|
56
|
-
"eslint": "10.
|
|
58
|
+
"eslint": "10.9.1",
|
|
57
59
|
"fast-check": "4.9.0",
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
60
|
+
"html-validate": "11.10.0",
|
|
61
|
+
"knip": "6.33.0",
|
|
62
|
+
"playwright": "1.62.1",
|
|
61
63
|
"prettier": "3.9.6",
|
|
62
64
|
"stylelint": "17.14.1",
|
|
63
65
|
"stylelint-config-standard": "40.0.0",
|
|
64
66
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
65
|
-
"typescript-eslint": "8.
|
|
67
|
+
"typescript-eslint": "8.68.0",
|
|
66
68
|
"vitest": "4.1.11"
|
|
67
69
|
},
|
|
68
70
|
"publishConfig": {
|
|
@@ -75,6 +75,16 @@ export interface ContextMenuOptions {
|
|
|
75
75
|
* opens, so a wrong answer here costs at most a duplicated menu on an unusual
|
|
76
76
|
* device, never a broken selection and never a lost paste. */
|
|
77
77
|
function isAppleTouchDevice(): boolean {
|
|
78
|
+
// Dead in every runtime that reaches this, and left in place deliberately.
|
|
79
|
+
// Measured on this repo's Node (v24.18.0, the version ts-ci pins): `typeof
|
|
80
|
+
// navigator` is "object", with userAgent "Node.js/24", platform
|
|
81
|
+
// "Linux x86_64" and maxTouchPoints undefined — so the fall-through already
|
|
82
|
+
// answers false there, by the same route a browser on a non-Apple device
|
|
83
|
+
// takes. A Web Worker has a WorkerNavigator, so it does not reach this
|
|
84
|
+
// either. What the guard still buys is totality: without it the bare
|
|
85
|
+
// `navigator` reads below would throw a ReferenceError in a runtime that has
|
|
86
|
+
// no navigator at all. It answers the same `false` the fall-through would, so
|
|
87
|
+
// nothing depends on reaching it, and no test can reach it without stubbing.
|
|
78
88
|
if (typeof navigator === "undefined") {
|
|
79
89
|
return false;
|
|
80
90
|
}
|
|
@@ -19,10 +19,25 @@
|
|
|
19
19
|
// the switcher's aggregate dot about what wants the user; the fold and the raise
|
|
20
20
|
// share isUnseenCue (model.ts).
|
|
21
21
|
//
|
|
22
|
-
// DOM-free
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// DOM-free by construction, like notify.ts: every capability arrives through
|
|
23
|
+
// AttentionEnv, so the fold, the idempotence and the degradation paths are driven
|
|
24
|
+
// in the tests by plain recorder functions, and browserAttentionEnv() below is the
|
|
25
|
+
// only function here that reads a global (globalThis.navigator, document).
|
|
26
|
+
//
|
|
27
|
+
// That last clause is a reading of the source, and nothing enforces it — on
|
|
28
|
+
// purpose, not by omission. An ambient-absence test would assert the RUNTIME
|
|
29
|
+
// rather than this module: the only importer is tabs/index.ts, the node project
|
|
30
|
+
// holds one file and it reads stylesheets off disk, so nothing here loads this
|
|
31
|
+
// module outside a browser and no test can observe an absent `document` without
|
|
32
|
+
// passing for a third wrong reason (.kiro/steering/testing-ts.md,
|
|
33
|
+
// "Capability-absence is NOT a project selection"). The half that does ship IS
|
|
34
|
+
// enforced, and at the site: attention.test.ts shadows the whole `navigator`
|
|
35
|
+
// object and drives the real browserAttentionEnv through it. The mechanism that
|
|
36
|
+
// could enforce the load-time half is an AST scan of this file's top-level
|
|
37
|
+
// statements — vibekit carries one for its ported copy, where a node-project test
|
|
38
|
+
// statically imports the module and a module-scope global read really does break
|
|
39
|
+
// something. Copy it here the day anything needs this module to load without a
|
|
40
|
+
// document.
|
|
26
41
|
|
|
27
42
|
import { type CueStatus, cueIconName, isUnseenCue, worseCue } from "./model.js";
|
|
28
43
|
|
|
@@ -1007,8 +1007,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1007
1007
|
// sync, so a user browsing a scrolled strip is never yanked back by an
|
|
1008
1008
|
// unrelated repaint), the newly active chip is brought into view in the
|
|
1009
1009
|
// overflowed scroller; inline: "nearest" is a no-op when it is already
|
|
1010
|
-
// visible. The typeof guard covers
|
|
1011
|
-
// scrollIntoView.
|
|
1010
|
+
// visible. The typeof guard covers an environment without scrollIntoView.
|
|
1012
1011
|
let lastRevealedActive = "";
|
|
1013
1012
|
function paintActive(): void {
|
|
1014
1013
|
for (const t of tabList) {
|
|
@@ -2247,7 +2246,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2247
2246
|
}
|
|
2248
2247
|
}
|
|
2249
2248
|
if (invert.size === 0) {
|
|
2250
|
-
return; // nothing moved on screen (
|
|
2249
|
+
return; // nothing moved on screen (every box measured zero)
|
|
2251
2250
|
}
|
|
2252
2251
|
applyShift(invert, "none");
|
|
2253
2252
|
// The read forces the reflow that COMMITS the from-state. Without it the
|
|
@@ -2537,8 +2536,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2537
2536
|
// Reordering re-appends every chip, and reparenting the focused field blurs
|
|
2538
2537
|
// it — so an open edit would be committed as a side effect of moving some
|
|
2539
2538
|
// OTHER tab, via a blur the user never performed. Resolve it here instead,
|
|
2540
|
-
// so the outcome is chosen
|
|
2541
|
-
//
|
|
2539
|
+
// so the outcome is chosen rather than emergent: a blur that arrives as a
|
|
2540
|
+
// side effect of reparenting is not a decision anyone made.
|
|
2542
2541
|
resolveEdit("blur", false);
|
|
2543
2542
|
const from = tabList.findIndex((t) => t.id === id);
|
|
2544
2543
|
const to = from + delta;
|
|
@@ -249,9 +249,9 @@ export function centreChipLabels(
|
|
|
249
249
|
};
|
|
250
250
|
|
|
251
251
|
remeasure(false);
|
|
252
|
-
// The DOM lib types document.fonts as always present;
|
|
253
|
-
// FontFaceSet at all,
|
|
254
|
-
//
|
|
252
|
+
// The DOM lib types document.fonts as always present; an engine without the CSS
|
|
253
|
+
// Font Loading API ships no FontFaceSet at all, so the honest type is the
|
|
254
|
+
// optional one.
|
|
255
255
|
const fonts = document.fonts as FontFaceSet | undefined;
|
|
256
256
|
// `ready` settles the initial load (including a failed one); `loadingdone`
|
|
257
257
|
// covers a face the host page adds afterwards, which `ready` never reports.
|
|
@@ -48,7 +48,7 @@ export type NotificationCtorLike = new (
|
|
|
48
48
|
* not recognise must degrade rather than be asserted. */
|
|
49
49
|
export interface NotifierEnv {
|
|
50
50
|
/** The Notification constructor, or undefined where the API does not exist
|
|
51
|
-
* (iOS Safari outside an installed PWA, a non-secure context
|
|
51
|
+
* (iOS Safari outside an installed PWA, a non-secure context). */
|
|
52
52
|
ctor: NotificationCtorLike | undefined;
|
|
53
53
|
/** Current permission: "default" | "granted" | "denied" (or anything else). */
|
|
54
54
|
permission: () => string;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// on the 360 ms fallback timer instead of the animation's own end, and every test
|
|
11
11
|
// still passes because the tests dispatch the JS constant.
|
|
12
12
|
//
|
|
13
|
-
// Living here rather than inside the feature is what lets `css-contract.test.ts`
|
|
13
|
+
// Living here rather than inside the feature is what lets `css-contract.node.test.ts`
|
|
14
14
|
// import it and assert the CSS agrees. Same reason `input-placeholder.ts` and
|
|
15
15
|
// `kernel/gesture.ts` exist: a constant two places must keep in lockstep gets its
|
|
16
16
|
// own module. NOT part of the package's `exports` map, so it is internal and can
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { createConnState } from "./conn-state.js";
|
|
|
40
40
|
import { createScrollbackKeeper } from "./scrollback.js";
|
|
41
41
|
import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
|
|
42
42
|
import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
|
|
43
|
+
import { reloadPage } from "./navigation.js";
|
|
43
44
|
import type {
|
|
44
45
|
CreateTerminalOptions,
|
|
45
46
|
FeatureInstance,
|
|
@@ -184,7 +185,7 @@ function renderFatalStartupInto(
|
|
|
184
185
|
reloadButton.type = "button";
|
|
185
186
|
reloadButton.textContent = STARTUP_FAILURE_COPY.reloadLabel;
|
|
186
187
|
reloadButton.addEventListener("click", () => {
|
|
187
|
-
|
|
188
|
+
reloadPage();
|
|
188
189
|
});
|
|
189
190
|
card.append(title, messageEl, reloadButton);
|
|
190
191
|
surface.appendChild(card);
|
|
@@ -135,10 +135,10 @@ export function attachLoadingStatus(
|
|
|
135
135
|
// Swap the visible text through a fade so a change reads as deliberate rather
|
|
136
136
|
// than as a glitch. The first write skips the fade (there is nothing to fade
|
|
137
137
|
// out) so the line does not arrive half-transparent.
|
|
138
|
+
//
|
|
139
|
+
// No `stopped` check, here or in the deferred half below, and that is
|
|
140
|
+
// deliberate rather than an omission: see stop(), which owns the invariant.
|
|
138
141
|
const show = (text: string): void => {
|
|
139
|
-
if (stopped) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
142
|
// First write has nothing to fade out, so it must not arrive half-transparent.
|
|
143
143
|
if (visible.textContent === "") {
|
|
144
144
|
visible.textContent = text;
|
|
@@ -146,18 +146,13 @@ export function attachLoadingStatus(
|
|
|
146
146
|
}
|
|
147
147
|
visible.classList.add("wt-loading-text-out");
|
|
148
148
|
later(() => {
|
|
149
|
-
if (stopped) {
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
149
|
visible.textContent = text;
|
|
153
150
|
visible.classList.remove("wt-loading-text-out");
|
|
154
151
|
}, SWAP_FADE_MS);
|
|
155
152
|
};
|
|
156
153
|
|
|
157
154
|
const announce = (text: string): void => {
|
|
158
|
-
|
|
159
|
-
live.textContent = text;
|
|
160
|
-
}
|
|
155
|
+
live.textContent = text;
|
|
161
156
|
};
|
|
162
157
|
|
|
163
158
|
later(() => {
|
|
@@ -170,7 +165,13 @@ export function attachLoadingStatus(
|
|
|
170
165
|
|
|
171
166
|
later(() => {
|
|
172
167
|
const waiting = messages.waiting;
|
|
173
|
-
|
|
168
|
+
// `pinned` only. Not `stopped`: this callback is held by `timers`, so stop()
|
|
169
|
+
// has cancelled it before it could read the flag. That disjunct was here and
|
|
170
|
+
// was as dead as the ones deleted above — and invisible to the mutation
|
|
171
|
+
// report, because a mutator works on binary-expression nodes and never
|
|
172
|
+
// isolates a bare identifier operand, so `pinned || stopped` is mutated as a
|
|
173
|
+
// pair and the pair is killable through `pinned`.
|
|
174
|
+
if (pinned || waiting.length === 0) {
|
|
174
175
|
return;
|
|
175
176
|
}
|
|
176
177
|
// showAt reads through an undefined check rather than an index assertion:
|
|
@@ -219,6 +220,23 @@ export function attachLoadingStatus(
|
|
|
219
220
|
announce(text);
|
|
220
221
|
},
|
|
221
222
|
stop(): void {
|
|
223
|
+
// THE OWNER of "nothing further happens", and the only one.
|
|
224
|
+
//
|
|
225
|
+
// It discharges that by cancelling every callback this controller armed:
|
|
226
|
+
// `later()` is the sole way to schedule here, everything it schedules is in
|
|
227
|
+
// `timers`, and the rotation interval is the one handle held separately.
|
|
228
|
+
// So the only call that can arrive after this line is one from OUTSIDE a
|
|
229
|
+
// cancellable callback — which is `reason()`, and `reason()` is where
|
|
230
|
+
// `stopped` is read.
|
|
231
|
+
//
|
|
232
|
+
// `show()` and `announce()` used to re-test it as well, and the mutation
|
|
233
|
+
// pass measured what that was worth: five permanently unkillable mutants,
|
|
234
|
+
// because no input could reach either function with `stopped` true. They
|
|
235
|
+
// were not even protecting anything if they had — the two nodes are
|
|
236
|
+
// detached three lines below, so a late write goes nowhere. The invariant
|
|
237
|
+
// is pinned by a test instead ("stop() leaves no timer armed"), which,
|
|
238
|
+
// unlike a guard, fails for the change that would actually break it: a
|
|
239
|
+
// future timer armed outside `later()`.
|
|
222
240
|
if (stopped) {
|
|
223
241
|
return;
|
|
224
242
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// navigation.ts — the library's one page-navigation call, behind its own module.
|
|
2
|
+
//
|
|
3
|
+
// `window.location.reload` cannot be substituted in a real browser: `reload` is
|
|
4
|
+
// an own, sealed property of the location instance (`configurable: false`,
|
|
5
|
+
// `writable: false`), `Location.prototype` carries no `reload` to spy on, and
|
|
6
|
+
// `window`, `globalThis.window` and `globalThis.location` are all
|
|
7
|
+
// non-configurable. So a test cannot redefine it, assign over it, or stub the
|
|
8
|
+
// global, and letting the real call run navigates the test runner's own frame
|
|
9
|
+
// and takes the whole run down with it.
|
|
10
|
+
//
|
|
11
|
+
// A separate module is the seam that works: a caller's import is a live binding
|
|
12
|
+
// the test framework can replace, which an intra-module call is not. Keeping the
|
|
13
|
+
// call here also means there is exactly one place in the library that navigates,
|
|
14
|
+
// so the next one has to be added deliberately.
|
|
15
|
+
|
|
16
|
+
/** Reload the current page. The only navigation this library performs. */
|
|
17
|
+
export function reloadPage(): void {
|
|
18
|
+
window.location.reload();
|
|
19
|
+
}
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
// caps rather than only writing.
|
|
29
29
|
|
|
30
30
|
import type { ScrollbackPersistence } from "./types.js";
|
|
31
|
-
import type { StoreSnapshot } from "@cplieger/web-terminal-engine";
|
|
32
31
|
import { DEFAULT_MAX_AGE_MS } from "./scrollback.js";
|
|
33
32
|
import { positiveIntOption } from "./options.js";
|
|
34
33
|
|
|
@@ -259,11 +258,16 @@ export function localScrollbackStorage(
|
|
|
259
258
|
return null;
|
|
260
259
|
}
|
|
261
260
|
try {
|
|
262
|
-
// The snapshot is returned
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
261
|
+
// The snapshot is returned unparsed on purpose: the kernel parses every
|
|
262
|
+
// field against the engine's snapshot contract, and duplicating that here
|
|
263
|
+
// would give two places to disagree about what a usable entry is. Typed
|
|
264
|
+
// `unknown` rather than asserted `as StoreSnapshot`, which is what this
|
|
265
|
+
// line used to do — and that cast was laundering `JSON.parse`'s `any` past
|
|
266
|
+
// no-unsafe-assignment while telling the compiler the value was verified,
|
|
267
|
+
// leaving the keeper to verify it anyway. That is how the keeper's
|
|
268
|
+
// boundary came to check the same thing twice.
|
|
269
|
+
const snapshot: unknown = JSON.parse(raw.slice(raw.indexOf("\n") + 1));
|
|
270
|
+
return { savedAt, snapshot };
|
|
267
271
|
} catch {
|
|
268
272
|
remove(keyFor(sessionId));
|
|
269
273
|
return null;
|
package/src/kernel/scrollback.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { LineStore, connection } from "@cplieger/web-terminal-engine";
|
|
20
20
|
import { positiveIntOption } from "./options.js";
|
|
21
|
-
import type {
|
|
21
|
+
import type { ScrollbackPersistence } from "./types.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Newest lines persisted per session when the consumer names no bound.
|
|
@@ -69,33 +69,72 @@ export interface ScrollbackKeeper {
|
|
|
69
69
|
stop(): void;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/**
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
/** A stored entry that has been read and proven: every field is what it says, so
|
|
73
|
+
* nothing downstream re-checks it. `readStoredEntry` is the only constructor,
|
|
74
|
+
* which is what makes that promise keepable. */
|
|
75
|
+
interface StoredEntry {
|
|
76
|
+
/** `Date.now()` at the write. Finite. */
|
|
77
|
+
readonly savedAt: number;
|
|
78
|
+
/** The snapshot's server epoch, or null when the entry names no server
|
|
79
|
+
* process.
|
|
80
|
+
*
|
|
81
|
+
* Stricter than "a finite number", because this value is adopted as a
|
|
82
|
+
* session's IDENTITY: the server reports a process-start timestamp in
|
|
83
|
+
* nanoseconds, so a negative or fractional value cannot have come from one
|
|
84
|
+
* and is a corrupt or hand-edited entry wearing the shape of a real one.
|
|
85
|
+
*
|
|
86
|
+
* NOT 0-for-unknown. The engine spells "unknown" as 0 on its own surface and
|
|
87
|
+
* that is its contract, but inheriting the spelling here put the rejection
|
|
88
|
+
* value inside the range of accepted values, so `epoch > 0` and `epoch >= 0`
|
|
89
|
+
* could not differ: at `epoch === 0` both yielded 0 and both were rejected
|
|
90
|
+
* downstream. Out-of-band restores the distinction, and 0 goes back to being
|
|
91
|
+
* a value the boundary refuses rather than the way it says no. */
|
|
92
|
+
readonly serverEpoch: number | null;
|
|
93
|
+
/** Still the engine's to parse. `LineStore.fromSnapshot` takes `unknown` and
|
|
94
|
+
* is the authority on a snapshot's contents (engine store.ts:1584), so a
|
|
95
|
+
* shape check here would be a second owner of one obligation. */
|
|
96
|
+
readonly snapshot: unknown;
|
|
80
97
|
}
|
|
81
98
|
|
|
82
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Read a stored entry, or null when it cannot be used.
|
|
83
101
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
* `unknown` in, because the value has been outside this program's memory and
|
|
103
|
+
* `ScrollbackPersistence.load`'s declared return type is an assumption about a
|
|
104
|
+
* consumer's storage rather than a fact about its contents — the library's own
|
|
105
|
+
* implementation produces one by `JSON.parse`. A `StoredEntry` out, so every
|
|
106
|
+
* later line reads fields instead of re-establishing them.
|
|
107
|
+
*
|
|
108
|
+
* The parameter type carries the caller's proof: `hydrate` must separate "no
|
|
109
|
+
* entry" from "bad entry" before calling, because those two have different
|
|
110
|
+
* handling, so non-nullishness is already established. That is why there is no
|
|
111
|
+
* object test here and why there must not be one — a property read on any other
|
|
112
|
+
* primitive yields undefined and the field checks below reject it, so a `typeof
|
|
113
|
+
* entry !== "object"` test would be a second statement of a thing the signature
|
|
114
|
+
* already guarantees. This function had two of them, one per field it read, and
|
|
115
|
+
* neither could be observed: 15 of the file's 19 surviving mutants were those
|
|
116
|
+
* two guards and their bodies.
|
|
117
|
+
*/
|
|
118
|
+
function readStoredEntry(entry: NonNullable<unknown>): StoredEntry | null {
|
|
119
|
+
const rec = entry as Record<string, unknown>;
|
|
120
|
+
const savedAt = rec["savedAt"];
|
|
121
|
+
// Finite, not merely a number: the age comparison below is a subtraction, and
|
|
122
|
+
// `Math.abs(Date.now() - NaN) > maxAgeMs` is false, so a NaN timestamp would
|
|
123
|
+
// read as freshly written forever.
|
|
124
|
+
if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) {
|
|
125
|
+
return null;
|
|
96
126
|
}
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
// Read through a nullable cast rather than a typeof test: `?.` is safe on a
|
|
128
|
+
// primitive as well as on null, so one operator covers every shape a stored
|
|
129
|
+
// value can have, and `fromSnapshot` owns what a usable snapshot contains.
|
|
130
|
+
const snapshot = rec["snapshot"] as Record<string, unknown> | null | undefined;
|
|
131
|
+
const epoch = snapshot?.["serverEpoch"];
|
|
132
|
+
return {
|
|
133
|
+
savedAt,
|
|
134
|
+
serverEpoch:
|
|
135
|
+
typeof epoch === "number" && Number.isSafeInteger(epoch) && epoch > 0 ? epoch : null,
|
|
136
|
+
snapshot,
|
|
137
|
+
};
|
|
99
138
|
}
|
|
100
139
|
|
|
101
140
|
export function createScrollbackKeeper(
|
|
@@ -143,22 +182,41 @@ export function createScrollbackKeeper(
|
|
|
143
182
|
* Every rejection below has that same correct handling, which is why none of
|
|
144
183
|
* them throws or reports: the worst outcome of starting empty is one slow
|
|
145
184
|
* restore, and the worst outcome of hydrating something unsound is a terminal
|
|
146
|
-
* that shows content from a session that no longer exists.
|
|
185
|
+
* that shows content from a session that no longer exists.
|
|
186
|
+
*
|
|
187
|
+
* This function owns exactly two decisions and delegates the rest, which is
|
|
188
|
+
* the point of its shape. It decides whether there is an entry at all, and
|
|
189
|
+
* whether an entry that parsed may be USED (age, and verifiability). What an
|
|
190
|
+
* entry's envelope must look like is `readStoredEntry`'s; what a snapshot must
|
|
191
|
+
* contain is `LineStore.fromSnapshot`'s. Three owners, three depths, no
|
|
192
|
+
* overlap — the arrangement this module did not have when its two readers each
|
|
193
|
+
* re-established that the entry was an object. */
|
|
147
194
|
function hydrate(sessionId: string): LineStore | null {
|
|
148
|
-
let
|
|
195
|
+
let raw: unknown;
|
|
149
196
|
try {
|
|
150
|
-
|
|
197
|
+
raw = cfg.load(sessionId);
|
|
151
198
|
} catch {
|
|
199
|
+
// Deliberately empty: a seam that threw has told us there is no entry, and
|
|
200
|
+
// `raw` is still undefined, which is exactly what the check below concludes.
|
|
201
|
+
// A `return null` here would be a second owner of "there is no entry".
|
|
202
|
+
}
|
|
203
|
+
// "No entry" is silent and is not a rejection: nothing is dropped, because
|
|
204
|
+
// there is nothing to drop. Every path below this line has an entry to
|
|
205
|
+
// account for. This is also what lets `readStoredEntry` take a non-nullish
|
|
206
|
+
// parameter and therefore need no object test.
|
|
207
|
+
if (raw === null || raw === undefined) {
|
|
152
208
|
return null;
|
|
153
209
|
}
|
|
154
|
-
|
|
210
|
+
|
|
211
|
+
const entry = readStoredEntry(raw);
|
|
212
|
+
if (entry === null) {
|
|
213
|
+
drop(sessionId);
|
|
155
214
|
return null;
|
|
156
215
|
}
|
|
157
216
|
|
|
158
|
-
const savedAt = savedAtOf(entry);
|
|
159
217
|
// Distance in either direction: a clock that moved backwards would otherwise
|
|
160
218
|
// leave an entry whose age never reaches the bound.
|
|
161
|
-
if (
|
|
219
|
+
if (Math.abs(Date.now() - entry.savedAt) > maxAgeMs) {
|
|
162
220
|
drop(sessionId);
|
|
163
221
|
return null;
|
|
164
222
|
}
|
|
@@ -170,19 +228,22 @@ export function createScrollbackKeeper(
|
|
|
170
228
|
}
|
|
171
229
|
|
|
172
230
|
// Structure checked; now the one rejection that is a real decision rather
|
|
173
|
-
// than a validation
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// detection was never available
|
|
184
|
-
|
|
185
|
-
|
|
231
|
+
// than a validation, and the reason the epoch is read at the boundary but
|
|
232
|
+
// JUDGED here. An entry with no server epoch cannot be verified against the
|
|
233
|
+
// server it is about to be shown next to, and absolute line indices only
|
|
234
|
+
// mean anything within one server process: a restarted server begins again
|
|
235
|
+
// near 0, so a hydrated store whose epoch cannot be compared would present
|
|
236
|
+
// old content as live AND then refuse the new session's output, because
|
|
237
|
+
// those low indices fall below what the hydrated store believes it evicted.
|
|
238
|
+
// A terminal that fills in slowly is a far better failure than one that is
|
|
239
|
+
// wrong and then permanently blank, so an unverifiable entry is discarded.
|
|
240
|
+
// In practice this never fires: an epoch is missing only from a server that
|
|
241
|
+
// reports none at all, in which case restart detection was never available
|
|
242
|
+
// to anyone.
|
|
243
|
+
//
|
|
244
|
+
// AFTER fromSnapshot, deliberately: reading it earlier would fire the warning
|
|
245
|
+
// below for a snapshot the engine was going to reject anyway.
|
|
246
|
+
if (entry.serverEpoch === null) {
|
|
186
247
|
if (!warnedNoEpoch) {
|
|
187
248
|
warnedNoEpoch = true;
|
|
188
249
|
console.warn(
|
|
@@ -196,7 +257,7 @@ export function createScrollbackKeeper(
|
|
|
196
257
|
// The epoch must reach the connection layer BEFORE this session's resume, or
|
|
197
258
|
// the first resumeAck records it with nothing to compare against and a
|
|
198
259
|
// restart goes undetected for exactly the content that needed checking.
|
|
199
|
-
connection.adoptPersistedEpoch(sessionId,
|
|
260
|
+
connection.adoptPersistedEpoch(sessionId, entry.serverEpoch);
|
|
200
261
|
return store;
|
|
201
262
|
}
|
|
202
263
|
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// These lists are HAND-WRITTEN on purpose. They are the public contract — a
|
|
26
26
|
// decision about what this library promises to support — not a fact derived
|
|
27
27
|
// from the stylesheets. What IS derived from the stylesheets is the
|
|
28
|
-
// verification that the promise holds: `src/css-contract.test.ts` generates the
|
|
28
|
+
// verification that the promise holds: `src/css-contract.node.test.ts` generates the
|
|
29
29
|
// declared/referenced token inventory from the CSS itself and fails if any
|
|
30
30
|
// token named here is not both DECLARED and READ by a rule. A token that is
|
|
31
31
|
// declared but never read is the silent-no-op case above, reached from the
|
package/src/kernel/types.ts
CHANGED
|
@@ -498,9 +498,28 @@ export interface PersistedScrollback {
|
|
|
498
498
|
* treats any failure as "nothing was restored" and takes a full resume, exactly
|
|
499
499
|
* as if persistence were switched off. */
|
|
500
500
|
export interface ScrollbackPersistence {
|
|
501
|
-
/** Return
|
|
502
|
-
*
|
|
503
|
-
|
|
501
|
+
/** Return whatever was stored for a session, or null/undefined when there is
|
|
502
|
+
* nothing. Called once per session, before that session connects.
|
|
503
|
+
*
|
|
504
|
+
* `unknown`, not `PersistedScrollback`, and the asymmetry with `save` below is
|
|
505
|
+
* the point: an entry going OUT was produced by the engine and is proven, while
|
|
506
|
+
* an entry coming BACK has been outside this program's memory and is a claim.
|
|
507
|
+
* Declaring the proven type on both sides told the compiler the returned value
|
|
508
|
+
* was verified — which no implementation of this seam can promise, this
|
|
509
|
+
* library's own `localScrollbackStorage` least of all, since it hands back a
|
|
510
|
+
* `JSON.parse` result. The kernel parsed it anyway, and because the type said
|
|
511
|
+
* otherwise it did so in helpers that each re-established the shape from
|
|
512
|
+
* scratch: measured 2026-08, 15 of `scrollback.ts`'s 19 permanently unkillable
|
|
513
|
+
* mutants were those duplicate checks.
|
|
514
|
+
*
|
|
515
|
+
* Nothing is lost by an implementation: a `load` annotated
|
|
516
|
+
* `PersistedScrollback | null` still satisfies this, so a consumer that holds
|
|
517
|
+
* real entries in memory keeps its own type checking. What goes is only the
|
|
518
|
+
* false guarantee at the boundary that cannot honour it.
|
|
519
|
+
*
|
|
520
|
+
* `PersistedScrollback` remains the shape to store and the shape you get back
|
|
521
|
+
* when storage is intact; see `save`. */
|
|
522
|
+
load(sessionId: string): unknown;
|
|
504
523
|
/** Store an entry, replacing any previous one for the session.
|
|
505
524
|
*
|
|
506
525
|
* THROW when nothing was persisted (over quota, storage revoked, an entry the
|
package/src/presets/tabbed.ts
CHANGED
|
@@ -10,6 +10,18 @@ import { activityMonitor } from "../features/activity-monitor.js";
|
|
|
10
10
|
import { animations } from "../features/animations.js";
|
|
11
11
|
import { presetSingle } from "./single.js";
|
|
12
12
|
|
|
13
|
+
/** Options shared by the two tabbed presets, passed through to the tabs feature.
|
|
14
|
+
* A preset that takes arguments is called as `features: () =>
|
|
15
|
+
* presetTabbed({...})`, which keeps the call inside createTerminal's failure
|
|
16
|
+
* boundary (see CreateTerminalOptions.features). */
|
|
17
|
+
export interface TabbedPresetOptions {
|
|
18
|
+
/** Swap the page's icon links to a status variant while a background session
|
|
19
|
+
* wants the user. Off by default, and enabling it is a promise that the
|
|
20
|
+
* variant assets are served — see TabsOptions.attentionIcons for the naming
|
|
21
|
+
* contract and .kiro/scripts/gen-attention-icons.py, which writes them. */
|
|
22
|
+
attentionIcons?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
/** buildTabbed composes the tabbed UI: the single-pane touch features, an
|
|
14
26
|
* externally-driven mobile toolbar (its grid is opened from a keyboard button
|
|
15
27
|
* in the tab bar, not its own toggle), the activity monitor (the status-SSE
|
|
@@ -27,18 +39,6 @@ import { presetSingle } from "./single.js";
|
|
|
27
39
|
* foreground-process/cwd inference — and both presets render what it reports. A
|
|
28
40
|
* browser that re-derived any of that could only disagree with the server and
|
|
29
41
|
* with every other client attached to the same session. */
|
|
30
|
-
/** Options shared by the two tabbed presets, passed through to the tabs feature.
|
|
31
|
-
* A preset that takes arguments is called as `features: () =>
|
|
32
|
-
* presetTabbed({...})`, which keeps the call inside createTerminal's failure
|
|
33
|
-
* boundary (see CreateTerminalOptions.features). */
|
|
34
|
-
export interface TabbedPresetOptions {
|
|
35
|
-
/** Swap the page's icon links to a status variant while a background session
|
|
36
|
-
* wants the user. Off by default, and enabling it is a promise that the
|
|
37
|
-
* variant assets are served — see TabsOptions.attentionIcons for the naming
|
|
38
|
-
* contract and .kiro/scripts/gen-attention-icons.py, which writes them. */
|
|
39
|
-
attentionIcons?: boolean;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
42
|
export function buildTabbed(
|
|
43
43
|
agentShell: boolean,
|
|
44
44
|
opts: TabbedPresetOptions = {},
|
|
@@ -59,12 +59,8 @@ export function buildTabbed(
|
|
|
59
59
|
];
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** Tabbed UI:
|
|
63
|
-
*
|
|
64
|
-
* title when the program sets one, else the last submitted line; each tab's
|
|
65
|
-
* activity dot stays hidden until its session reports activity via OSC 9;4
|
|
66
|
-
* progress (kiro-cli, Claude Code, …), so a plain bash/sh keeps clean,
|
|
67
|
-
* label-only tabs. */
|
|
62
|
+
/** Tabbed UI: buildTabbed with agent activity presumed off (see buildTabbed for
|
|
63
|
+
* title and activity-dot sourcing). */
|
|
68
64
|
export function presetTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
|
|
69
65
|
return buildTabbed(false, opts);
|
|
70
66
|
}
|