@cplieger/web-terminal-ui 7.3.1 → 8.0.0
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 +114 -18
- package/css/30-tabs.css +52 -13
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +13 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- package/src/features/tabs/notify.ts +0 -265
|
@@ -1,84 +1,54 @@
|
|
|
1
|
-
//
|
|
2
|
-
// the per-tab record, the session REST client, the close-tombstone set, and the
|
|
3
|
-
// pinned-name helpers. No DOM, no chrome, no kernel context —
|
|
4
|
-
// everything here is factory/pure and unit-testable in isolation. The chrome
|
|
5
|
-
// halves are strip.ts (desktop) and switcher.ts (mobile); index.ts wires all
|
|
6
|
-
// three over the kernel context.
|
|
1
|
+
// No DOM and no kernel context in this module; index.ts wires it over the chrome.
|
|
7
2
|
|
|
8
3
|
import type { LineStore } from "@cplieger/web-terminal-engine";
|
|
9
4
|
import type { SessionInfo } from "@cplieger/web-terminal-engine";
|
|
10
5
|
import type { ViewMemory } from "@cplieger/web-terminal-engine";
|
|
11
|
-
import type { TabHandle } from "../../kernel/types.js";
|
|
6
|
+
import type { PaneSide, TabHandle } from "../../kernel/types.js";
|
|
12
7
|
|
|
13
|
-
// One session's wire shape (SessionInfo) is the ENGINE's exported type — the
|
|
14
|
-
// same repo as the Go terminal.SessionInfo it mirrors, so the cross-language
|
|
15
|
-
// contract has one home. Type-only import: erases at compile, no runtime dep.
|
|
16
8
|
export type { SessionInfo };
|
|
17
9
|
|
|
18
|
-
/** A status record as the tabs feature consumes it: the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* this way, and the optionality is meaningful: the polling fallback lists
|
|
22
|
-
* SessionInfo with no percentage at all, which means "no information" and must
|
|
23
|
-
* not be read as "the percentage was cleared". */
|
|
10
|
+
/** A status record as the tabs feature consumes it: the REST wire shape plus the
|
|
11
|
+
* percentage that exists only on the status STREAM. The polling fallback lists
|
|
12
|
+
* no percentage at all, which means "no information", not "cleared". */
|
|
24
13
|
export type StatusRecord = SessionInfo & {
|
|
25
14
|
readonly progressValue?: number;
|
|
26
15
|
};
|
|
27
16
|
|
|
28
|
-
// The status vocabulary
|
|
29
|
-
//
|
|
30
|
-
// statusPhrase) instead of comparing strings itself, so a status's
|
|
31
|
-
// MEANING has exactly one home. Adding a status means adding it to the
|
|
32
|
-
// predicates that should include it, not to a comparison at each call site.
|
|
17
|
+
// The status vocabulary is module-private so a status's MEANING has one home:
|
|
18
|
+
// every consumer asks a predicate below instead of comparing strings.
|
|
33
19
|
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// final screen replays and the kernel shows "Session ended" — but it can never
|
|
37
|
-
// produce output again, so session selection prefers live sessions everywhere.
|
|
20
|
+
// An exited session is viewable history that can never produce output again, so
|
|
21
|
+
// session selection prefers live sessions everywhere.
|
|
38
22
|
const STATUS_EXITED = "exited";
|
|
39
23
|
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* reported as "exited", so a routine restart never paints as a failure. Like
|
|
44
|
-
* "exited" it is terminal: nothing clears it, and it outranks every progress
|
|
45
|
-
* state. */
|
|
24
|
+
/** A non-zero exit status or an unrequested signal. A server-initiated end (a
|
|
25
|
+
* closed tab, the reaper, a shutdown) is reported as "exited", so a routine
|
|
26
|
+
* restart never paints as a failure. Terminal, like "exited". */
|
|
46
27
|
const STATUS_CRASHED = "crashed";
|
|
47
28
|
|
|
48
|
-
/** OSC 9;4 progress state 2, the error state (iTerm2 semantics). A STATE
|
|
49
|
-
*
|
|
50
|
-
* process dies (which outranks it). */
|
|
29
|
+
/** OSC 9;4 progress state 2, the error state (iTerm2 semantics). A STATE that
|
|
30
|
+
* persists until the program reports another, or the process dies. */
|
|
51
31
|
const STATUS_FAILED = "failed";
|
|
52
32
|
|
|
53
|
-
/** OSC 9;4 progress state 4, the warning state (iTerm2 semantics
|
|
54
|
-
* the same state paused). Same persistence rules as STATUS_FAILED. */
|
|
33
|
+
/** OSC 9;4 progress state 4, the warning state (iTerm2 semantics). */
|
|
55
34
|
const STATUS_WARNING = "warning";
|
|
56
35
|
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
59
|
-
* because a crashed session is exactly as dead as an exited one: reloading onto
|
|
60
|
-
* either is the stuck-loading wedge the bootstrap's live-session preference
|
|
61
|
-
* exists to avoid. */
|
|
36
|
+
/** Whether the session's process is GONE, whichever way it went: reloading onto
|
|
37
|
+
* a crashed session is the same stuck-loading wedge as onto an exited one. */
|
|
62
38
|
export function isEndedStatus(status: string): boolean {
|
|
63
39
|
return status === STATUS_EXITED || status === STATUS_CRASHED;
|
|
64
40
|
}
|
|
65
41
|
|
|
66
|
-
/** The statuses whose dot
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* `crashed` is the case that needs it — a plain shell that dies badly has
|
|
70
|
-
* reported no activity in its life, and its red dot is the one thing the user
|
|
71
|
-
* most needs to see. An ordinary `exited` deliberately stays gated: a clean end
|
|
72
|
-
* is not news. */
|
|
42
|
+
/** The statuses whose dot shows even without the sticky reportsActivity flag: a
|
|
43
|
+
* plain shell that dies badly has reported no activity in its life, and its red
|
|
44
|
+
* dot is the one thing the user most needs to see. A clean exit is not news. */
|
|
73
45
|
export function statusRevealsDot(status: string): boolean {
|
|
74
46
|
return status === STATUS_WARNING || status === STATUS_FAILED || status === STATUS_CRASHED;
|
|
75
47
|
}
|
|
76
48
|
|
|
77
|
-
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* disagree. An unknown status (a newer server: the wire is parsed, not
|
|
81
|
-
* validated) falls back to the raw value rather than being hidden. */
|
|
49
|
+
/** The human wording for a status, for BOTH the dot's tooltip and the tab's
|
|
50
|
+
* accessible name, so they cannot disagree. An unknown status (a newer server)
|
|
51
|
+
* falls back to the raw value rather than being hidden. */
|
|
82
52
|
export function statusPhrase(status: string): string {
|
|
83
53
|
switch (status) {
|
|
84
54
|
case "working":
|
|
@@ -120,65 +90,33 @@ export function normalizeProgress(value: unknown): number {
|
|
|
120
90
|
return Math.min(100, Math.round(value));
|
|
121
91
|
}
|
|
122
92
|
|
|
123
|
-
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* sending it (correctly — a progress state persists until the program changes
|
|
128
|
-
* it), so this predicate is what stops that value from being painted underneath a
|
|
129
|
-
* status that came from somewhere else entirely. `input` and `done` come from the
|
|
130
|
-
* NOTIFICATION channel; `idle` is the absence of a progress state; `exited` and
|
|
131
|
-
* `crashed` are process facts. A percentage under any of those is a claim about
|
|
132
|
-
* a different channel than the one the reader is looking at. */
|
|
93
|
+
/** Whether the OSC 9;4 progress channel is speaking through this status; only
|
|
94
|
+
* these may show a bar. The server keeps sending the last percentage while the
|
|
95
|
+
* state persists, and `input`, `done`, `idle`, `exited` and `crashed` come from
|
|
96
|
+
* other channels, so a percentage under them is a claim about the wrong one. */
|
|
133
97
|
export function statusOwnsProgress(status: string): boolean {
|
|
134
98
|
return status === "working" || status === STATUS_FAILED || status === STATUS_WARNING;
|
|
135
99
|
}
|
|
136
100
|
|
|
137
|
-
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* 1. The program's own clear: OSC 9;4;0, or the abbreviated OSC 9;4, which the
|
|
145
|
-
* engine reports as percentage -1. That is the spec path, and the value
|
|
146
|
-
* arriving as PROGRESS_ABSENT is all it takes.
|
|
147
|
-
* 2. The status is not one the progress channel owns (statusOwnsProgress) —
|
|
148
|
-
* checked here. That covers a dead process, whose progress is meaningless
|
|
149
|
-
* and which nothing will ever clear, and equally a `done` or `input` latch,
|
|
150
|
-
* which is the notification channel talking. Measured: kiro-cli parks
|
|
151
|
-
* progress state 4 at its context-usage percentage when idle, so a finished
|
|
152
|
-
* turn used to paint a green done dot beside a 72% bar — a number about the
|
|
153
|
-
* context window, rendered as how far along the work was.
|
|
154
|
-
*
|
|
155
|
-
* There is deliberately NO third clear. 100% is not special: state 1 at 100 is a
|
|
156
|
-
* STATE that persists, and the progress channel carries no "done" signal at all,
|
|
157
|
-
* so a program that pins 100 and goes quiet keeps its bar — the same "persists
|
|
158
|
-
* until the program says otherwise" property the indeterminate state already has.
|
|
159
|
-
* There is also no TIMEOUT: a timer would assert a change the program never
|
|
160
|
-
* reported (Ghostty's ~15s one does, which is why it also asks emitters to
|
|
161
|
-
* refresh once a second; nothing here requires a keep-alive). */
|
|
101
|
+
/** The percentage a status may SHOW. Two things clear it: the program's own
|
|
102
|
+
* OSC 9;4;0 (arriving as PROGRESS_ABSENT) and a status the channel does not own
|
|
103
|
+
* (kiro-cli parks state 4 at its context-usage percentage when idle, which once
|
|
104
|
+
* painted a done dot beside a 72% bar). Deliberately no third clear: 100% is a
|
|
105
|
+
* STATE that persists, and a timeout would assert a change the program never
|
|
106
|
+
* reported. */
|
|
162
107
|
export function renderedProgress(status: string, progress: number): number {
|
|
163
108
|
return statusOwnsProgress(status) ? progress : PROGRESS_ABSENT;
|
|
164
109
|
}
|
|
165
110
|
|
|
166
111
|
// The secondary-activity vocabulary: a host-reported background activity that
|
|
167
|
-
// OUTLIVES the turn, orthogonal to the status above.
|
|
168
|
-
// reason the status vocabulary is — every consumer asks a predicate, so a state's
|
|
169
|
-
// MEANING has one home.
|
|
170
|
-
//
|
|
171
|
-
// A background task is running.
|
|
112
|
+
// OUTLIVES the turn, orthogonal to the status above.
|
|
172
113
|
const ACTIVITY_WORKING = "working";
|
|
173
114
|
// Stopped and resumable; nobody is being asked for anything.
|
|
174
115
|
const ACTIVITY_WAITING = "waiting";
|
|
175
|
-
// A background task is blocked on the user.
|
|
176
116
|
const ACTIVITY_INPUT = "input";
|
|
177
117
|
|
|
178
|
-
/**
|
|
179
|
-
*
|
|
180
|
-
* non-string, absent — is "": an unrecognised state must fail toward NO mark
|
|
181
|
-
* rather than toward a lit one whose meaning this build cannot name. */
|
|
118
|
+
/** The wire value in the closed set, or "" for no mark: an unrecognised state
|
|
119
|
+
* must fail toward NO mark rather than a lit one this build cannot name. */
|
|
182
120
|
export function normalizeActivity(value: unknown): string {
|
|
183
121
|
if (value === ACTIVITY_WORKING || value === ACTIVITY_WAITING || value === ACTIVITY_INPUT) {
|
|
184
122
|
return value;
|
|
@@ -186,10 +124,8 @@ export function normalizeActivity(value: unknown): string {
|
|
|
186
124
|
return "";
|
|
187
125
|
}
|
|
188
126
|
|
|
189
|
-
/**
|
|
190
|
-
*
|
|
191
|
-
* the state is non-empty, so 0 beside a live state is a server that does not
|
|
192
|
-
* count, which activityPhrase reads as one. */
|
|
127
|
+
/** The count off the wire; a non-integer, a negative or an absent value is 0,
|
|
128
|
+
* which activityPhrase reads as one beside a live state. */
|
|
193
129
|
export function normalizeActivityCount(value: unknown): number {
|
|
194
130
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
195
131
|
return 0;
|
|
@@ -197,13 +133,8 @@ export function normalizeActivityCount(value: unknown): number {
|
|
|
197
133
|
return value;
|
|
198
134
|
}
|
|
199
135
|
|
|
200
|
-
/**
|
|
201
|
-
*
|
|
202
|
-
* sighted user reads on hover and what a screen reader announces cannot
|
|
203
|
-
* disagree (the statusPhrase rule). "" for no mark.
|
|
204
|
-
*
|
|
205
|
-
* It cannot say "workflow": the secondary channel is generic, so the wording has
|
|
206
|
-
* to be true for whatever a host puts on it. */
|
|
136
|
+
/** The human wording for the mark, for BOTH its tooltip and the tab's accessible
|
|
137
|
+
* name; "" for no mark. It cannot say "workflow": the channel is generic. */
|
|
207
138
|
export function activityPhrase(state: string, count: number): string {
|
|
208
139
|
const normalized = normalizeActivity(state);
|
|
209
140
|
if (normalized === "") {
|
|
@@ -220,22 +151,13 @@ export function activityPhrase(state: string, count: number): string {
|
|
|
220
151
|
return `${subject} waiting for you`;
|
|
221
152
|
}
|
|
222
153
|
|
|
223
|
-
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* to a 100px floor. The bar answers "how far" for the eye; this answers it for a
|
|
231
|
-
* screen reader, which cannot see the bar at all (it is decoration with no text).
|
|
232
|
-
*
|
|
233
|
-
* Pass PROGRESS_ABSENT for progress to announce the state alone. Callers hand in
|
|
234
|
-
* renderedProgress's output, so a percentage the tab is not showing is not
|
|
235
|
-
* announced either.
|
|
236
|
-
*
|
|
237
|
-
* A RECORD rather than positional arguments: `label` and `status` are adjacent
|
|
238
|
-
* same-typed strings, so a transposition compiles. */
|
|
154
|
+
/** A tab's announced name: its label, the session's state, and the percentage
|
|
155
|
+
* when one is showing. The percentage is announced but never DRAWN as text (no
|
|
156
|
+
* emulator puts a number beside a tab label, and it costs width in a chip that
|
|
157
|
+
* shrinks to a 100px floor); the bar is decoration a screen reader cannot see.
|
|
158
|
+
* Pass renderedProgress's output, so an unshown percentage is not announced. A
|
|
159
|
+
* record rather than positional arguments, because `label` and `status` are
|
|
160
|
+
* adjacent same-typed strings. */
|
|
239
161
|
export function tabAccessibleName(v: {
|
|
240
162
|
readonly label: string;
|
|
241
163
|
readonly status: string;
|
|
@@ -251,44 +173,20 @@ export function tabAccessibleName(v: {
|
|
|
251
173
|
return activity === "" ? head : `${head} (${activity})`;
|
|
252
174
|
}
|
|
253
175
|
|
|
254
|
-
// localStorage key for the last active session id, so a page reload reopens the
|
|
255
|
-
// tab the user left on rather than always defaulting to the oldest one.
|
|
256
|
-
export const ACTIVE_TAB_KEY = "wt-active-session";
|
|
257
|
-
|
|
258
176
|
// One-time "swipe to switch" hint, remembered across loads.
|
|
259
177
|
export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
|
|
260
178
|
|
|
261
|
-
/** The
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* reported an error ("failed").
|
|
267
|
-
*
|
|
268
|
-
* `working` and `warning` are excluded because they are ONGOING and
|
|
269
|
-
* informational: an animated dot pinned to the switch button would nag with
|
|
270
|
-
* nothing to act on. `failed` was excluded on the same reasoning and that was
|
|
271
|
-
* wrong — OSC 9;4 state 2 is not ongoing, it is a result the program parked and
|
|
272
|
-
* does not revisit until its next state change, which is exactly the shape of a
|
|
273
|
-
* thing to tell the user about. It is also the half of "red" a viewer cares most
|
|
274
|
-
* about: `crashed` was already here, so an errored turn raised nothing while a
|
|
275
|
-
* dead process raised everything.
|
|
276
|
-
*
|
|
277
|
-
* Declared once here so the raise test, the acknowledgement store and every
|
|
278
|
-
* attention sink cannot disagree about which statuses are cue-worthy. */
|
|
179
|
+
/** The statuses that raise an attention cue: only states that WANT the user, like
|
|
180
|
+
* a notification. `working` and `warning` are ongoing and informational, so a
|
|
181
|
+
* dot for them would nag with nothing to act on; `failed` is a parked result the
|
|
182
|
+
* program does not revisit, so it belongs. Declared once so the raise test, the
|
|
183
|
+
* acknowledgement store and every attention sink cannot disagree. */
|
|
279
184
|
export type CueStatus = "input" | "done" | "crashed" | "failed";
|
|
280
185
|
|
|
281
|
-
/** Severity order
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* The order is read by the one surface that can show a single state (the browser
|
|
286
|
-
* tab icon), which paints the most severe unseen cue. That is a total order and
|
|
287
|
-
* so a defensible rule, unlike picking a session.
|
|
288
|
-
*
|
|
289
|
-
* A dead process outranks a reported error (the session is gone, not merely
|
|
290
|
-
* unhappy), an error outranks a request for input, and a finished turn is the
|
|
291
|
-
* mildest thing worth a cue at all. */
|
|
186
|
+
/** Severity order, most severe first, AND the complete set isCueStatus tests
|
|
187
|
+
* against. The browser tab icon can show one state and paints the most severe
|
|
188
|
+
* unseen cue: a dead process outranks a reported error, which outranks a request
|
|
189
|
+
* for input, and a finished turn is the mildest thing worth a cue. */
|
|
292
190
|
export const CUE_SEVERITY: readonly CueStatus[] = [STATUS_CRASHED, STATUS_FAILED, "input", "done"];
|
|
293
191
|
|
|
294
192
|
/** isCueStatus narrows a raw server status to a cue-worthy one. */
|
|
@@ -297,7 +195,7 @@ export function isCueStatus(status: string): status is CueStatus {
|
|
|
297
195
|
}
|
|
298
196
|
|
|
299
197
|
/** worseCue returns whichever of two cues is more severe; "" means no cue. */
|
|
300
|
-
|
|
198
|
+
function worseCue(a: CueStatus | "", b: CueStatus | ""): CueStatus | "" {
|
|
301
199
|
if (a === "") {
|
|
302
200
|
return b;
|
|
303
201
|
}
|
|
@@ -318,30 +216,17 @@ export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
|
|
|
318
216
|
return "alert";
|
|
319
217
|
}
|
|
320
218
|
|
|
321
|
-
/** The cue statuses
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* something the viewer has to be told AT ONCE, so none of them may wait on a task:
|
|
326
|
-
* `crashed` and `exited` mean the PROCESS is gone (and a task belonging to a dead
|
|
327
|
-
* session is dead too), `input` IS what the viewer is being pointed at whatever
|
|
328
|
-
* else the session is doing, and `failed` is an error the program declared, which
|
|
329
|
-
* does not become less urgent because something else is still running. On this
|
|
330
|
-
* engine's path `failed` is OSC 9;4 state 2 rather than a turn verdict, so it is
|
|
331
|
-
* deliberately NOT treated the way vibekit's settle probe treats its own `failed`
|
|
332
|
-
* (see web-terminal-engine.md). */
|
|
219
|
+
/** The cue statuses meaning the turn is OVER WITHOUT INCIDENT, the only ones a
|
|
220
|
+
* live background task may blank. `done` is the whole set: every other cue must
|
|
221
|
+
* reach the viewer AT ONCE, and `failed` here is OSC 9;4 state 2 rather than a
|
|
222
|
+
* turn verdict. */
|
|
333
223
|
const SETTLED_CUES: ReadonlySet<string> = new Set<CueStatus>(["done"]);
|
|
334
224
|
|
|
335
|
-
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* "" and NOT "idle", and the difference is load-bearing: "" means NO
|
|
341
|
-
* INFORMATION, so a caller must leave the wt-cue-seen map alone, where "idle" is
|
|
342
|
-
* a real non-cue state that FORGETS the acknowledgement. Blanking with "idle"
|
|
343
|
-
* would drop the dismissal and re-raise the cue from scratch the moment the task
|
|
344
|
-
* ended, even for a session the viewer had already visited. */
|
|
225
|
+
/** The status the CUE surfaces see: the session's own, unless a settled turn's
|
|
226
|
+
* background task is still running, in which case "". "" and NOT "idle": "" means
|
|
227
|
+
* NO INFORMATION, so the seen map is left alone, where "idle" is a real non-cue
|
|
228
|
+
* state that would forget the acknowledgement and re-raise the cue when the task
|
|
229
|
+
* ends. The status DOT keeps rendering the real state. */
|
|
345
230
|
export function foldedCueStatus(status: string, activity: string): string {
|
|
346
231
|
if (!SETTLED_CUES.has(status) || normalizeActivity(activity) === "") {
|
|
347
232
|
return status;
|
|
@@ -349,14 +234,9 @@ export function foldedCueStatus(status: string, activity: string): string {
|
|
|
349
234
|
return "";
|
|
350
235
|
}
|
|
351
236
|
|
|
352
|
-
/**
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* Note what it does NOT consider: whether the session is the active tab. A
|
|
357
|
-
* latch arriving on the active tab is acknowledged at arrival (applyStatus), so
|
|
358
|
-
* it is already absent from the unseen set by the time anything folds over it,
|
|
359
|
-
* and no caller needs a special case. */
|
|
237
|
+
/** Whether a session's CURRENT status is a cue this viewer has not acknowledged;
|
|
238
|
+
* the one predicate behind the raise and the attention count. A latch arriving
|
|
239
|
+
* on a shown tab is acknowledged at arrival, so no caller needs a special case. */
|
|
360
240
|
export function isUnseenCue(
|
|
361
241
|
status: string,
|
|
362
242
|
id: string,
|
|
@@ -365,39 +245,53 @@ export function isUnseenCue(
|
|
|
365
245
|
return isCueStatus(status) && seen.get(id) !== status;
|
|
366
246
|
}
|
|
367
247
|
|
|
368
|
-
/**
|
|
369
|
-
*
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
*
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
248
|
+
/** What the cue fold reads per tab; structural, so a caller passes its own tab
|
|
249
|
+
* objects. */
|
|
250
|
+
export interface CueCandidate {
|
|
251
|
+
readonly id: string;
|
|
252
|
+
readonly status: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The unseen cues over the tab list: a COUNT for the title and the badge, one
|
|
256
|
+
* WORST for the icon. A count is set-valued and needs no rule for choosing among
|
|
257
|
+
* sessions; severity is a total order, so the icon's choice is not arbitrary.
|
|
258
|
+
* Neither names a session, the standing constraint on a page-wide surface. */
|
|
259
|
+
export interface CueSummary {
|
|
260
|
+
readonly count: number;
|
|
261
|
+
readonly worst: CueStatus | "";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Fold the tab list and this viewer's acknowledgements into the one summary
|
|
265
|
+
* every attention surface renders. */
|
|
266
|
+
export function summarizeCues(
|
|
267
|
+
tabs: readonly CueCandidate[],
|
|
268
|
+
seen: ReadonlyMap<string, CueStatus>,
|
|
269
|
+
): CueSummary {
|
|
270
|
+
let count = 0;
|
|
271
|
+
let worst: CueStatus | "" = "";
|
|
272
|
+
for (const tab of tabs) {
|
|
273
|
+
if (!isUnseenCue(tab.status, tab.id, seen)) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
count += 1;
|
|
277
|
+
worst = worseCue(worst, tab.status);
|
|
278
|
+
}
|
|
279
|
+
return { count, worst };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** localStorage key for the cues this viewer has SEEN: session id -> the
|
|
283
|
+
* acknowledged status. Remembered because `input` and `done` are latched on the
|
|
284
|
+
* server and re-delivered in every snapshot, so a dismissed dot came back on
|
|
285
|
+
* every reload; client-side because "seen" is the VIEWER's, not the session's;
|
|
286
|
+
* per session because several background tabs can hold a latch at once. */
|
|
389
287
|
export const CUE_SEEN_KEY = "wt-cue-seen";
|
|
390
288
|
|
|
391
|
-
/** Bound on the acknowledgement map
|
|
392
|
-
* a real one is nowhere near this, and a corrupted or hostile stored value
|
|
289
|
+
/** Bound on the acknowledgement map, so a corrupted or hostile stored value
|
|
393
290
|
* cannot make the restore path do unbounded work. */
|
|
394
291
|
export const MAX_PERSISTED_CUE_SEEN = 200;
|
|
395
292
|
|
|
396
|
-
/**
|
|
397
|
-
*
|
|
398
|
-
* acknowledgement only re-lights a dot the user can dismiss again, so degrading
|
|
399
|
-
* to "nothing acknowledged" is always safe. Pure, so it is testable without a
|
|
400
|
-
* storage backend; the caller owns the localStorage read and its try/catch. */
|
|
293
|
+
/** Read stored acknowledgements into a clean map, dropping anything untrusted: a
|
|
294
|
+
* lost acknowledgement only re-lights a dot the user can dismiss again. */
|
|
401
295
|
export function parseCueSeen(raw: string | null): Map<string, CueStatus> {
|
|
402
296
|
const out = new Map<string, CueStatus>();
|
|
403
297
|
if (raw === null || raw === "") {
|
|
@@ -452,41 +346,21 @@ export interface TabOrderKey {
|
|
|
452
346
|
* is a real position at the front of the strip. */
|
|
453
347
|
const ORDER_ABSENT = Number.MAX_SAFE_INTEGER;
|
|
454
348
|
|
|
455
|
-
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
* Milliseconds, deliberately, against a wire value carrying nanoseconds: the
|
|
462
|
-
* server's own tiebreak is the id, this one's is too, and the pair therefore
|
|
463
|
-
* agree on every input a human can produce. Comparing the STRINGS instead would
|
|
464
|
-
* be wrong, not merely coarse — Go's RFC 3339 encoding drops trailing zeros
|
|
465
|
-
* from the fraction, so ".15" sorts before ".1" lexically. */
|
|
349
|
+
/** A wire createdAt as a number. An unparseable value sorts LAST: the end reads
|
|
350
|
+
* as "new", where the head would rewrite the top of the strip on a value the
|
|
351
|
+
* client failed to read. Milliseconds against a nanosecond wire value, because
|
|
352
|
+
* the id is the tiebreak on both sides; comparing the STRINGS would be wrong,
|
|
353
|
+
* since Go's RFC 3339 encoding drops trailing zeros and ".15" sorts before ".1". */
|
|
466
354
|
function createdMillis(raw: string): number {
|
|
467
355
|
const when = Date.parse(raw);
|
|
468
356
|
return Number.isNaN(when) ? Number.MAX_SAFE_INTEGER : when;
|
|
469
357
|
}
|
|
470
358
|
|
|
471
|
-
/**
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
* history of this server still opens on the arrangement its owner chose. This
|
|
477
|
-
* replaced a per-browser `localStorage` arrangement, which by construction could
|
|
478
|
-
* not travel and which left a second device with no arrangement at all.
|
|
479
|
-
*
|
|
480
|
-
* Age and id remain, and each answers a case the position cannot: age orders
|
|
481
|
-
* every session when the server keeps no order (an older engine sends no field,
|
|
482
|
-
* so all positions are absent and creation order is the honest fallback), and
|
|
483
|
-
* the id makes the order TOTAL, so two sessions created inside one millisecond
|
|
484
|
-
* cannot swap places between two runs.
|
|
485
|
-
*
|
|
486
|
-
* Age is read from the `createdAt` field rather than from the wire SEQUENCE on
|
|
487
|
-
* purpose. Sessions arrive from two racing sources — the status stream's
|
|
488
|
-
* snapshot on open, and the bootstrap's GET /api/sessions — so a client that
|
|
489
|
-
* merges them sees neither source's order, however well each source sorted. */
|
|
359
|
+
/** The total order the strip is built in: the server's shared position, then
|
|
360
|
+
* age, then id. Age covers a server that keeps no order, and the id makes the
|
|
361
|
+
* order TOTAL. Age is read from `createdAt` rather than the wire SEQUENCE
|
|
362
|
+
* because sessions arrive from two racing sources (the stream's snapshot and
|
|
363
|
+
* the bootstrap's list), so a merged client sees neither source's order. */
|
|
490
364
|
export function compareTabOrder(a: TabOrderKey, b: TabOrderKey): number {
|
|
491
365
|
const posA = a.order ?? ORDER_ABSENT;
|
|
492
366
|
const posB = b.order ?? ORDER_ABSENT;
|
|
@@ -504,14 +378,9 @@ export function compareTabOrder(a: TabOrderKey, b: TabOrderKey): number {
|
|
|
504
378
|
return a.id < b.id ? -1 : 1;
|
|
505
379
|
}
|
|
506
380
|
|
|
507
|
-
/**
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
* This is what makes the strip independent of the order tabs ARRIVE in. Each
|
|
512
|
-
* insertion goes before the first tab that outranks the new one, which keeps a
|
|
513
|
-
* list built from an empty strip sorted at every step, so all arrival orders of
|
|
514
|
-
* one session set converge on the same strip. */
|
|
381
|
+
/** Where `incoming` belongs in `current` under compareTabOrder: before the first
|
|
382
|
+
* tab that outranks it, else the end, so every arrival order of one session set
|
|
383
|
+
* converges on the same strip. */
|
|
515
384
|
export function orderedInsertIndex(current: readonly TabOrderKey[], incoming: TabOrderKey): number {
|
|
516
385
|
for (let i = 0; i < current.length; i++) {
|
|
517
386
|
const other = current[i];
|
|
@@ -524,20 +393,15 @@ export function orderedInsertIndex(current: readonly TabOrderKey[], incoming: Ta
|
|
|
524
393
|
|
|
525
394
|
export interface Tab {
|
|
526
395
|
id: string;
|
|
527
|
-
/**
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* listing, and dropping it would cascade into a duplicate replacement
|
|
533
|
-
* session (the boot double-create bug). */
|
|
396
|
+
/** The local mutation epoch at which this tab was adopted. The list reconcile
|
|
397
|
+
* snapshots the counter BEFORE its GET and drops an unlisted tab only when
|
|
398
|
+
* the tab predates the snapshot: a tab born while the list was in flight is
|
|
399
|
+
* invisible to that stale listing, and dropping it cascaded into a duplicate
|
|
400
|
+
* replacement session. */
|
|
534
401
|
born: number;
|
|
535
|
-
/** The title the SERVER resolved
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
* Possibly empty only against an engine that has none of those. The displayed
|
|
539
|
-
* label adds a numbered fallback and de-duplication (see relabelAll in
|
|
540
|
-
* index.ts). */
|
|
402
|
+
/** The title the SERVER resolved: the pinned name, else the program's OSC 0/2
|
|
403
|
+
* title, else a host-pushed title, else the engine's own inference. The
|
|
404
|
+
* displayed label adds a numbered fallback and de-duplication. */
|
|
541
405
|
title: string;
|
|
542
406
|
/** The computed, de-duplicated label actually shown in the chrome. */
|
|
543
407
|
display: string;
|
|
@@ -569,19 +433,12 @@ export interface Tab {
|
|
|
569
433
|
/** How many sources produced that state, 0 when the server does not count. */
|
|
570
434
|
activityCount: number;
|
|
571
435
|
aria: TabHandle;
|
|
572
|
-
/** This tab's saved reading position
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
* NOT a pixel scrollTop, which is what this was until 2026-08: a rebuild has
|
|
580
|
-
* built only ~301 of up to 5000 rows when the restore lands, so the browser
|
|
581
|
-
* clamped the offset away, and it stopped meaning the same line as soon as
|
|
582
|
-
* the tab's session produced output while backgrounded
|
|
583
|
-
* (engine docs/scroll-position-fidelity.md §1.1). In memory only, like
|
|
584
|
-
* before: a reload starts every tab following. */
|
|
436
|
+
/** This tab's saved reading position as captureViewMemory() returned it when
|
|
437
|
+
* the tab was last left; null (follow the tail) for a tab never viewed or left
|
|
438
|
+
* on the alternate screen. NOT a pixel scrollTop: a rebuild has built only
|
|
439
|
+
* ~301 of up to 5000 rows when the restore lands, so the browser clamped the
|
|
440
|
+
* offset away, and it stopped meaning the same line once the session produced
|
|
441
|
+
* output while backgrounded. In memory only. */
|
|
585
442
|
view: ViewMemory | null;
|
|
586
443
|
/** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
|
|
587
444
|
* Its activity dot is shown only while true; a session that never reports
|
|
@@ -600,89 +457,134 @@ export interface Tab {
|
|
|
600
457
|
nameSeq: number;
|
|
601
458
|
}
|
|
602
459
|
|
|
603
|
-
/**
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
* a title the host pushed through SetSessionTitle, then its own
|
|
609
|
-
* foreground-process/cwd inference). A client that re-implemented that ladder could only disagree with
|
|
610
|
-
* the server and with every other client.
|
|
611
|
-
*
|
|
612
|
-
* The pin is still checked here, even though the server also folds it into
|
|
613
|
-
* `title`, so a rename paints immediately instead of waiting for the round trip.
|
|
614
|
-
*
|
|
615
|
-
* fallback=true marks the "New tab" case so relabelAll leaves untitled tabs
|
|
616
|
-
* unnumbered. */
|
|
460
|
+
/** A tab's label before de-duplication: the pinned name, else the title the
|
|
461
|
+
* SERVER resolved. Only two rungs, because the engine folds every automatic
|
|
462
|
+
* source into `title` and a client re-implementing that ladder could only
|
|
463
|
+
* disagree with it. The pin is still read here so a rename paints before the
|
|
464
|
+
* round trip. fallback=true marks "New tab" so relabelAll leaves it unnumbered. */
|
|
617
465
|
export function baseLabel(tab: Tab): { text: string; fallback: boolean } {
|
|
618
466
|
const real = pinnedNameOf(tab) || tab.title.trim();
|
|
619
467
|
return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
|
|
620
468
|
}
|
|
621
469
|
|
|
622
|
-
/**
|
|
623
|
-
*
|
|
624
|
-
* enabled state can never disagree about whether a tab is pinned. */
|
|
470
|
+
/** A tab's pin, trimmed, with a whitespace-only value reading as absent; one
|
|
471
|
+
* definition so baseLabel and the menu's enabled state cannot disagree. */
|
|
625
472
|
function pinnedNameOf(tab: Tab): string {
|
|
626
473
|
return tab.pinnedTitle?.trim() ?? "";
|
|
627
474
|
}
|
|
628
475
|
|
|
629
|
-
/**
|
|
630
|
-
*
|
|
476
|
+
/** Whether a tab carries a user-set name, which gates the tab menu's "use the
|
|
477
|
+
* automatic name" item. */
|
|
631
478
|
export function hasPinnedName(tab: Tab): boolean {
|
|
632
479
|
return pinnedNameOf(tab) !== "";
|
|
633
480
|
}
|
|
634
481
|
|
|
635
|
-
/** The session
|
|
636
|
-
*
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
482
|
+
/** The realm a session API runs in: the mounted window's `fetch`, its abort
|
|
483
|
+
* signals and its clock, never the importing page's. */
|
|
484
|
+
export interface SessionAPIRealm {
|
|
485
|
+
readonly fetch: typeof fetch;
|
|
486
|
+
readonly AbortSignal: typeof AbortSignal;
|
|
487
|
+
readonly Date: typeof Date;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The session REST client, bound to an apiBase. Every call is timeout-bounded:
|
|
491
|
+
* fetch has no default timeout, and a stalled-but-open server would leave a
|
|
492
|
+
* bootstrap await pending forever. */
|
|
641
493
|
export interface SessionAPI {
|
|
642
494
|
list(): Promise<SessionInfo[]>;
|
|
643
495
|
create(): Promise<SessionInfo>;
|
|
644
496
|
close(id: string): Promise<void>;
|
|
645
|
-
/** Set the user's pinned name
|
|
646
|
-
*
|
|
647
|
-
* correct until the next reload, so the caller must be able to surface it. */
|
|
497
|
+
/** Set the user's pinned name. THROWS on failure: a rename that silently did
|
|
498
|
+
* not persist looks correct until the next reload. */
|
|
648
499
|
setPinnedTitle(id: string, title: string): Promise<void>;
|
|
649
|
-
/** Remove a session's pinned name
|
|
650
|
-
* sources. Throws on failure, for the same reason as setPinnedTitle. */
|
|
500
|
+
/** Remove a session's pinned name. Throws on failure, as setPinnedTitle does. */
|
|
651
501
|
clearPinnedTitle(id: string): Promise<void>;
|
|
652
|
-
/** Replace the display order every viewer of this server shares
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
* session set is not the one the caller listed, so this client has not yet
|
|
657
|
-
* seen a session created or closed elsewhere; the caller answers it by
|
|
658
|
-
* re-listing and sending again, not by telling the user. Any other status is a
|
|
659
|
-
* genuine failure of a reorder the user performed.
|
|
660
|
-
*
|
|
661
|
-
* Absent from a server before engine 3.9.0, where it answers 404. */
|
|
502
|
+
/** Replace the display order every viewer of this server shares. THROWS on
|
|
503
|
+
* failure, and the status is the point: a 409 means the caller's session set
|
|
504
|
+
* is stale, answered by re-listing and sending again, not by telling the user.
|
|
505
|
+
* A server before the route answers 404. */
|
|
662
506
|
setOrder(ids: readonly string[]): Promise<void>;
|
|
507
|
+
/** The pane layout every viewer of this server shares. Null on 404, a server
|
|
508
|
+
* before the route, against which the layout runs unpersisted; a throw on any
|
|
509
|
+
* other non-2xx. */
|
|
510
|
+
getLayout(): Promise<PaneLayout | null>;
|
|
511
|
+
/** Replace the shared pane layout. Throws on non-2xx as `setOrder` does: a 409
|
|
512
|
+
* means a side names a session that is not live. */
|
|
513
|
+
setLayout(layout: PaneLayout): Promise<void>;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The server's pane-layout record: which session each pane shows, the handle's
|
|
517
|
+
* left share, the pane that receives typing (its session is the active tab), and
|
|
518
|
+
* whether the split is open. When `open` is false `left` is the one shown session. */
|
|
519
|
+
export interface PaneLayout {
|
|
520
|
+
readonly left: string | null;
|
|
521
|
+
readonly right: string | null;
|
|
522
|
+
readonly handle: number;
|
|
523
|
+
readonly selected: PaneSide;
|
|
524
|
+
readonly open: boolean;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** A record read from the wire is a claim until its shape is checked. */
|
|
528
|
+
function readPaneLayout(body: unknown): PaneLayout | null {
|
|
529
|
+
if (typeof body !== "object" || body === null) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
const rec = body as Record<string, unknown>;
|
|
533
|
+
const side = (v: unknown): string | null | undefined =>
|
|
534
|
+
v === null ? null : typeof v === "string" ? v : undefined;
|
|
535
|
+
const left = side(rec["left"]);
|
|
536
|
+
const right = side(rec["right"]);
|
|
537
|
+
const handle = rec["handle"];
|
|
538
|
+
const selected = rec["selected"];
|
|
539
|
+
const open = rec["open"];
|
|
540
|
+
if (
|
|
541
|
+
left === undefined ||
|
|
542
|
+
right === undefined ||
|
|
543
|
+
typeof handle !== "number" ||
|
|
544
|
+
!Number.isFinite(handle) ||
|
|
545
|
+
handle < 0 ||
|
|
546
|
+
handle > 1 ||
|
|
547
|
+
(selected !== "left" && selected !== "right") ||
|
|
548
|
+
typeof open !== "boolean"
|
|
549
|
+
) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
return isConsistentLayout({ left, right, handle, selected, open })
|
|
553
|
+
? { left, right, handle, selected, open }
|
|
554
|
+
: null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The server's own rules for a well-formed record: a closed split shows only its
|
|
558
|
+
* left side and selects it, one session is never on both sides, and an open split
|
|
559
|
+
* with one shown side selects that side. */
|
|
560
|
+
function isConsistentLayout(l: PaneLayout): boolean {
|
|
561
|
+
if (!l.open) {
|
|
562
|
+
return l.right === null && l.selected === "left";
|
|
563
|
+
}
|
|
564
|
+
if (l.left !== null && l.right !== null) {
|
|
565
|
+
return l.left !== l.right;
|
|
566
|
+
}
|
|
567
|
+
if (l.left !== null) {
|
|
568
|
+
return l.selected === "left";
|
|
569
|
+
}
|
|
570
|
+
if (l.right !== null) {
|
|
571
|
+
return l.selected === "right";
|
|
572
|
+
}
|
|
573
|
+
return true;
|
|
663
574
|
}
|
|
664
575
|
|
|
665
576
|
const SESSION_API_TIMEOUT_MS = 15000;
|
|
666
577
|
|
|
667
|
-
/** A non-2xx response from the session API, carrying what the server
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
* session creation: web-terminal-kiro answers 503 with `Retry-After: 5` and a
|
|
672
|
-
* body message while its tool engine installs the manifest's tools on first
|
|
673
|
-
* boot. Callers that only saw an `Error` could not tell that apart from a 500,
|
|
674
|
-
* could not honour the retry hint, and could not repeat the server's
|
|
675
|
-
* explanation — so the page read as broken while the server was deliberately
|
|
676
|
-
* waiting. Everything needed to do better is on this error. */
|
|
578
|
+
/** A non-2xx response from the session API, carrying what the server said. A
|
|
579
|
+
* host may TEMPORARILY refuse session creation (a 503 with `Retry-After` and a
|
|
580
|
+
* body message while it installs tools on first boot), and a caller that only
|
|
581
|
+
* saw an `Error` read that as a broken page. */
|
|
677
582
|
export class SessionAPIError extends Error {
|
|
678
583
|
/** HTTP status, so a caller can branch on 503 (retry) vs 429 vs 5xx. */
|
|
679
584
|
readonly status: number;
|
|
680
|
-
/** Retry-After in milliseconds, or undefined
|
|
681
|
-
* hint. */
|
|
585
|
+
/** Retry-After in milliseconds, or undefined without a usable hint. */
|
|
682
586
|
readonly retryAfterMs: number | undefined;
|
|
683
|
-
/** The human-readable message
|
|
684
|
-
* one (webhttp's `error` field, or `message` elsewhere), so the host's own
|
|
685
|
-
* explanation can reach the user verbatim. Length-capped because it is
|
|
587
|
+
/** The error envelope's human-readable message, length-capped because it is
|
|
686
588
|
* server-controlled text destined for UI chrome. */
|
|
687
589
|
readonly serverMessage: string | undefined;
|
|
688
590
|
|
|
@@ -702,7 +604,7 @@ const SERVER_MESSAGE_MAX_CHARS = 120;
|
|
|
702
604
|
* missing or unparseable value. Clamped so a buggy or hostile header cannot
|
|
703
605
|
* park the UI for hours, and floored at 0 so a date already in the past retries
|
|
704
606
|
* immediately rather than never. */
|
|
705
|
-
function parseRetryAfter(header: string | null): number | undefined {
|
|
607
|
+
function parseRetryAfter(header: string | null, now: number): number | undefined {
|
|
706
608
|
if (header === null) {
|
|
707
609
|
return undefined;
|
|
708
610
|
}
|
|
@@ -717,18 +619,12 @@ function parseRetryAfter(header: string | null): number | undefined {
|
|
|
717
619
|
if (Number.isNaN(when)) {
|
|
718
620
|
return undefined;
|
|
719
621
|
}
|
|
720
|
-
return Math.min(Math.max(when -
|
|
622
|
+
return Math.min(Math.max(when - now, 0), RETRY_AFTER_MAX_MS);
|
|
721
623
|
}
|
|
722
624
|
|
|
723
|
-
/**
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
* here is simply "no server message".
|
|
727
|
-
*
|
|
728
|
-
* Two field names are accepted. `error` is the field the first-party Go envelope
|
|
729
|
-
* writes (webhttp's ErrorResponse, `json:"error"`), which is what every server in
|
|
730
|
-
* this family returns; `message` is the common alternative elsewhere. Preferring
|
|
731
|
-
* `error` keeps the family's own hosts authoritative. */
|
|
625
|
+
/** The error envelope's human-readable message, or undefined on any failure: the
|
|
626
|
+
* status and retry hint matter more than the prose. `error` is the first-party
|
|
627
|
+
* envelope's field and is preferred; `message` is the common alternative. */
|
|
732
628
|
async function readServerMessage(r: Response): Promise<string | undefined> {
|
|
733
629
|
try {
|
|
734
630
|
const body: unknown = await r.json();
|
|
@@ -748,40 +644,43 @@ async function readServerMessage(r: Response): Promise<string | undefined> {
|
|
|
748
644
|
}
|
|
749
645
|
|
|
750
646
|
/** Build the error for a failed session-API response. */
|
|
751
|
-
async function
|
|
647
|
+
async function failedResponse(
|
|
648
|
+
operation: string,
|
|
649
|
+
r: Response,
|
|
650
|
+
now: number,
|
|
651
|
+
): Promise<SessionAPIError> {
|
|
752
652
|
return new SessionAPIError(
|
|
753
653
|
operation,
|
|
754
654
|
r.status,
|
|
755
|
-
parseRetryAfter(r.headers.get("Retry-After")),
|
|
655
|
+
parseRetryAfter(r.headers.get("Retry-After"), now),
|
|
756
656
|
await readServerMessage(r),
|
|
757
657
|
);
|
|
758
658
|
}
|
|
759
659
|
|
|
760
|
-
export function createSessionAPI(apiBase: string): SessionAPI {
|
|
660
|
+
export function createSessionAPI(apiBase: string, realm: SessionAPIRealm): SessionAPI {
|
|
661
|
+
const request = (url: string, init: RequestInit): Promise<Response> =>
|
|
662
|
+
realm.fetch(url, { ...init, signal: realm.AbortSignal.timeout(SESSION_API_TIMEOUT_MS) });
|
|
663
|
+
const sessionError = (operation: string, r: Response): Promise<SessionAPIError> =>
|
|
664
|
+
failedResponse(operation, r, realm.Date.now());
|
|
761
665
|
return {
|
|
762
666
|
async list(): Promise<SessionInfo[]> {
|
|
763
|
-
const r = await
|
|
667
|
+
const r = await request(apiBase, {
|
|
764
668
|
headers: { Accept: "application/json" },
|
|
765
|
-
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
766
669
|
});
|
|
767
670
|
if (!r.ok) {
|
|
768
671
|
throw await sessionError("list", r);
|
|
769
672
|
}
|
|
770
673
|
const data: unknown = await r.json();
|
|
771
|
-
// A 200 with a non-array body
|
|
772
|
-
//
|
|
773
|
-
// bootstrap's `sessions.length` / `for...of` (or the poll's `list.map`)
|
|
774
|
-
// uncaught. Reject a non-array so the callers' existing catch paths
|
|
775
|
-
// recover (bootstrap -> [], poll -> skip the tick).
|
|
674
|
+
// A 200 with a non-array body (a proxy error object, a nil slice marshaled
|
|
675
|
+
// as `null`) is a throw the callers' catch paths already recover from.
|
|
776
676
|
if (!Array.isArray(data)) {
|
|
777
677
|
throw new Error("web-terminal-ui: session list returned a non-array body");
|
|
778
678
|
}
|
|
779
679
|
return data as SessionInfo[];
|
|
780
680
|
},
|
|
781
681
|
async create(): Promise<SessionInfo> {
|
|
782
|
-
const r = await
|
|
682
|
+
const r = await request(apiBase, {
|
|
783
683
|
method: "POST",
|
|
784
|
-
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
785
684
|
});
|
|
786
685
|
if (!r.ok) {
|
|
787
686
|
throw await sessionError("create", r);
|
|
@@ -789,9 +688,8 @@ export function createSessionAPI(apiBase: string): SessionAPI {
|
|
|
789
688
|
return (await r.json()) as SessionInfo;
|
|
790
689
|
},
|
|
791
690
|
async close(id: string): Promise<void> {
|
|
792
|
-
const r = await
|
|
691
|
+
const r = await request(`${apiBase}/${encodeURIComponent(id)}`, {
|
|
793
692
|
method: "DELETE",
|
|
794
|
-
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
795
693
|
});
|
|
796
694
|
if (!r.ok) {
|
|
797
695
|
throw await sessionError("close", r);
|
|
@@ -800,20 +698,18 @@ export function createSessionAPI(apiBase: string): SessionAPI {
|
|
|
800
698
|
// The user's pinned name. Not best-effort: the caller shows a failure and
|
|
801
699
|
// rolls the optimistic label back, so both of these propagate.
|
|
802
700
|
async setPinnedTitle(id: string, title: string): Promise<void> {
|
|
803
|
-
const r = await
|
|
701
|
+
const r = await request(`${apiBase}/${encodeURIComponent(id)}/pinned-title`, {
|
|
804
702
|
method: "PUT",
|
|
805
703
|
headers: { "Content-Type": "application/json" },
|
|
806
704
|
body: JSON.stringify({ title }),
|
|
807
|
-
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
808
705
|
});
|
|
809
706
|
if (!r.ok) {
|
|
810
707
|
throw await sessionError("set pinned title", r);
|
|
811
708
|
}
|
|
812
709
|
},
|
|
813
710
|
async clearPinnedTitle(id: string): Promise<void> {
|
|
814
|
-
const r = await
|
|
711
|
+
const r = await request(`${apiBase}/${encodeURIComponent(id)}/pinned-title`, {
|
|
815
712
|
method: "DELETE",
|
|
816
|
-
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
817
713
|
});
|
|
818
714
|
if (!r.ok) {
|
|
819
715
|
throw await sessionError("clear pinned title", r);
|
|
@@ -822,16 +718,41 @@ export function createSessionAPI(apiBase: string): SessionAPI {
|
|
|
822
718
|
// The shared display order. The path is a literal segment rather than an id,
|
|
823
719
|
// so nothing is interpolated into it.
|
|
824
720
|
async setOrder(ids: readonly string[]): Promise<void> {
|
|
825
|
-
const r = await
|
|
721
|
+
const r = await request(`${apiBase}/order`, {
|
|
826
722
|
method: "PUT",
|
|
827
723
|
headers: { "Content-Type": "application/json" },
|
|
828
724
|
body: JSON.stringify({ order: ids }),
|
|
829
|
-
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
830
725
|
});
|
|
831
726
|
if (!r.ok) {
|
|
832
727
|
throw await sessionError("set order", r);
|
|
833
728
|
}
|
|
834
729
|
},
|
|
730
|
+
async getLayout(): Promise<PaneLayout | null> {
|
|
731
|
+
const r = await request(`${apiBase}/layout`, {
|
|
732
|
+
headers: { Accept: "application/json" },
|
|
733
|
+
});
|
|
734
|
+
if (r.status === 404) {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
if (!r.ok) {
|
|
738
|
+
throw await sessionError("get layout", r);
|
|
739
|
+
}
|
|
740
|
+
const layout = readPaneLayout(await r.json());
|
|
741
|
+
if (layout === null) {
|
|
742
|
+
throw new Error("web-terminal-ui: session layout returned a malformed body");
|
|
743
|
+
}
|
|
744
|
+
return layout;
|
|
745
|
+
},
|
|
746
|
+
async setLayout(layout: PaneLayout): Promise<void> {
|
|
747
|
+
const r = await request(`${apiBase}/layout`, {
|
|
748
|
+
method: "PUT",
|
|
749
|
+
headers: { "Content-Type": "application/json" },
|
|
750
|
+
body: JSON.stringify(layout),
|
|
751
|
+
});
|
|
752
|
+
if (!r.ok) {
|
|
753
|
+
throw await sessionError("set layout", r);
|
|
754
|
+
}
|
|
755
|
+
},
|
|
835
756
|
};
|
|
836
757
|
}
|
|
837
758
|
|
|
@@ -848,11 +769,14 @@ export interface Tombstones {
|
|
|
848
769
|
|
|
849
770
|
const CLOSE_TOMBSTONE_MS = 15000;
|
|
850
771
|
|
|
851
|
-
export function createTombstones(
|
|
772
|
+
export function createTombstones(
|
|
773
|
+
clock: () => number,
|
|
774
|
+
ttlMs: number = CLOSE_TOMBSTONE_MS,
|
|
775
|
+
): Tombstones {
|
|
852
776
|
const recentlyClosed = new Map<string, number>();
|
|
853
777
|
return {
|
|
854
778
|
add(id: string): void {
|
|
855
|
-
const now =
|
|
779
|
+
const now = clock();
|
|
856
780
|
// Sweep entries whose window already elapsed (active() treats them as
|
|
857
781
|
// untombstoned anyway) so the map cannot grow without bound over a long
|
|
858
782
|
// session of opens/closes; then record this close.
|
|
@@ -868,7 +792,7 @@ export function createTombstones(ttlMs: number = CLOSE_TOMBSTONE_MS): Tombstones
|
|
|
868
792
|
if (closedAt === undefined) {
|
|
869
793
|
return false;
|
|
870
794
|
}
|
|
871
|
-
if (
|
|
795
|
+
if (clock() - closedAt < ttlMs) {
|
|
872
796
|
return true;
|
|
873
797
|
}
|
|
874
798
|
recentlyClosed.delete(id);
|
|
@@ -877,21 +801,15 @@ export function createTombstones(ttlMs: number = CLOSE_TOMBSTONE_MS): Tombstones
|
|
|
877
801
|
};
|
|
878
802
|
}
|
|
879
803
|
|
|
880
|
-
/**
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
* a naive `slice` would count UTF-16 code units and could cut a surrogate pair in
|
|
885
|
-
* half, sending a lone surrogate to the server. Neither bound is a count of
|
|
886
|
-
* user-perceived characters (a grapheme cluster is neither, per UAX #29); both
|
|
887
|
-
* are defensive limits, not display promises. */
|
|
804
|
+
/** The bound on a user-typed tab name, mirroring the engine's pinned-title cap so
|
|
805
|
+
* the field cannot accept text the server would silently truncate. Counted in
|
|
806
|
+
* CODE POINTS, matching the server's runes: a naive `slice` counts UTF-16 units
|
|
807
|
+
* and could send a lone surrogate. */
|
|
888
808
|
export const MAX_PINNED_NAME = 128;
|
|
889
809
|
|
|
890
|
-
/**
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
* point. Applied client-side rather than trusting the server's round-trip, so
|
|
894
|
-
* the optimistic label matches what the server will store. */
|
|
810
|
+
/** A user-typed tab name cleaned before display and send: control characters and
|
|
811
|
+
* DEL out (CWE-117), trimmed, bounded by code point. Client-side, so the
|
|
812
|
+
* optimistic label matches what the server will store. */
|
|
895
813
|
export function sanitizePinnedName(s: string): string {
|
|
896
814
|
const kept: string[] = [];
|
|
897
815
|
for (const ch of s) {
|