@cplieger/web-terminal-ui 7.3.2 → 8.0.1

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.
Files changed (50) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +114 -18
  3. package/THIRD_PARTY_NOTICES.md +7 -0
  4. package/css/30-tabs.css +21 -0
  5. package/css/31-switcher.css +15 -0
  6. package/css/32-split.css +176 -0
  7. package/css/MANIFEST +1 -0
  8. package/css/MANIFEST.tabbed +1 -0
  9. package/package.json +15 -12
  10. package/src/composition.ts +165 -237
  11. package/src/features/activity-monitor.ts +20 -55
  12. package/src/features/animations.ts +12 -30
  13. package/src/features/clipboard.ts +36 -64
  14. package/src/features/connection-banner.ts +10 -19
  15. package/src/features/context-menu.ts +70 -145
  16. package/src/features/dom.ts +44 -54
  17. package/src/features/menu-position.ts +24 -28
  18. package/src/features/mobile-toolbar.ts +32 -62
  19. package/src/features/predictive-echo.ts +15 -49
  20. package/src/features/scroll-to-bottom.ts +12 -24
  21. package/src/features/tabs/index.ts +1398 -1806
  22. package/src/features/tabs/ink-centre.ts +45 -54
  23. package/src/features/tabs/model.ts +301 -383
  24. package/src/features/tabs/strip.ts +45 -97
  25. package/src/features/tabs/switcher.ts +70 -48
  26. package/src/index.ts +13 -13
  27. package/src/kernel/a11y.ts +106 -27
  28. package/src/kernel/attention.ts +157 -0
  29. package/src/kernel/conn-state.ts +28 -22
  30. package/src/kernel/fatal.ts +76 -0
  31. package/src/kernel/feature-host.ts +136 -0
  32. package/src/kernel/gesture.ts +9 -13
  33. package/src/kernel/kernel.ts +57 -2139
  34. package/src/kernel/layout-policy.ts +44 -0
  35. package/src/kernel/loading-status.ts +30 -88
  36. package/src/kernel/navigation.ts +11 -17
  37. package/src/kernel/notify.ts +158 -0
  38. package/src/kernel/pane.ts +1415 -0
  39. package/src/kernel/realm.ts +11 -0
  40. package/src/kernel/regions.ts +8 -11
  41. package/src/kernel/scrollback.ts +88 -168
  42. package/src/kernel/selection.ts +25 -0
  43. package/src/kernel/shell.ts +1434 -0
  44. package/src/kernel/split-handle.ts +280 -0
  45. package/src/kernel/status-share.ts +100 -0
  46. package/src/kernel/types.ts +457 -441
  47. package/src/predict.ts +144 -170
  48. package/src/viewport.ts +124 -197
  49. package/src/features/tabs/attention.ts +0 -250
  50. package/src/features/tabs/notify.ts +0 -265
@@ -1,84 +1,54 @@
1
- // tabs/model.ts — the session MODEL half of the tabs feature: the wire type,
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 session's REST wire
19
- * shape plus the percentage that exists only on the status STREAM
20
- * (SessionStatus.progressValue). Both status sources flow through one code path
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, module-private on purpose: every consumer asks one of
29
- // the predicates below (isEndedStatus / statusRevealsDot / isCueStatus /
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
- // The server-side status of a session whose process has exited (mirrors the
35
- // engine's terminal.StatusExited). Such a session is viewable history — its
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
- /** The other end-of-process status (engine terminal.StatusCrashed): a non-zero
41
- * exit status, or a terminating signal the program was not asked for. A
42
- * server-initiated end (a closed tab, the idle reaper, a server shutdown) is
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, not an
49
- * event: it persists until the program reports another progress state, or the
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; ConEmu calls
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
- /** isEndedStatus reports whether a status means the session's process is GONE,
58
- * whichever way it went. Session selection asks this — not `=== "exited"` —
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 must be visible even if the server never set the
67
- * session's sticky reportsActivity flag. `warning` and `failed` are OSC 9;4
68
- * states, so the flag is set for them in practice and this only floors it;
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
- /** statusPhrase is the human wording for a status, used for BOTH the dot's hover
78
- * tooltip and the suffix in a tab's accessible name — one definition, so what a
79
- * sighted user reads on hover and what a screen reader announces cannot
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
- /** statusOwnsProgress reports whether a status is one the program's OSC 9;4
124
- * progress channel is currently speaking through. Only these may show a bar.
125
- *
126
- * The tab keeps the last percentage the server reported, and the server keeps
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
- /** renderedProgress is the percentage a status may actually SHOW. The tab keeps
138
- * the last value the server reported; this is the one place that decides whether
139
- * it is still meaningful, so the clearing rule has a single home.
140
- *
141
- * Two things clear it, and both are honest — neither invents a state change the
142
- * program never made:
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. Module-private for the same
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
- /** normalizeActivity cleans the wire value into the closed set, or "" for no
179
- * mark. Anything outside it — an unknown state from a newer server, a
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
- /** normalizeActivityCount cleans the count off the wire: a non-integer, a
190
- * negative or an absent value is 0. The engine documents count >= 1 whenever
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
- /** activityPhrase is the human wording for the mark, used for BOTH its hover
201
- * tooltip and the suffix in a tab's accessible name — one definition, so what a
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
- /** tabAccessibleName is a tab's announced name: its rendered label plus the
224
- * session's state, and the percentage when one is showing.
225
- *
226
- * The percentage is announced but never DRAWN as text. No terminal emulator puts
227
- * a number next to a tab label — Windows Terminal draws a ring over the tab icon,
228
- * Ghostty and kitty a bar on a window edge, ConEmu a percentage in the WINDOW
229
- * title — and the visible number cost label width in a chip that already shrinks
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 session statuses that raise an attention cue: the mobile switcher's
262
- * aggregate dot, and the three out-of-page surfaces derived from the same set
263
- * (see attention.ts). The rule is "only states that want the user — it's like a
264
- * notification": a background terminal blocked on the user ("input"), one whose
265
- * turn finished ("done"), one whose process died badly ("crashed"), and one that
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 over CueStatus, most severe first, AND the complete set: this
282
- * array is what isCueStatus tests against, so the type and the runtime list
283
- * cannot drift the way two hand-maintained four-way checks could.
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
- export function worseCue(a: CueStatus | "", b: CueStatus | ""): CueStatus | "" {
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 that mean the session's own turn is OVER WITHOUT INCIDENT, and
322
- * therefore the only ones a live background task may blank.
323
- *
324
- * Narrow on purpose, and `done` is the whole set. Every other cue status is
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
- /** foldedCueStatus is the status the CUE surfaces see for a session: its own
336
- * status, unless a settled turn's background task is still running, in which
337
- * case "". Nothing about the status DOT changes — the strip keeps rendering the
338
- * turn's real state; only the cue's view of it is blanked.
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
- /** isUnseenCue reports whether a session's CURRENT status is a cue this viewer
353
- * has not acknowledged. The single predicate behind both the raise and the
354
- * attention count, so a dot can never light for a session the count omits.
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
- /** localStorage key for the cues this viewer has already SEEN: session id -> the
369
- * latched status that was acknowledged.
370
- *
371
- * It has to be remembered, because dismissing the cue does not change the
372
- * session: `input` and `done` are LATCHED server-side (the engine clears them
373
- * only on the session's next working phase) and the status stream re-delivers
374
- * the latch in the snapshot it pushes on every open. So a dismissed dot came
375
- * back on the next page load — and, since the snapshot is re-pushed on every
376
- * SSE reconnect, on a phone simply returning to a backgrounded page.
377
- *
378
- * Client-side for the same reason as ACTIVE_TAB_KEY: "I have seen this" is a
379
- * property of the VIEWER, not of the session. A phone acknowledging a finished
380
- * turn must not blank the dot on the desktop watching the same server, so this
381
- * needs no engine API. Contrast the tab ARRANGEMENT, which is a property of the
382
- * session set and so is the server's (see compareTabOrder): a reorder is meant
383
- * to reach the other device, and an acknowledgement is not.
384
- *
385
- * Keyed per session rather than as one latest-wins slot: several background tabs
386
- * can hold a latched status at once while the cue only ever shows the newest, so
387
- * a single-slot acknowledgement would let every other one re-raise the dot on
388
- * the next load. */
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: every key is a live session server-side, so
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
- /** parseCueSeen reads stored acknowledgements into a clean map. Anything it
397
- * cannot trust is dropped (or, for a broken document, all of it): a lost
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
- /** createdMillis reads a wire createdAt as a number for comparison. An
456
- * unparseable value sorts LAST rather than first: the caller's question is where
457
- * a tab belongs in a strip, and the end is the answer that reads as "new" —
458
- * putting a tab the client cannot date at the head would rewrite the top of the
459
- * strip on the strength of a value it failed to read.
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
- /** compareTabOrder is the total order the strip is built in: the server's shared
472
- * position, then age, then id.
473
- *
474
- * The server owns the arrangement (engine 3.9.0, `PUT /api/sessions/order`), so
475
- * a drag on one device moves the tab on every device, and a browser with no
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
- /** orderedInsertIndex returns where `incoming` belongs in `current` (the tabs
508
- * already on the strip, in display order) under compareTabOrder: the index of
509
- * the first tab that sorts after it, else the end.
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
- /** Local mutation epoch at which this tab was adopted (a monotonic counter,
528
- * not a timestamp). The list reconcile snapshots the counter BEFORE its GET
529
- * /api/sessions and drops a server-unlisted tab only when the tab predates
530
- * that snapshot — a tab born while the list was in flight (the bootstrap's
531
- * create racing the SSE stream-open reconcile) is invisible to that stale
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 for this session: its pinned name, else the
536
- * program's OSC 0/2 window title, else a title the host pushed through
537
- * SetSessionTitle, else the engine's own foreground-process/cwd inference.
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: the absolute LINE at the viewport top
573
- * plus its on-screen offset and the follow state, as captureViewMemory()
574
- * returned it when the tab was last left. Null for a tab never viewed (and
575
- * for one left on the alternate screen, which has no absolute indices worth
576
- * remembering) — restoring null means "follow the tail", which is the right
577
- * default for a first visit.
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
- /** baseLabel is a tab's label before de-duplication: the user's pinned name if
604
- * there is one, otherwise the title the SERVER resolved.
605
- *
606
- * Only two rungs, because the engine now owns every automatic source and folds
607
- * them into `title` in precedence order (pinned, the program's OSC window title,
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
- /** pinnedNameOf normalizes a tab's pin: trimmed, with a whitespace-only value
623
- * reading as absent. One definition, so baseLabel's precedence and the menu's
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
- /** hasPinnedName reports whether a tab carries a user-set name, which is what
630
- * gates the tab menu's "use the automatic name" item. */
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 REST client (GET/POST/DELETE /api/sessions + PUT .../title),
636
- * bound to an apiBase. Every call is timeout-bounded: fetch has no default
637
- * timeout, and a stalled-but-open server would otherwise leave a bootstrap
638
- * list/create await pending forever (the old permanent-loading-overlay wedge;
639
- * under the v4 session-owner contract the kernel would eventually see nothing,
640
- * but a bounded call recovers into the retry chrome MUCH sooner). */
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 for a session. UNLIKE setTitle this THROWS on
646
- * failure: a rename the user typed and that silently did not persist looks
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 so its label falls back to the automatic
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, by sending
653
- * every live session id in the wanted order.
654
- *
655
- * THROWS on failure, and the status is the point. A 409 means the server's
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 actually
668
- * said instead of flattening it into a message string.
669
- *
670
- * The motivating case is a host that legitimately and TEMPORARILY refuses
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 when the server sent no usable
681
- * hint. */
585
+ /** Retry-After in milliseconds, or undefined without a usable hint. */
682
586
  readonly retryAfterMs: number | undefined;
683
- /** The human-readable message from the error envelope, when the server sent
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 - Date.now(), 0), RETRY_AFTER_MAX_MS);
622
+ return Math.min(Math.max(when - now, 0), RETRY_AFTER_MAX_MS);
721
623
  }
722
624
 
723
- /** Pull the error envelope's human-readable message out of a failed response,
724
- * without letting a non-JSON body, a hostile payload, or a slow read break the
725
- * caller: the status and retry hint matter more than the prose, so every failure
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 sessionError(operation: string, r: Response): Promise<SessionAPIError> {
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 fetch(apiBase, {
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 -- a proxy error object, or a Go server
772
- // marshaling a nil session slice as JSON `null` -- must not reach the
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 fetch(apiBase, {
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 fetch(`${apiBase}/${encodeURIComponent(id)}`, {
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 fetch(`${apiBase}/${encodeURIComponent(id)}/pinned-title`, {
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 fetch(`${apiBase}/${encodeURIComponent(id)}/pinned-title`, {
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 fetch(`${apiBase}/order`, {
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(ttlMs: number = CLOSE_TOMBSTONE_MS): Tombstones {
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 = Date.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 (Date.now() - closedAt < ttlMs) {
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
- /** MAX_PINNED_NAME bounds a user-typed tab name, mirroring the engine's own
881
- * pinned-title cap so the field cannot accept text the server would silently
882
- * truncate. A hand-typed label past ~40 characters is never visible in a 300px
883
- * chip; 128 is generous. Counted in CODE POINTS, matching the server's runes —
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
- /** sanitizePinnedName cleans a user-typed tab name before it is displayed or
891
- * sent: control characters and DEL out (they would inject newlines or escape
892
- * sequences into the label and into logs, CWE-117), trimmed, bounded by code
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) {