@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.
Files changed (48) hide show
  1. package/README.md +114 -18
  2. package/css/30-tabs.css +52 -13
  3. package/css/31-switcher.css +15 -0
  4. package/css/32-split.css +176 -0
  5. package/css/MANIFEST +1 -0
  6. package/css/MANIFEST.tabbed +1 -0
  7. package/package.json +13 -12
  8. package/src/composition.ts +165 -237
  9. package/src/features/activity-monitor.ts +20 -55
  10. package/src/features/animations.ts +12 -30
  11. package/src/features/clipboard.ts +36 -64
  12. package/src/features/connection-banner.ts +10 -19
  13. package/src/features/context-menu.ts +70 -145
  14. package/src/features/dom.ts +44 -54
  15. package/src/features/menu-position.ts +24 -28
  16. package/src/features/mobile-toolbar.ts +32 -62
  17. package/src/features/predictive-echo.ts +15 -49
  18. package/src/features/scroll-to-bottom.ts +12 -24
  19. package/src/features/tabs/index.ts +1398 -1806
  20. package/src/features/tabs/ink-centre.ts +45 -54
  21. package/src/features/tabs/model.ts +301 -383
  22. package/src/features/tabs/strip.ts +45 -97
  23. package/src/features/tabs/switcher.ts +70 -48
  24. package/src/index.ts +13 -13
  25. package/src/kernel/a11y.ts +106 -27
  26. package/src/kernel/attention.ts +157 -0
  27. package/src/kernel/conn-state.ts +28 -22
  28. package/src/kernel/fatal.ts +76 -0
  29. package/src/kernel/feature-host.ts +136 -0
  30. package/src/kernel/gesture.ts +9 -13
  31. package/src/kernel/kernel.ts +57 -2139
  32. package/src/kernel/layout-policy.ts +44 -0
  33. package/src/kernel/loading-status.ts +30 -88
  34. package/src/kernel/navigation.ts +11 -17
  35. package/src/kernel/notify.ts +158 -0
  36. package/src/kernel/pane.ts +1415 -0
  37. package/src/kernel/realm.ts +11 -0
  38. package/src/kernel/regions.ts +8 -11
  39. package/src/kernel/scrollback.ts +88 -168
  40. package/src/kernel/selection.ts +25 -0
  41. package/src/kernel/shell.ts +1434 -0
  42. package/src/kernel/split-handle.ts +280 -0
  43. package/src/kernel/status-share.ts +100 -0
  44. package/src/kernel/types.ts +457 -441
  45. package/src/predict.ts +144 -170
  46. package/src/viewport.ts +124 -197
  47. package/src/features/tabs/attention.ts +0 -250
  48. package/src/features/tabs/notify.ts +0 -265
@@ -1,17 +1,9 @@
1
- // The v3 kernel/feature contract (design section 22.4).
2
- //
3
- // A terminal is a small always-present kernel plus opt-in feature modules. This
4
- // module is the typed spine both sides hang off: the feature interface, the
5
- // context the kernel hands each feature, the typed event bus payloads, and the
6
- // layout-region vocabulary. It is fully typed (no `any`, no stringly-typed
7
- // capability lookup) so a feature's public API is held by reference and a peer
8
- // reads it through a typed token.
9
-
10
1
  import type { LoadingMessages } from "./loading-status.js";
11
2
  import type {
12
3
  ScreenMessage,
13
4
  ModesMessage,
14
5
  LineStore,
6
+ StatusStreamCallbacks,
15
7
  StoreSnapshot,
16
8
  ViewMemory,
17
9
  } from "@cplieger/web-terminal-engine";
@@ -19,8 +11,6 @@ import type {
19
11
  /** Cancels a subscription or registration. Idempotent by convention. */
20
12
  export type Unsubscribe = () => void;
21
13
 
22
- // --- Layout regions (section 22.13) ---
23
-
24
14
  /** The named layout regions the kernel owns. A feature mounts its chrome into
25
15
  * one of these; the region owns position, spacing, stack direction, z-order,
26
16
  * and the keyboard-inset lift. */
@@ -37,8 +27,6 @@ export type RegionName =
37
27
  * visual order (WCAG 2.4.3). */
38
28
  export type RegionSlot = string;
39
29
 
40
- // --- Session references ---
41
-
42
30
  /** A reference to a session, carried on switch events and onSwitch. */
43
31
  export interface SessionRef {
44
32
  readonly id: string;
@@ -55,8 +43,6 @@ export interface SessionView {
55
43
  highestIndex(): number;
56
44
  }
57
45
 
58
- // --- Connection state (mirrors the kernel's connection-state machine) ---
59
-
60
46
  /** Connection state the kernel owns and broadcasts on `connection:state`. */
61
47
  export type ConnState =
62
48
  | "open"
@@ -70,17 +56,14 @@ export type ConnState =
70
56
  | "ended"
71
57
  // The engine refused an explicitly incompatible wire revision. This is a
72
58
  // terminal state, not a transient disconnect; page reload/update is required.
73
- | "incompatible";
74
-
75
- // --- Engine drive handles ---
76
- //
77
- // Features drive the engine's render and scroll surfaces through these handles:
78
- // the subset of the engine's render/scroll modules features legitimately need.
79
- // The kernel assigns the real engine namespaces here, so TS verifies they
80
- // satisfy the subset (drift is caught at the kernel). The connection layer is
81
- // deliberately NOT exposed: features send only through ctx.send / ctx.paste (the
82
- // sanitizing funnel) and never touch the raw outbox/resume layer or the binary
83
- // frame format (section 22.9).
59
+ | "incompatible"
60
+ // The pane holds no session (its shown tab left while the terminal stays up):
61
+ // nothing is connecting, so there is nothing to show. Left by the next attach.
62
+ | "idle";
63
+
64
+ // The subset of the engine's renderer and scroll controller features may drive;
65
+ // the kernel assigns the real instances, so drift is caught there. The connection
66
+ // is NOT exposed: features send only through the sanitizing funnel.
84
67
 
85
68
  /** The render methods features may drive. */
86
69
  export interface RenderHandle {
@@ -120,29 +103,234 @@ export interface ScrollHandle {
120
103
  * memory. The engine's scroll controller owns the container's scrollTop;
121
104
  * features go through this seam, never the DOM element (engine >= v3). */
122
105
  currentScrollTop(): number;
123
- /** Restore a saved offset; follow/hold re-derives from the resulting scroll
124
- * event exactly as for a user scroll — the write half of scroll memory.
125
- *
126
- * @deprecated A pixel offset cannot identify a reading position: replayed
127
- * into a surface whose rows are still building it is silently clamped, and
128
- * replayed into one whose content grew it points at a different line. Use
129
- * render.captureViewMemory() + render.bind(store, { view }), which restore a
130
- * LINE. Removed in the next major. */
131
- restoreScrollTop(top: number): void;
132
- /** Restore an offset AND its follow state together, which is the only way to
133
- * express "holding at the bottom" (a content shrink clamps a scrolled-up reader
134
- * there without engaging follow, so position alone cannot say it).
135
- *
136
- * NOT the per-view scroll-memory API: this is still a PIXEL offset, with the
137
- * same defect as `restoreScrollTop` above — a rebuild clamps it and a tab whose
138
- * content grew while backgrounded lands on a different line. `render.bind(store,
139
- * { view })` with `render.captureViewMemory()` is what a consumer wants; the
140
- * engine calls this internally from that path. Declared here because it is part
141
- * of the engine's scroll surface, not because a consumer should reach for it. */
106
+ /** Restore an offset AND its follow state together, the only way to express
107
+ * "holding at the bottom" (a content shrink clamps a scrolled-up reader there
108
+ * without engaging follow, so position alone cannot say it). NOT the per-view
109
+ * scroll-memory API: this is a PIXEL offset, so a rebuild clamps it and a tab
110
+ * whose content grew while backgrounded lands on a different line;
111
+ * `render.bind(store, { view })` with `render.captureViewMemory()` is what a
112
+ * consumer wants, and the engine calls this from that path. Declared because
113
+ * it is part of the engine's scroll surface. */
142
114
  restoreView(view: { top: number; following: boolean }): void;
143
115
  }
144
116
 
145
- // --- Typed event bus payloads (section 22.4) ---
117
+ /** The engine's mode-state readers a feature may consult (never write): the
118
+ * connection writes them from the server's modes frames, and the engine's
119
+ * `ModeState` satisfies this. */
120
+ export interface ModeReaders {
121
+ /** True when the server has DEC 2004 (bracketed paste) enabled. */
122
+ isBracketedPaste(): boolean;
123
+ /** True when the server has DECCKM (application cursor keys) enabled. */
124
+ isApplicationCursor(): boolean;
125
+ /** Active mouse tracking mode (xterm DECSET): 0 = off, 1000 = normal, 1002 = button-event, 1003 = any-event. */
126
+ getMouseMode(): number;
127
+ /** True when the server has DEC 1006 (SGR mouse encoding) enabled. */
128
+ isMouseSGR(): boolean;
129
+ /** True when the server has DEC 1016 (SGR-pixels mouse) enabled: reports carry pixel coordinates. */
130
+ isMousePixels(): boolean;
131
+ /** True when the server has DEC 1004 (focus event reporting) enabled. */
132
+ isFocusReporting(): boolean;
133
+ /** True when the server has DECKPAM (application keypad) enabled. */
134
+ isApplicationKeypad(): boolean;
135
+ /** True when the server has DEC 5 (reverse video / DECSCNM) enabled. */
136
+ isReverseVideo(): boolean;
137
+ /** Kitty keyboard progressive-enhancement flags in effect (bit0 disambiguate,
138
+ * bit1 report-event-types, bit2 report-alternate-keys); 0 means legacy encoding. */
139
+ getKeyboardFlags(): number;
140
+ }
141
+
142
+ /** One of the two display panes a terminal can show. */
143
+ export type PaneSide = "left" | "right";
144
+
145
+ /** A pane kernel as the shell and a shell-scoped feature see it. */
146
+ export interface PaneHandle {
147
+ /** Left for the pane a closed split shows and right for the one it hides; both
148
+ * sides are reassigned at every open and close, so no session moves between
149
+ * kernels for either. */
150
+ readonly side: PaneSide;
151
+ /** The pane root. */
152
+ readonly root: HTMLElement;
153
+ /** This pane's `.term`. */
154
+ surface(): HTMLElement;
155
+ readonly render: RenderHandle;
156
+ readonly scroll: ScrollHandle;
157
+ readonly modes: ModeReaders;
158
+ /** This pane's active session, or none. */
159
+ readonly session: SessionView;
160
+ /** `shown` has an active session; `hidden` is the pane a closed split hides. */
161
+ state(): "shown" | "empty" | "failed" | "hidden";
162
+ /** This pane's regions. */
163
+ region(name: RegionName, slot?: RegionSlot): HTMLElement;
164
+ on<K extends keyof TerminalEvents>(
165
+ event: K,
166
+ cb: (payload: TerminalEvents[K]) => void,
167
+ ): Unsubscribe;
168
+ /** Show a session here. */
169
+ notifySwitch(session: SessionRef): void;
170
+ /** Make this pane empty: detach the shown session, forget it on this pane's
171
+ * connection (which closes its socket), clear the screen and go `idle`, so no
172
+ * wake handler reconnects a pane that holds no session. Idempotent. */
173
+ clearActiveSession(): void;
174
+ /** Send this pane's grid size now, once its font metrics are known. The
175
+ * viewport's own settle is not waited for: a handle drag sends the geometry
176
+ * under the pointer, and the engine drops a size equal to the last sent. */
177
+ announceSize(): void;
178
+ focus(): void;
179
+ send(bytes: Uint8Array): void;
180
+ }
181
+
182
+ /** The split view's state. */
183
+ export interface SplitState {
184
+ readonly open: boolean;
185
+ /** Open, but the pane row is under the width two panes need, so one is shown. */
186
+ readonly collapsed: boolean;
187
+ /** The EFFECTIVE left share of the pane row, 0 to 1. */
188
+ readonly ratio: number;
189
+ /** The remembered share the layout record holds. */
190
+ readonly committedRatio: number;
191
+ readonly selected: PaneSide;
192
+ }
193
+
194
+ /** Opens, closes and resizes the two-pane split. On a terminal built without the
195
+ * split option `enabled` is false and every mutator returns false. */
196
+ export interface SplitController {
197
+ readonly enabled: boolean;
198
+ state(): SplitState;
199
+ isOpen(): boolean;
200
+ /** Enabled, closed, and the pane row is wide enough for two panes. */
201
+ canOpen(): boolean;
202
+ /** True when the split is now open at a 50/50 share; false when nothing changed. */
203
+ open(): boolean;
204
+ /** The selected pane fills the view; false when nothing changed. The state is
205
+ * closed at once; with `wt-animate` on the shell root the grid columns slide
206
+ * over `--dur-standard` before the other pane leaves the grid. */
207
+ close(): boolean;
208
+ /** Close one side; the other pane fills the view. */
209
+ closeSide(side: PaneSide): boolean;
210
+ /** Set the left share; `commit` writes the record. False when refused. */
211
+ setRatio(ratio: number, commit: boolean): boolean;
212
+ onChange(cb: (state: SplitState) => void): Unsubscribe;
213
+ }
214
+
215
+ /** How the page's attention surfaces are bound. */
216
+ export interface AttentionOptions {
217
+ /** Swap every `link[rel~="icon"]` href to the reported variant: the `favicon`
218
+ * token of the filename gains `-<icon>` (`/favicon.svg` becomes
219
+ * `/favicon-input.svg`, `/favicon-32x32.png` becomes `/favicon-input-32x32.png`),
220
+ * so the consumer serves one file per variant it reports; a link whose filename
221
+ * does not start with `favicon` is left alone. Off by default because the
222
+ * variants are the consumer's own assets. */
223
+ readonly icons: boolean;
224
+ }
225
+
226
+ /** What wants the user across the page, as one feature reports it. */
227
+ export interface AttentionState {
228
+ /** How many sessions want the user: the document title's `(N) ` prefix and the
229
+ * installed app's badge number. 0 clears both. */
230
+ readonly count: number;
231
+ /** The icon variant for the most pressing of them, or null for the page's own
232
+ * icon. Applied only under `AttentionOptions.icons`. */
233
+ readonly icon: string | null;
234
+ }
235
+
236
+ /** Renders an attention state onto the document. */
237
+ export interface AttentionReporter {
238
+ /** Render `state`. Idempotent: a state equal to the last one reported touches
239
+ * nothing, so a status sweep may call it every tick. */
240
+ report(state: AttentionState): void;
241
+ }
242
+
243
+ /** The notification a session status event carries (OSC 9 `<message>`). Both
244
+ * members are optional: an older server, or a status with no notification,
245
+ * omits them. */
246
+ export interface NotificationEvent {
247
+ readonly id: string;
248
+ readonly notification?: string | undefined;
249
+ readonly notificationSeq?: number | undefined;
250
+ }
251
+
252
+ /** What the reporting feature knows about the session at delivery time. */
253
+ export interface NotificationView {
254
+ /** The session's tab is on screen. Folded with page visibility: a notification
255
+ * is suppressed ONLY when the user is already looking at its terminal. */
256
+ readonly sessionIsActive: boolean;
257
+ /** The notification's title. */
258
+ readonly label: string;
259
+ /** Show the session in the page; run when the user clicks the notification. */
260
+ readonly activate: () => void;
261
+ }
262
+
263
+ /** The page's browser notifier. One per terminal, so one permission prompt per
264
+ * page whichever feature reports the gesture. The notification text is UNTRUSTED
265
+ * program output and reaches the browser's own surface as data, never the DOM. */
266
+ export interface Notifier {
267
+ /** Deliver one status event's notification, if it carries a new one. True when
268
+ * a browser notification was posted; false for every degraded path (no
269
+ * notification in the event, a repeat, suppression, no API, no permission, a
270
+ * throwing constructor). */
271
+ deliver(ev: NotificationEvent, view: NotificationView): boolean;
272
+ /** Note that this page has a session capable of notifying, which is what makes
273
+ * a permission prompt worth raising at all. */
274
+ arm(): void;
275
+ /** Note a user gesture: the ONLY moment a permission prompt may be raised. */
276
+ gesture(): void;
277
+ /** Drop a closed session's dedupe state. */
278
+ forget(id: string): void;
279
+ }
280
+
281
+ /** The shell a feature reaches through `ctx.shell`: the pane kernels and the
282
+ * facts shared between them. */
283
+ export interface ShellContext {
284
+ /** The shell root; the pane root in the direct-root topology. */
285
+ readonly root: HTMLElement;
286
+ /** Null while that side has no built kernel. */
287
+ pane(side: PaneSide): PaneHandle | null;
288
+ /** Built panes, left first. */
289
+ panes(): readonly PaneHandle[];
290
+ selected(): PaneSide;
291
+ /** False for an invalid value and for a failed, hidden or absent pane, and for
292
+ * an empty pane while another pane shows a tab. While no pane shows one (both
293
+ * emptied, a replacement on its way) an empty pane may be selected, so the
294
+ * divider faces the pane the new tab is headed for. */
295
+ select(side: PaneSide): boolean;
296
+ onSelectionChange(cb: (side: PaneSide) => void): Unsubscribe;
297
+ /** A pane built, hidden, shown or torn down. */
298
+ onPanesChange(cb: () => void): Unsubscribe;
299
+ /** Where a click on `sessionId`'s tab would show it: the first empty pane, left
300
+ * then right, else the selected pane. */
301
+ targetFor(sessionId: string): PaneSide;
302
+ /** The second half of closing a shown tab: `forgetSession(sessionId)` on every
303
+ * built pane's connection other than `side` (whose own `clearActiveSession()`
304
+ * already did it), and the scrollback keeper's forget. */
305
+ dropSessionExcept(sessionId: string, side: PaneSide): void;
306
+ /** Bind the page's attention surfaces (the document-title prefix, the app
307
+ * badge, the icon links) and get the reporter that drives them. The terminal
308
+ * is the only writer of `document.title`: a program's OSC 0/2 title replaces
309
+ * the base and keeps the prefix. The surfaces are restored when the page hides
310
+ * and on destroy, and repainted on a back-forward cache restore. Keep the
311
+ * report an AGGREGATE, since a page has one title over many sessions. */
312
+ attention(opts: AttentionOptions): AttentionReporter;
313
+ /** Subscribe to the server's session status stream at `path`. The terminal
314
+ * holds ONE stream per path for the page and fans it out, because the server
315
+ * caps its stream subscribers; the first subscriber opens it and the last
316
+ * one's unsubscribe closes it, as does destroy. A subscriber joining a stream
317
+ * that has already opened has its `onOpen` called at once, since the stream
318
+ * carries only future changes and the open is what a consumer resyncs on. */
319
+ subscribeStatus(path: string, callbacks: StatusStreamCallbacks): Unsubscribe;
320
+ /** The page's one browser notifier. A feature reports the events it sees
321
+ * through `deliver`, a notification-capable session through `arm` and a user
322
+ * gesture through `gesture`; the terminal decides whether to post and when to
323
+ * ask for permission. */
324
+ readonly notifications: Notifier;
325
+ readonly split: SplitController;
326
+ /** The layout owner's boot path: open the split at the share and on the side a
327
+ * restored record remembers, where `split.open()` resets them to 0.5 and left.
328
+ * The selection is part of the open and is not announced as a change; it may
329
+ * rest on `selected` while that pane is still empty, as it may whenever no pane
330
+ * shows a tab. False when the split is open already, `committedRatio` is not a
331
+ * share of 0 to 1, or `selected` is not a side. */
332
+ restoreSplit(committedRatio: number, selected: PaneSide): boolean;
333
+ }
146
334
 
147
335
  /** The inbound-wire + lifecycle events features can subscribe to via ctx.on.
148
336
  * No `unknown`: each event carries a typed payload. */
@@ -169,13 +357,11 @@ export interface TerminalEvents {
169
357
  "render:cursor": undefined;
170
358
  }
171
359
 
172
- // --- Accessibility primitives (kernel-owned, single source) ---
173
-
174
360
  /** Controls the ARIA tablist/tabpanel seam on the kernel's output surface. The
175
361
  * kernel owns the tabpanel (its output surface); `tabs` registers each tab
176
362
  * button through this so it never crosses into kernel-owned DOM. */
177
363
  export interface TablistController {
178
- /** The output surface's element id, for a tab's aria-controls. */
364
+ /** The id of the tabpanel a click on an unshown tab would fill. */
179
365
  panelId(): string;
180
366
  /** Register a tab button: sets role=tab + aria-controls on it and returns a
181
367
  * handle to update its selected state and label, or remove it. */
@@ -184,39 +370,35 @@ export interface TablistController {
184
370
 
185
371
  /** Handle to one registered tab's ARIA state. */
186
372
  export interface TabHandle {
187
- /** Mark this tab selected; the kernel points the panel's aria-labelledby at it. */
373
+ /** Mark this tab selected; the kernel points its pane's aria-labelledby at it. */
188
374
  setSelected(selected: boolean): void;
375
+ /** Point aria-controls at the tabpanel of the pane showing this tab, or (null)
376
+ * at the one a click would fill. */
377
+ setPanel(side: PaneSide | null): void;
378
+ /** aria-expanded: whether this tab is shown while the split is open; null
379
+ * removes the attribute, for a closed split. */
380
+ setExpanded(state: boolean | null): void;
189
381
  /** Set the accessible label (used for aria-labelledby on the panel). */
190
382
  setLabel(text: string): void;
191
- /** Enter or leave inline-edit mode. A textbox is not valid content for
192
- * `role="tab"` (ARIA marks a tab's children presentational, so a focusable
193
- * descendant may get no accessibility-tree node at all), so a feature that
194
- * puts an input inside its chip drops the tab semantics for the duration.
195
- *
196
- * One atomic transition each way. On: `role` and `aria-selected` are removed
197
- * and the chip leaves the roving sequence while the field owns focus. Off: the
198
- * role is restored along with `aria-selected` and the roving tabindex derived
199
- * from `selected` AS OF NOW — the active tab can change while an edit is open
200
- * on a different chip, so the caller passes the current state rather than the
201
- * handle replaying the state it saw at entry. */
383
+ /** Enter or leave inline-edit mode. ARIA marks a tab's children presentational,
384
+ * so a textbox inside a `role="tab"` may get no accessibility-tree node; the
385
+ * chip drops the tab semantics for the duration and takes `selected` AS OF
386
+ * NOW on the way back, since the active tab can change while an edit is open
387
+ * on another chip. */
202
388
  setEditing(editing: boolean, selected: boolean): void;
203
389
  /** Deregister this tab. */
204
390
  remove(): void;
205
391
  }
206
392
 
207
- // --- The context handed to each feature's setup ---
208
-
209
- /** Everything a feature is given at setup. The only surface features use to
210
- * affect the terminal; it closes the round-3 holes (typed APIs by reference,
211
- * ctx.use tokens, a typed bus, a defined lifecycle, kernel-owned a11y). */
393
+ /** Everything a feature is given at setup: the only surface a feature uses to
394
+ * affect the terminal. */
212
395
  export interface TerminalContext {
213
396
  /** Mount chrome into a named kernel region; DOM order in a region equals
214
397
  * visual order. Returns a live element the feature appends into. */
215
398
  region(name: RegionName, slot?: RegionSlot): HTMLElement;
216
- /** The terminal scroll surface, for features that attach surface-level
217
- * gestures (contextMenu right-click / long-press, the tabs swipe) or scope a
399
+ /** The terminal scroll surface, for surface-level gestures and for scoping a
218
400
  * selection to the output. Read-only use; features own only their region
219
- * chrome (section 22.9, single-operator trust). */
401
+ * chrome. */
220
402
  surface(): HTMLElement;
221
403
 
222
404
  /** The single sanitizing, session-routed input path: bracket, strip control
@@ -243,11 +425,26 @@ export interface TerminalContext {
243
425
  readonly render: RenderHandle;
244
426
  /** Drive the engine scroll controller. */
245
427
  readonly scroll: ScrollHandle;
428
+ /** Read the engine's mode state (bracketed paste, mouse tracking, DECCKM). */
429
+ readonly modes: ModeReaders;
246
430
  /** Read-only active-session view. */
247
431
  readonly session: SessionView;
248
-
249
- /** Subscribe to a typed terminal event. Auto-disposed on destroy. */
432
+ /** The shell this feature's pane belongs to. A feature with `scope: "shell"`
433
+ * receives a context whose `surface`, `render`, `scroll`, `modes`, `session`,
434
+ * `send`, `paste`, `toast` and `announce` resolve to the SELECTED pane at call
435
+ * time, whose `region` is the shell's own, and whose `on` is the union of every
436
+ * pane's bus; `ctx.shell.pane(side)` reaches one pane explicitly. */
437
+ readonly shell: ShellContext;
438
+
439
+ /** Subscribe to a typed terminal event. Released with the feature. */
250
440
  on<K extends keyof TerminalEvents>(e: K, fn: (p: TerminalEvents[K]) => void): Unsubscribe;
441
+ /** Hand the terminal a release for something this feature acquired OUTSIDE
442
+ * `ctx` (a timer, an observer, a `document` or `window` listener, an outside
443
+ * subscription), on the line that acquires it. The terminal runs every
444
+ * release, last registered first, when this feature's `setup` throws and after
445
+ * its `teardown()` returns, so a half-built feature leaks nothing and a
446
+ * `teardown` never re-lists what `defer` already holds. */
447
+ defer(release: () => void): void;
251
448
 
252
449
  /** Look up a peer feature's typed API by its factory value, or undefined if
253
450
  * that feature is absent or not yet set up. Read lazily (at interaction
@@ -255,142 +452,119 @@ export interface TerminalContext {
255
452
  use<A>(feature: TerminalFeature<A>): A | undefined;
256
453
 
257
454
  /** Show a transient toast on the kernel-owned toast surface (a shared
258
- * primitive, section 22.3), so a feature signals "Copied" etc. without
455
+ * primitive), so a feature signals "Copied" etc. without
259
456
  * owning its own surface and without needing connectionBanner present. */
260
457
  toast(message: string, ms?: number): void;
261
458
  /** Announce a message on the single kernel-owned polite (or assertive) live
262
459
  * region, so features do not spawn competing aria-live regions. */
263
460
  announce(message: string, politeness?: "polite" | "assertive"): void;
264
- /** Report WHY startup is still waiting, onto the consumer's loading overlay.
265
- *
266
- * For the window before the first frame, when the overlay covers every
267
- * surface a feature could otherwise speak through: the toast layer and the
268
- * connection banner both live inside .wt-root and paint UNDER it, so a
269
- * feature that retries for minutes has no visible channel without this.
270
- * Supersedes the kernel's own scripted wording (and stops its rotation),
271
- * because a reason the SERVER gave is always better than a guess -- pass the
272
- * server's own message where there is one. Safe to call on every tick of a
273
- * retry loop: an unchanged message is a no-op, so a screen reader is not
274
- * re-read the same sentence every few seconds. Also a no-op once the overlay
275
- * has been dismissed, so a late retry cannot resurrect it. */
461
+ /** Report WHY startup is still waiting, onto the consumer's loading overlay,
462
+ * which covers every other surface a feature could speak through before the
463
+ * first frame. Supersedes the kernel's scripted wording; pass the server's own
464
+ * message where there is one. An unchanged message is a no-op, so a screen
465
+ * reader is not re-read the same sentence every tick, and so is a call after
466
+ * the overlay has been dismissed. */
276
467
  loadingReason(message: string): void;
277
- /** Set the prefix the kernel composes onto the document title, or "" to clear
278
- * it. The kernel is the only writer of `document.title`: it holds the base
279
- * (the served `<title>`, replaced by a program's OSC 0/2 window title) and
280
- * this prefix, and re-composes on either change. A feature that assigned
281
- * `document.title` directly would be erased by the next OSC 2 from any shell
282
- * or editor running in a session, with no ordering that avoids it.
283
- *
284
- * Idempotent and safe on every status tick: an unchanged result is not
285
- * re-assigned, because the title doubles as the browser-tab label and the
286
- * bookmark name. Cleared automatically when the terminal is destroyed.
287
- *
288
- * Keep it an AGGREGATE. A page has one title while this UI multiplexes many
289
- * sessions, so any per-session value has to pick a session arbitrarily (see
290
- * the standing decision against writing progress here in features/tabs). */
291
- titlePrefix(text: string): void;
292
468
  /** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
293
469
  tablist(): TablistController;
294
470
 
295
- /** Construct a LineStore honoring the terminal's configured retained-line
296
- * cap (`CreateTerminalOptions.scrollbackLines`; engine default when unset).
297
- * A feature that keeps per-session stores (tabs' switching cache) creates
298
- * every store through this factory, so ONE consumer option governs the
299
- * kernel's implicit store and every per-tab store alike — the two cannot
300
- * drift.
301
- *
302
- * Pass the session id the store belongs to. It is optional only so an
303
- * existing feature keeps compiling; a feature that owns sessions should
304
- * always pass it, because it is what lets the kernel return a store HYDRATED
305
- * from `CreateTerminalOptions.persistScrollback` (and register it for
306
- * saving). Omitting it yields a correct but always-empty store, so that
307
- * feature's sessions silently opt out of persistence — the kernel warns once
308
- * when persistence is enabled and an id is withheld. */
471
+ /** Construct a LineStore honoring `CreateTerminalOptions.scrollbackLines`, so
472
+ * one option governs the implicit store and every per-tab store alike. Pass
473
+ * the session id: it is what lets the store be HYDRATED from
474
+ * `persistScrollback` and registered for saving. Omitting it yields a correct
475
+ * but never-persisted store, warned once when persistence is enabled. */
309
476
  newLineStore(sessionId?: string): LineStore;
310
477
 
311
- /** The current layout facts a feature keys touch-vs-desktop behavior on.
312
- * `narrow` is a ROOT compact in EITHER dimension against the kernel's
313
- * breakpoints: skinny (width, a portrait phone or a narrow embedded panel)
314
- * or short (height, a landscape phone) — the same fact the .wt-narrow root
315
- * class exposes to CSS; root size, not viewport size, so an embedded
316
- * terminal in a narrow panel counts as narrow. `coarse` is the primary
317
- * pointer's coarseness (a live media-query read). Read lazily at
318
- * interaction time. */
478
+ /** The layout facts a feature keys touch-vs-desktop behavior on. `narrow` is a
479
+ * ROOT compact in EITHER dimension (the `.wt-narrow` class), root size rather
480
+ * than viewport size so an embedded panel counts; `coarse` is a live read of
481
+ * the primary pointer. */
319
482
  layout(): { narrow: boolean; coarse: boolean };
320
483
 
321
- /** Switch the live terminal to a session. tabs binds the renderer to the
322
- * session's cached store (ctx.render.bind) first, then calls this; the kernel
323
- * reconnects the terminal WS to that session (connection.setSession, using
324
- * its per-tab resume state), invokes every feature's onSwitch (ordered,
325
- * before input resumes), and emits session:switch for pure observers
326
- * (sections 5, 22.4). Updates the active session the SessionView reports.
327
- * This is how tabs drives the reconnect-on-switch swap without touching the
328
- * raw connection layer (section 22.9). */
484
+ /** Switch the live terminal to a session. The caller binds the renderer to the
485
+ * session's store first; the kernel re-points the connection, invokes every
486
+ * feature's onSwitch before input resumes, and emits session:switch for pure
487
+ * observers. */
329
488
  notifySwitch(session: SessionRef): void;
330
- /** Drop a session's per-tab resume state (on tab close), so its outbox and
331
- * byte counters are released. The kernel routes this to the connection
332
- * layer; features never touch it directly. */
489
+ /** Drop a closed session's resume state on every pane's connection and in the
490
+ * scrollback keeper. */
333
491
  dropSession(id: string): void;
334
492
 
335
493
  /** Observe feature errors (a feature's runtime callback threw). */
336
494
  onError(fn: (feature: string, err: unknown) => void): Unsubscribe;
337
495
  }
338
496
 
339
- // --- The feature interface ---
340
-
341
497
  /** A feature is a factory value implementing this: a name and a setup the
342
498
  * kernel runs once. `Api` is the feature's own typed public API (void when it
343
499
  * exposes none). */
344
500
  export interface TerminalFeature<Api = void> {
345
501
  readonly name: string;
502
+ /** Build the feature. A `setup` that throws or rejects releases nothing itself:
503
+ * everything taken through `ctx` is released by the terminal, and everything
504
+ * acquired outside `ctx` must be handed to `ctx.defer` at acquisition or it
505
+ * leaks. */
346
506
  setup(ctx: TerminalContext): FeatureInstance<Api> | Promise<FeatureInstance<Api>>;
347
- /** Populated by the kernel after setup with the instance's api, so a consumer
348
- * holding the feature value can read it (e.g. `tabs.api?.create()`). Read
349
- * it lazily; it is undefined until the kernel has run this feature's setup.
350
- * Readonly so a feature is covariant in Api (a TerminalFeature<X> is a
351
- * TerminalFeature<unknown>); the kernel sets it through a narrow cast. */
507
+ /** `"shell"` for a feature set up ONCE per terminal with the shell context (the
508
+ * tab row, the key toolbar, the status monitor, the animation class); absent for
509
+ * a feature set up once per pane. */
510
+ readonly scope?: "shell";
511
+ /** Populated after setup with the instance's api, so a consumer holding the
512
+ * feature value can read it (`tabs.api?.create()`); undefined until then.
513
+ * Readonly so a feature is covariant in Api. */
352
514
  readonly api?: Api;
353
- /** Present on the ONE feature that owns session selection (at most one per
354
- * terminal; createTerminal throws when two features register). Its presence
355
- * makes the kernel SKIP the startup connect to the bare wsPath (which a
356
- * session-gated server 404s, churning the reconnect backoff); the kernel
357
- * instead drives the first connect through resolveInitialSession() once
358
- * feature setup completes. Single-terminal presets leave it unset so the
359
- * kernel connects to the bare wsPath at startup. */
515
+ /** Present on the ONE feature that owns session selection; createTerminal
516
+ * throws when two features register. Its presence makes the terminal SKIP
517
+ * the startup connect to the bare wsPath (which a session-gated server 404s)
518
+ * and drive the first connect through resolveInitialSession() instead. A
519
+ * feature sets this OR `paneLayoutOwner`, never both. */
360
520
  readonly sessionOwner?: SessionOwnerRegistration;
521
+ /** Present on the feature that owns which session each pane shows (the tabs
522
+ * feature). Like `sessionOwner` it makes the terminal skip the bare startup
523
+ * connect; unlike it, the owner shows the initial tabs itself through the pane
524
+ * handles. At most one owner of either kind per terminal. */
525
+ readonly paneLayoutOwner?: PaneLayoutOwnerRegistration;
526
+ }
527
+
528
+ /** The pane-layout-owner registration: the tabs feature's half of the first
529
+ * connect, where the server's layout record decides what each pane shows. */
530
+ export interface PaneLayoutOwnerRegistration {
531
+ /** Show the initial tabs through the pane handles from the server's layout
532
+ * record and the session list; resolve true when any pane shows a tab. Called
533
+ * once, after every shell-scoped setup has resolved. A false or a throw means
534
+ * nothing could be shown; the terminal dismisses the loading overlay so the
535
+ * owner's retry chrome is visible. */
536
+ resolveInitialLayout(): Promise<boolean>;
537
+ /** The session shown on `side`, or null for an empty, hidden or failed pane. */
538
+ shownIn(side: PaneSide): string | null;
539
+ /** Show a tab in the pane on `side`, emptying the pane that showed it before:
540
+ * how the terminal hands a tab to a survivor when the pane showing it leaves.
541
+ * The split's own state is the terminal's and is not touched. False when
542
+ * refused: an unknown id, or a side whose pane is absent, failed or hidden. */
543
+ showIn(side: PaneSide, id: string): boolean;
361
544
  }
362
545
 
363
- /** The session-owner registration: how the one session-owning feature and the
364
- * kernel split the first connect. The feature owns session selection (list /
365
- * spawn / pick) and its own bootstrap state; the kernel owns the connect
366
- * itself, so a failed bootstrap is SEEN by the kernel (which dismisses the
367
- * loading overlay) instead of inferred from a missing side effect. */
546
+ /** How the one session-owning feature and the terminal split the first connect:
547
+ * the feature owns session selection and its bootstrap state, the terminal owns
548
+ * the connect, so a failed bootstrap is SEEN rather than inferred. */
368
549
  export interface SessionOwnerRegistration {
369
- /** Resolve the initial session: list (or spawn) one, build per-session
370
- * state, and bind the renderer to the session's store (ctx.render.bind) —
371
- * but do NOT call ctx.notifySwitch for it. The kernel performs the switch
372
- * with the returned ref through the same path notifySwitch uses. Return
373
- * null when no session could be resolved (the bootstrap failed): the
374
- * feature keeps its retry chrome alive, and the kernel dismisses the
375
- * loading overlay so that chrome is visible. A throw is treated as null
376
- * (and reported through the feature-error channel). Called exactly once,
377
- * after every feature's setup has resolved. */
550
+ /** Resolve the initial session and bind the renderer to its store, but do NOT
551
+ * call ctx.notifySwitch for it: the terminal performs the switch with the
552
+ * returned ref. Null (or a throw, reported through the error channel) means
553
+ * the bootstrap failed; the terminal dismisses the loading overlay so the
554
+ * feature's retry chrome is visible. Called exactly once, after every
555
+ * feature's setup has resolved. */
378
556
  resolveInitialSession(): Promise<SessionRef | null>;
379
557
  }
380
558
 
381
- /** What a feature's setup returns: its optional typed API, a teardown, and an
382
- * optional onSwitch the kernel invokes on a tab switch. */
559
+ /** What a feature's setup returns. */
383
560
  export interface FeatureInstance<Api = void> {
384
561
  /** This feature's public API, surfaced on the feature value and via ctx.use. */
385
562
  readonly api?: Api;
386
563
  /** Remove this feature's DOM and listeners. Run in reverse order on destroy. */
387
564
  teardown(): void;
388
- /** Called by the kernel at the START of a tab switch (detach), before the
389
- * connection is re-pointed at the incoming session, for features holding
390
- * latched input state that must not fire against the next session: the mobile
391
- * toolbar disarms its one-shot sticky-Ctrl here so a pending Ctrl cannot
392
- * become an accidental Ctrl+C to the wrong agent (design 5.1). Runs for every
393
- * feature before any onSwitch. */
565
+ /** Called at the START of a tab switch, before the connection is re-pointed,
566
+ * so latched input state (a pending sticky-Ctrl) cannot fire against the
567
+ * incoming session. Runs for every feature before any onSwitch. */
394
568
  onDetach?(): void;
395
569
  /** Called by the kernel on a tab switch (attach), after the connection is
396
570
  * re-pointed, for features that must re-point before input resumes
@@ -398,31 +572,13 @@ export interface FeatureInstance<Api = void> {
398
572
  onSwitch?(session: SessionRef): void;
399
573
  }
400
574
 
401
- // --- Entry point ---
402
-
403
- /** A fatal failure while createTerminal is starting up.
404
- *
405
- * Two phases can fail, and both are delivered here so a consumer never has to
406
- * hand-build its own startup-failure surface. That sentence used to be
407
- * aspirational: resolving the mount target and constructing the feature list
408
- * both happened at the CALL SITE, outside this boundary, so a consumer really
409
- * did need its own surface for those two — and every consumer that built one
410
- * restated this library's copy and re-decided its focus and ARIA behavior.
411
- * createTerminal now takes a selector and a feature THUNK, which pulls both
412
- * failures inside, and the promise holds literally.
413
- *
414
- * - `kernel-init`: a SYNCHRONOUS throw out of createTerminal (an unresolvable
415
- * mount selector, a throwing preset or feature constructor, an invalid
416
- * feature list, a DOM invariant). The kernel renders the recovery surface and
417
- * then RETHROWS, so a caller with its own handling still sees the error.
418
- * - `feature-setup`: an async feature-composition failure. The kernel has
419
- * already stopped the connection, released every listener and singleton,
420
- * torn down completed features, and cleared the terminal root when it
421
- * delivers this value. Nothing is rethrown; the failure is asynchronous.
422
- *
423
- * Discriminate on `phase`: `feature-setup` names the offending feature,
424
- * `kernel-init` has no feature to name because composition never began, and
425
- * only `kernel-init` can carry an undefined `surface`. */
575
+ /** A fatal failure while createTerminal is starting up, delivered so a consumer
576
+ * never hand-builds its own startup-failure surface. `kernel-init` is a
577
+ * SYNCHRONOUS throw out of createTerminal (the recovery surface is rendered,
578
+ * then the error is RETHROWN); `feature-setup` is an async composition failure,
579
+ * delivered after the failed pane has released everything and cleared its root,
580
+ * with nothing rethrown. An undefined `surface` says there is nowhere to
581
+ * render, in either phase. */
426
582
  export type TerminalStartupFailure =
427
583
  | {
428
584
  readonly phase: "feature-setup";
@@ -430,37 +586,31 @@ export type TerminalStartupFailure =
430
586
  readonly feature: string;
431
587
  /** The original thrown or rejected value. */
432
588
  readonly cause: unknown;
433
- /** The element the built-in surface would fill — always the terminal root
434
- * in this phase, since the terminal had already mounted. */
435
- readonly surface: HTMLElement;
589
+ /** The element the built-in surface would fill, and the element a handler
590
+ * claiming the surface (returning true) must render into: the terminal
591
+ * root, or under `split` the failed second pane's root while it stays in
592
+ * the grid. `undefined` when a second pane fails after the split closed:
593
+ * its tab has moved to the healthy pane, which fills the view, and the
594
+ * failed pane is discarded with no panel. */
595
+ readonly surface: HTMLElement | undefined;
436
596
  }
437
597
  | {
438
598
  readonly phase: "kernel-init";
439
599
  /** The original thrown value, rethrown to the caller after this returns. */
440
600
  readonly cause: unknown;
441
601
  /** The element the built-in surface would fill, and the element a handler
442
- * claiming the surface (returning true) must render into. Usually the
443
- * resolved root — but when the MOUNT TARGET itself could not be resolved
444
- * there is no root, so in `viewport` layout this is a fresh full-viewport
445
- * element the kernel appended to the body, and in `container` layout it is
446
- * `undefined`: an embedded terminal is one panel in a host application, so
447
- * a missing mount target is reported and rethrown but never answered by
448
- * seizing the host's page. A handler that renders its own UI must
449
- * therefore check for undefined. */
602
+ * claiming the surface (returning true) must render into: usually the
603
+ * resolved root. `undefined` in two cases, so a handler must check: an
604
+ * unresolvable mount target in `container` layout (an embedded terminal
605
+ * never seizes its host page), and a document that already holds a live
606
+ * terminal, in either layout (the root may be that terminal's). */
450
607
  readonly surface: HTMLElement | undefined;
451
608
  };
452
609
 
453
610
  /** One stored scrollback entry: the engine's store snapshot plus the timestamp
454
- * the library stamps on it.
455
- *
456
- * The timestamp is the library's, not the consumer's, because the maximum age
457
- * is ENFORCED here rather than documented as someone else's job. The case that
458
- * motivates persisting at all — iOS discarding a backgrounded tab — is exactly
459
- * the case where no "closed cleanly" path ever runs, so without an age bound
460
- * the consumer's storage accumulates snapshots for sessions that stopped
461
- * existing weeks ago. A rejected entry is also dropped through
462
- * `ScrollbackPersistence.drop`, which makes collection-on-access automatic; a
463
- * consumer that wants to sweep proactively has `savedAt` to sweep by. */
611
+ * the library stamps on it. The timestamp is the library's because the maximum
612
+ * age is ENFORCED here: the case that motivates persisting (iOS discarding a
613
+ * backgrounded tab) is the case where no "closed cleanly" path ever runs. */
464
614
  export interface PersistedScrollback {
465
615
  /** `Date.now()` when the library wrote this entry. */
466
616
  readonly savedAt: number;
@@ -468,233 +618,113 @@ export interface PersistedScrollback {
468
618
  readonly snapshot: StoreSnapshot;
469
619
  }
470
620
 
471
- /** The consumer-supplied storage seam for scrollback persistence
472
- * (`CreateTerminalOptions.persistScrollback`).
473
- *
474
- * Storage is the CONSUMER's, deliberately, for two reasons. Chrome's
475
- * page-lifecycle guidance is to close IndexedDB connections on freeze because a
476
- * held connection affects bfcache eligibility, and this library depends on
477
- * bfcache (it reconnects and re-measures on `pageshow`) — so the library must
478
- * not own a database. And terminal scrollback contains secrets while browser
479
- * storage has no meaningful at-rest protection, which makes "where does this
480
- * live, and for how long" a decision for the application, not its terminal
481
- * widget.
482
- *
483
- * `sessionId` is a real session id, never a key of the consumer's invention:
484
- * the same id the tabs feature uses, or — for a single unmanaged terminal — the
485
- * engine's per-tab id from `connection.currentSessionId()`. That is load-bearing
486
- * rather than tidy, because the id is also what the persisted server epoch is
487
- * adopted against. A consumer may of course prefix it inside its own storage.
488
- *
489
- * `load` is SYNCHRONOUS, which is a real constraint and worth understanding
490
- * before fighting it: hydration has to complete before the resume goes out, and
491
- * a resume is not restartable — a store hydrated after `haveThrough` was sent
492
- * has already lost the argument. A consumer whose storage is asynchronous
493
- * (IndexedDB) reads its snapshots into memory before calling `createTerminal`
494
- * and answers from there, which is also the shape that holds no database
495
- * connection open.
496
- *
497
- * Every callback may throw or return nonsense without consequence: the library
498
- * treats any failure as "nothing was restored" and takes a full resume, exactly
499
- * as if persistence were switched off. */
621
+ /** The consumer-supplied storage seam for scrollback persistence. Storage is the
622
+ * CONSUMER's because a held IndexedDB connection costs bfcache eligibility,
623
+ * which this library depends on, and because scrollback holds secrets while
624
+ * browser storage has no at-rest protection. `sessionId` is a real session id,
625
+ * the one the persisted server epoch is adopted against. `load` is SYNCHRONOUS:
626
+ * a resume is not restartable, so hydration must complete before it goes out;
627
+ * asynchronous storage is read into memory before `createTerminal`. Every
628
+ * callback may throw: any failure means "nothing restored" and a full resume. */
500
629
  export interface ScrollbackPersistence {
501
- /** Return whatever was stored for a session, or null/undefined when there is
502
- * nothing. Called once per session, before that session connects.
503
- *
504
- * `unknown`, not `PersistedScrollback`, and the asymmetry with `save` below is
505
- * the point: an entry going OUT was produced by the engine and is proven, while
506
- * an entry coming BACK has been outside this program's memory and is a claim.
507
- * Declaring the proven type on both sides told the compiler the returned value
508
- * was verified — which no implementation of this seam can promise, this
509
- * library's own `localScrollbackStorage` least of all, since it hands back a
510
- * `JSON.parse` result. The kernel parsed it anyway, and because the type said
511
- * otherwise it did so in helpers that each re-established the shape from
512
- * scratch: measured 2026-08, 15 of `scrollback.ts`'s 19 permanently unkillable
513
- * mutants were those duplicate checks.
514
- *
515
- * Nothing is lost by an implementation: a `load` annotated
516
- * `PersistedScrollback | null` still satisfies this, so a consumer that holds
517
- * real entries in memory keeps its own type checking. What goes is only the
518
- * false guarantee at the boundary that cannot honour it.
519
- *
520
- * `PersistedScrollback` remains the shape to store and the shape you get back
521
- * when storage is intact; see `save`. */
630
+ /** Whatever was stored for a session, or null/undefined. Called once per
631
+ * session, before that session connects. `unknown`, not `PersistedScrollback`:
632
+ * an entry coming BACK has been outside this program's memory and is a claim,
633
+ * which the library's own `JSON.parse`-backed storage cannot promise more of.
634
+ * A `load` annotated `PersistedScrollback | null` still satisfies this. */
522
635
  load(sessionId: string): unknown;
523
- /** Store an entry, replacing any previous one for the session.
524
- *
525
- * THROW when nothing was persisted (over quota, storage revoked, an entry the
526
- * store refuses). Returning normally is taken as "this is on disk" and records
527
- * a watermark against it; a failure reported that way made the library skip the
528
- * session until its output advanced again, which is how persistence stopped
529
- * silently. A throw is caught, warned once, and retried on the next pass. */
636
+ /** Store an entry, replacing any previous one. THROW when nothing was
637
+ * persisted: returning normally records "this is on disk", and a failure
638
+ * reported that way made the library skip the session until its output
639
+ * advanced again. A throw is warned once and retried on the next pass. */
530
640
  save(sessionId: string, entry: PersistedScrollback): void;
531
- /** Delete a session's entry. Called when a tab is closed, and when the
532
- * library rejects a stored entry (too old, unusable, or unverifiable), so a
533
- * bad or expired entry is not re-read on every load. */
641
+ /** Delete a session's entry: on tab close, and when the library rejects a
642
+ * stored entry, so a bad one is not re-read on every load. */
534
643
  drop(sessionId: string): void;
535
- /** Newest lines to persist per session (default 200).
536
- *
537
- * A bound rather than the whole store because the cost is a repeated
538
- * serialize-and-store on a device that may already be under memory pressure,
539
- * and because the screen plus recent history is what a returning user needs.
540
- * The default follows the nearest precedent — VS Code's
541
- * `terminal.integrated.persistentSessionScrollback` restores 100 lines — with
542
- * headroom, and against measurement: 200 lines is ~60 K characters of a
543
- * coloured session and serialises in under a millisecond, where 1000 is ~300 K
544
- * and ~4 ms, paid on every backgrounding and every timer tick while output
545
- * advances. The depth NOT kept is reported honestly after hydration: the store
546
- * shows its "earlier output trimmed" marker rather than implying the buffer is
547
- * complete. Non-integer or non-positive values are ignored. */
644
+ /** Newest lines to persist per session (default 200). A bound rather than the
645
+ * whole store because the cost is a repeated serialize on every backgrounding
646
+ * and timer tick, and the screen plus recent history is what a returning user
647
+ * needs; the store shows its "earlier output trimmed" marker for the rest.
648
+ * Non-integer or non-positive values are ignored. */
548
649
  lines?: number;
549
- /** Maximum age of a stored entry (default 7 days). An entry outside this is
550
- * neither loaded nor kept. Distance in EITHER direction counts, so a phone
551
- * whose clock moved cannot leave an entry that never expires. */
650
+ /** Maximum age of a stored entry (default 7 days), in EITHER direction, so a
651
+ * clock that moved cannot leave an entry that never expires. */
552
652
  maxAgeMs?: number;
553
653
  /** Interval for the background save while content is changing (default 10s).
554
- *
555
- * `visibilitychange` to hidden and `pagehide` are the last reliable callbacks
556
- * before a discard and the library writes on both, but Chrome's page-lifecycle
557
- * documentation is explicit that `pagehide` is not guaranteed. The timer is
558
- * what makes a killed tab have a recent-enough snapshot rather than none. It
559
- * skips sessions whose content has not advanced since their last save. */
654
+ * `pagehide` is not guaranteed before a discard, so the timer is what makes a
655
+ * killed tab have a recent snapshot rather than none. */
560
656
  saveIntervalMs?: number;
561
657
  }
562
658
 
563
659
  /** Options for createTerminal. */
564
660
  export interface CreateTerminalOptions {
565
- /** The feature list, as a FUNCTION that produces it; omitted means the bare
566
- * kernel (no chrome). Heterogeneous feature APIs are held as unknown here; a
567
- * consumer reads a specific feature's api off the feature value it holds.
568
- *
569
- * It is a function, not an array, so that a preset or feature constructor
570
- * that throws does so INSIDE createTerminal's failure boundary. Passed as an
571
- * array (`features: presetTabbed()`) the call was evaluated as an argument —
572
- * before createTerminal was entered — so the throw escaped the library
573
- * entirely and every consumer had to wrap the preset call in its own
574
- * try/catch and hand-build a recovery surface. Write `features:
575
- * presetTabbed` when the preset takes no arguments, or `features: () =>
576
- * presetTabbed({...})` when it does. */
661
+ /** The feature list, as a FUNCTION so a preset that throws does so INSIDE
662
+ * createTerminal's failure boundary; omitted means the bare kernel. Invoked
663
+ * once per pane and must return FRESH feature objects on every call: the
664
+ * terminal writes each object's `api` after setup and resolves `ctx.use` on
665
+ * its identity, so a saved array (`const f = presetTabbed(); features: () =>
666
+ * f`) is refused at `kernel-init`. Features marked `scope: "shell"` are taken
667
+ * from the first invocation only. */
577
668
  features?: () => readonly TerminalFeature<unknown>[];
578
- /** How the terminal claims space (default "viewport").
579
- * "viewport": the root becomes a fixed full-viewport box — the full-page
580
- * product (web-terminal-server, web-terminal-kiro, the scaffold page).
581
- * "container": the root fills its parent element, which becomes the
582
- * boundary — the embedded case (vibekit's panel). Either way the kernel
583
- * stamps the matching class (wt-viewport / wt-container) on the root and
669
+ /** How the terminal claims space (default "viewport"): a fixed full-viewport
670
+ * box, or a root filling its parent element (the embedded case). The
671
+ * matching class (wt-viewport / wt-container) is stamped on the root, and
584
672
  * every piece of chrome positions against the root, never the page. */
585
673
  layout?: "viewport" | "container";
586
674
  /** WebSocket endpoint path (default "/ws"). */
587
675
  wsPath?: string;
588
676
  /** CSS font shorthand awaited before the first resize. */
589
677
  fontReady?: string;
590
- /** Retained scrollback lines per terminal (and per tab under the tabs
591
- * feature) — the client-side line cap; the engine's default is 5000.
592
- *
593
- * This is the page's dominant memory dial: the cap bounds both the styled
594
- * run arrays each store retains AND the DOM rows the renderer keeps (one
595
- * `div.term-row` per retained line, no offscreen virtualisation), which in
596
- * turn bounds the height of the scrolled-contents layer the browser
597
- * composites. It is a HISTORY budget floored at the live screen: the
598
- * engine never evicts the current window's rows, so a value at or below
599
- * the terminal height keeps the full screen and simply retains no
600
- * scrollback. Choose a value comfortably above the largest expected
601
- * terminal height (a few multiples of it): near or below the height,
602
- * batched eviction has no history headroom to work with and degrades
603
- * back to per-line eviction — the screen stays correct, but the churn
604
- * the batching exists to remove returns. A memory-constrained consumer (iOS Safari, where the content
605
- * process is a jetsam victim under system pressure) passes a smaller
606
- * budget; scrolling back then reaches fewer lines, so this is a consumer
607
- * policy choice, not a tuning knob the library second-guesses. Applies to
608
- * the kernel's implicit store (via the engine renderer) and to every store
609
- * created through `ctx.newLineStore()` (the tabs switching cache).
678
+ /** Retained scrollback lines per terminal and per tab; the engine's default is
679
+ * 5000. The page's dominant memory dial: it bounds the styled runs each store
680
+ * retains AND the DOM rows the renderer keeps. A HISTORY budget floored at the
681
+ * live screen, so choose a few multiples of the largest expected height: near
682
+ * or below it, batched eviction has no headroom and degrades to per-line
683
+ * churn. A memory-constrained consumer (iOS Safari) passes a smaller budget.
610
684
  * Non-integer or non-positive values are ignored. */
611
685
  scrollbackLines?: number;
612
686
  /** Persist each session's scrollback across a page discard, through storage
613
- * the CONSUMER supplies. Omitted means off, which is the default on purpose.
614
- *
615
- * What it fixes: a page that is discarded and reloaded otherwise resumes
616
- * holding nothing, so it asks the server for everything and refills its whole
617
- * buffer over the wire. On iOS that is the normal case rather than an edge
618
- * case — Safari evicts backgrounded tabs under memory pressure and returning
619
- * to one re-runs the page — and the refill is both slow and visible. With a
620
- * snapshot restored, the resume asks only for what was printed while the tab
621
- * was gone.
622
- *
623
- * Scope it honestly: this helps the FRESH-LOAD case only. A warm reconnect and
624
- * an in-page tab switch already replay nothing, because their stores never
625
- * went away.
626
- *
627
- * Off by default, and the default is the LIBRARY's rather than a
628
- * recommendation. `localStorage` is a shared, origin-wide, quota-limited
629
- * resource, and this package is embedded in host applications that keep their
630
- * own state there; consuming it uninvited would risk the embedder's writes
631
- * failing, whose degradation is theirs to design and not ours to assume. Our
632
- * own failure under quota pressure is graceful (nothing restored, terminal
633
- * unaffected). An application decides durability for its own users; a library
634
- * does not decide it for an embedder who never asked. All three reference apps
635
- * DO enable it — see `localScrollbackStorage`, which is the ready-made answer.
636
- *
637
- * Note for a consumer weighing it: enabling this writes terminal output where
638
- * it is readable from that browser without reaching the server and outlives the
639
- * tab (bounded by a per-session delete on close and a seven-day expiry). No
640
- * permission prompt is involved — `localStorage` needs none — and a browser
641
- * that blocks site data simply restores nothing.
642
- *
643
- * It applies to the whole terminal, not one composition: the kernel's implicit
644
- * store (a single unmanaged terminal) and every store a session-owning feature
645
- * creates through `ctx.newLineStore(sessionId)` (the tabs cache) are both
646
- * hydrated and both saved. */
687
+ * the CONSUMER supplies; see `localScrollbackStorage`. Helps the FRESH-LOAD
688
+ * case only (iOS evicts backgrounded tabs, and returning re-runs the page):
689
+ * the resume asks only for what was printed while the tab was gone. Off by
690
+ * default because `localStorage` is a shared, quota-limited resource an
691
+ * embedder may depend on, and because enabling it writes terminal output
692
+ * where that browser can read it without the server. Applies to the implicit
693
+ * store and to every `ctx.newLineStore(sessionId)` store alike. */
647
694
  persistScrollback?: ScrollbackPersistence;
648
- /** Optional pre-JS loading overlay the kernel fades out on first paint. Give
649
- * it the `wt-loading` class and a `wt-loading-bar` child and css/page.css
650
- * styles it; the kernel also writes a progressive status line into it while
651
- * startup drags on (see kernel/loading-status.ts). */
695
+ /** Optional pre-JS loading overlay the terminal fades out on first paint. Give
696
+ * it the `wt-loading` class and a `wt-loading-bar` child; a progressive status
697
+ * line is written into it while startup drags on. */
652
698
  loading?: HTMLElement;
653
699
  /** Reword the loading overlay's progressive status text. Partial: anything
654
- * omitted keeps the library default. The wording only ever appears on a SLOW
655
- * start (nothing is written for the first few seconds), so this is about what
656
- * a waiting user reads, not about branding the normal case. A live reason
657
- * pushed by a feature via `ctx.loadingReason` supersedes all of it. */
700
+ * omitted keeps the library default. It only ever appears on a SLOW start; a
701
+ * live reason pushed through `ctx.loadingReason` supersedes all of it. */
658
702
  loadingMessages?: Partial<LoadingMessages>;
659
- /** Observe or replace the kernel's fatal feature-setup surface. Called only
660
- * after the live terminal has been completely torn down and its root
661
- * cleared. Return true after rendering a replacement recovery surface into
662
- * that root; false or undefined keeps the built-in Reload page surface. If
663
- * this callback throws, the kernel logs that error and shows the built-in
664
- * surface, so a reporting failure cannot leave the page blank. */
703
+ /** Observe or replace the fatal startup surface. Return true after rendering a
704
+ * replacement recovery surface into `failure.surface`; false or undefined
705
+ * keeps the built-in Reload surface. A throw here is logged and the built-in
706
+ * surface shown, so a reporting failure cannot leave the page blank. */
665
707
  // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- an observing handler returns nothing; only a literal `true` claims the recovery surface, so forcing `return undefined` on every observer is worse than the union
666
708
  onFatalError?: (failure: TerminalStartupFailure) => boolean | void;
667
- /** Called when the active session's process has ended and nothing is retrying:
668
- * the engine's definitive process-exited close, the same fact the connection
669
- * banner renders as "Session ended".
670
- *
671
- * Wired by a host that can do something about it, which means a host that
672
- * knows its endpoint yields a new session on the next connect (see
673
- * `TerminalHandle.reattach`). Everything the kernel does about the end — lower
674
- * the loading overlay, surface the state, discard an unconfirmed restore —
675
- * happens first and happens regardless, so a host that wires nothing loses
676
- * nothing, and a callback that throws cannot take the banner down with it (the
677
- * throw is logged and swallowed).
678
- *
679
- * Observation only: the recovery POLICY stays with the host, deliberately.
680
- * Whether to reattach at all, how many times and how fast depends on what sits
681
- * behind the endpoint, which this library does not know. */
709
+ /** Called when the active session's process has ended and nothing is retrying,
710
+ * the fact the connection banner renders as "Session ended". Observation
711
+ * only: everything the terminal does about the end happens first and
712
+ * regardless, and the recovery POLICY (see `TerminalHandle.reattach`) stays
713
+ * with the host, which alone knows what its endpoint yields on the next
714
+ * connect. A throw is logged and swallowed. */
682
715
  onSessionEnded?: () => void;
683
- /** Theme overrides: CSS custom properties set on the terminal root so a
684
- * consumer recolors the UI (accent, tab hover/active, the activity-dot
685
- * palette) without shipping CSS. Keys must be CSS custom-property names
686
- * (start with "--"); values are any CSS value. The library ships the defaults
687
- * (css/00-tokens.css) — the "template"; these are the consumer's "settings"
688
- * and override them for this instance.
689
- *
690
- * The supported keys are published as DATA, not prose: `PUBLIC_THEME_TOKENS`
691
- * (exported at the package root and at "@cplieger/web-terminal-ui/style-contract").
692
- * This type stays an open Record so a consumer may build its theme
693
- * dynamically, which means nothing here rejects a key the library renamed or
694
- * retired — the override would simply apply to nothing. Assert your keys
695
- * against that list rather than trusting a comment; the library's own suite
696
- * guarantees every token on it is both declared and read by a rule. */
716
+ /** Theme overrides: CSS custom properties set on the terminal root, over the
717
+ * defaults of css/00-tokens.css. The supported keys are published as DATA in
718
+ * `PUBLIC_THEME_TOKENS`; this type stays an open Record so a theme can be
719
+ * built dynamically, which means an unknown key applies to nothing. */
697
720
  theme?: Readonly<Record<string, string>>;
721
+ /** Two panes side by side, driven from the tab chrome (a split button, the tab
722
+ * menu's snap items, tab drag and drop, and a focusable divider). Requires the
723
+ * built-in tabs feature (`tabs()` or a tabbed preset) in `features`; a terminal
724
+ * without it fails at `kernel-init`, as does a value other than true or false.
725
+ * With it the root becomes the shell root holding one pane root per pane; the
726
+ * DOM of a terminal built without it is unchanged. */
727
+ split?: boolean;
698
728
  }
699
729
 
700
730
  /** The handle createTerminal returns. Feature APIs are not materialized here
@@ -703,40 +733,26 @@ export interface CreateTerminalOptions {
703
733
  export interface TerminalHandle {
704
734
  /** Focus the terminal input (opens the soft keyboard on touch). */
705
735
  focus(): void;
706
- /** Send bytes to the active session through the kernel's sanitizing,
707
- * session-routed input funnel — the same path features use: input
708
- * transforms apply, and the view snaps to the bottom exactly like typed
709
- * input. The supported host path for "type this command" affordances
710
- * (a run-in-shell button). No-op after destroy(). */
736
+ /** Send bytes to the active session through the sanitizing input funnel, the
737
+ * same path features use, so transforms apply and the view snaps to the
738
+ * bottom like typed input. No-op after destroy(). */
711
739
  send(bytes: Uint8Array): void;
712
- /** Reset the LOCAL display: drop the client-side scrollback and screen (the
713
- * same reset the engine performs on a server restart). Deliberately injects
714
- * no keystroke — a host that wants a freshly drawn prompt sends one itself
715
- * (e.g. Ctrl+L via send()). No-op after destroy(). */
716
- reset(): void;
717
- /** Attach again to whatever the server serves now, discarding this terminal's
718
- * view of the session that is gone.
719
- *
720
- * For the one state the kernel cannot leave on its own: `ended`. The engine
721
- * refuses to reconnect a definitively closed session, and it is right to on a
722
- * per-session server, where reconnecting could only collect the same close
723
- * again. A host whose endpoint hands out a NEW session on the next connect —
724
- * one shared PTY that the server replaces once it is spent — is the only party
725
- * that knows a reconnect is worth making, so it is the party that asks.
726
- *
727
- * Three steps, in this order, and none of them is a host's to perform: drop
728
- * the local scrollback and screen (the old session's content, and a stale
729
- * absolute-index base whose `haveThrough` would otherwise claim lines the new
730
- * session has never reached), move the connection state off `ended` so the
731
- * banner stops contradicting a blank screen, then reconnect.
732
- *
733
- * Reconnecting is ALL it does: it starts no process and asks the server for
734
- * nothing. Whatever makes a new session exist is the host's own call to its
735
- * own API, made before this one. Do not call it on a live session — that is a
736
- * needless full replay, and it drops history the server may have evicted since.
740
+ /** Reset the LOCAL display: drop the client-side scrollback and screen.
741
+ * Injects no keystroke; a host that wants a fresh prompt sends one itself.
737
742
  * No-op after destroy(). */
743
+ reset(): void;
744
+ /** Attach again to whatever the server serves now, for the one state the
745
+ * terminal cannot leave on its own: `ended`. Only a host whose endpoint hands
746
+ * out a NEW session on the next connect knows a reconnect is worth making, so
747
+ * it asks. Drops the local scrollback and screen (a stale `haveThrough` would
748
+ * claim lines the new session never reached), leaves `ended`, then reconnects;
749
+ * it starts no process. Not for a live session: a needless full replay. No-op
750
+ * after destroy(). */
738
751
  reattach(): void;
739
752
  /** Tear down every feature in reverse order, dispose all subscriptions, and
740
753
  * release the kernel's DOM and engine wiring. */
741
754
  destroy(): void;
755
+ /** The split controller; present only when `CreateTerminalOptions.split` was
756
+ * true. */
757
+ split?: SplitController;
742
758
  }