@cplieger/web-terminal-ui 2.1.3 → 3.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.
@@ -0,0 +1,119 @@
1
+ // Layout regions (design section 22.13).
2
+ //
3
+ // The kernel owns a small layout skeleton: named regions with fixed geometry,
4
+ // stacking, and keyboard-inset behavior. Features contribute chrome into a
5
+ // region via ctx.region(name, slot); the region owns position, spacing, stack
6
+ // direction, z-order, and the keyboard lift, so features cannot invent their own
7
+ // placement or collide. DOM order within a region always equals visual order, so
8
+ // focus order never diverges from reading order (WCAG 2.4.3): each slot is a
9
+ // wrapper inserted in a declared order, and a feature appends into its slot.
10
+
11
+ import type { RegionName, RegionSlot } from "./types.js";
12
+
13
+ /** The regions the kernel builds, in DOM order. */
14
+ const REGION_NAMES: readonly RegionName[] = [
15
+ "top-bar",
16
+ "banner",
17
+ "bottom-inset-end",
18
+ "bottom-switcher",
19
+ "overlay",
20
+ "sheet",
21
+ ];
22
+
23
+ /** Declared slot order per region. A slot not listed here sorts after the known
24
+ * ones, in first-request order, so an unanticipated slot still composes
25
+ * predictably rather than throwing. */
26
+ const SLOT_ORDER: Record<RegionName, readonly RegionSlot[]> = {
27
+ "top-bar": ["tabs"],
28
+ banner: ["status"],
29
+ "bottom-inset-end": ["keys", "scroll"],
30
+ "bottom-switcher": ["switcher"],
31
+ overlay: ["menu"],
32
+ sheet: ["overview"],
33
+ };
34
+
35
+ const DEFAULT_SLOT = "default";
36
+
37
+ export interface Regions {
38
+ /** Return the live slot element within a region for a feature to append into.
39
+ * Created once per (region, slot) and inserted in the region's declared slot
40
+ * order. */
41
+ region(name: RegionName, slot?: RegionSlot): HTMLElement;
42
+ /** Remove all region containers from the root. */
43
+ destroy(): void;
44
+ }
45
+
46
+ /** slotRank returns a sort key for a slot within a region: its index in the
47
+ * declared order, or a large base offset plus a per-region monotonic counter
48
+ * for unlisted slots (stable first-request order). */
49
+ function slotRank(name: RegionName, slot: RegionSlot, seen: Map<string, number>): number {
50
+ const order = SLOT_ORDER[name];
51
+ const idx = order.indexOf(slot);
52
+ if (idx >= 0) {
53
+ return idx;
54
+ }
55
+ const key = `${name}:${slot}`;
56
+ let rank = seen.get(key);
57
+ if (rank === undefined) {
58
+ rank = 1000 + seen.size;
59
+ seen.set(key, rank);
60
+ }
61
+ return rank;
62
+ }
63
+
64
+ /** Build the region skeleton inside root and return a Regions accessor. */
65
+ export function createRegions(root: HTMLElement): Regions {
66
+ const containers = new Map<RegionName, HTMLElement>();
67
+ const slots = new Map<string, HTMLElement>(); // "region:slot" -> slot element
68
+ const unlistedSeen = new Map<string, number>();
69
+
70
+ for (const name of REGION_NAMES) {
71
+ const el = document.createElement("div");
72
+ el.className = `wt-region wt-region-${name}`;
73
+ el.dataset["region"] = name;
74
+ root.appendChild(el);
75
+ containers.set(name, el);
76
+ }
77
+
78
+ function region(name: RegionName, slot: RegionSlot = DEFAULT_SLOT): HTMLElement {
79
+ const key = `${name}:${slot}`;
80
+ const existing = slots.get(key);
81
+ if (existing) {
82
+ return existing;
83
+ }
84
+ const container = containers.get(name);
85
+ if (!container) {
86
+ throw new Error(`web-terminal-ui: unknown region ${name}`);
87
+ }
88
+ const el = document.createElement("div");
89
+ el.className = `wt-slot wt-slot-${slot}`;
90
+ el.dataset["slot"] = slot;
91
+ // Insert in declared slot order: before the first existing child whose rank
92
+ // is greater than ours.
93
+ const myRank = slotRank(name, slot, unlistedSeen);
94
+ let inserted = false;
95
+ for (const child of Array.from(container.children)) {
96
+ const childSlot = (child as HTMLElement).dataset["slot"] ?? DEFAULT_SLOT;
97
+ if (slotRank(name, childSlot, unlistedSeen) > myRank) {
98
+ container.insertBefore(el, child);
99
+ inserted = true;
100
+ break;
101
+ }
102
+ }
103
+ if (!inserted) {
104
+ container.appendChild(el);
105
+ }
106
+ slots.set(key, el);
107
+ return el;
108
+ }
109
+
110
+ function destroy(): void {
111
+ for (const el of containers.values()) {
112
+ el.remove();
113
+ }
114
+ containers.clear();
115
+ slots.clear();
116
+ }
117
+
118
+ return { region, destroy };
119
+ }
@@ -0,0 +1,286 @@
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
+ import type { ScreenMessage, ModesMessage, LineStore } from "@cplieger/web-terminal-engine";
11
+
12
+ /** Cancels a subscription or registration. Idempotent by convention. */
13
+ export type Unsubscribe = () => void;
14
+
15
+ // --- Layout regions (section 22.13) ---
16
+
17
+ /** The named layout regions the kernel owns. A feature mounts its chrome into
18
+ * one of these; the region owns position, spacing, stack direction, z-order,
19
+ * and the keyboard-inset lift. */
20
+ export type RegionName =
21
+ | "top-bar" // desktop tab strip
22
+ | "bottom-inset-end" // thumb-zone control stack (keys, scroll-to-bottom), lifts above the keyboard
23
+ | "bottom-switcher" // mobile tab switcher, lifted above the keyboard
24
+ | "overlay" // viewport-clamped non-modal popovers (context menu)
25
+ | "sheet" // modal bottom sheet (tab overview): focus-trapped, scrim
26
+ | "banner"; // connection status + toasts, one shared stacking context
27
+
28
+ /** A named ordering slot within a region, so features compose in a defined
29
+ * order instead of guessing z-indices. DOM order within a region always equals
30
+ * visual order (WCAG 2.4.3). */
31
+ export type RegionSlot = string;
32
+
33
+ // --- Session references ---
34
+
35
+ /** A reference to a session, carried on switch events and onSwitch. */
36
+ export interface SessionRef {
37
+ readonly id: string;
38
+ }
39
+
40
+ /** Read-only view of the active session for features (active id, size, resume
41
+ * bounds). Features never touch the raw connection/outbox/resume layer. */
42
+ export interface SessionView {
43
+ /** Active session id, or null in the unmanaged single-terminal case. */
44
+ readonly id: string | null;
45
+ /** Current terminal size in cells. */
46
+ size(): { cols: number; rows: number };
47
+ /** Highest absolute line index the active store holds (-1 if empty). */
48
+ highestIndex(): number;
49
+ }
50
+
51
+ // --- Connection state (mirrors the kernel's connection-state machine) ---
52
+
53
+ /** Connection state the kernel owns and broadcasts on `connection:state`. */
54
+ export type ConnState = "open" | "connecting" | "reconnecting" | "offline" | "restarted";
55
+
56
+ // --- Engine drive handles ---
57
+ //
58
+ // Features drive the engine's render and scroll surfaces through these handles:
59
+ // the subset of the engine's render/scroll modules features legitimately need.
60
+ // The kernel assigns the real engine namespaces here, so TS verifies they
61
+ // satisfy the subset (drift is caught at the kernel). The connection layer is
62
+ // deliberately NOT exposed: features send only through ctx.send / ctx.paste (the
63
+ // sanitizing funnel) and never touch the raw outbox/resume layer or the binary
64
+ // frame format (section 22.9).
65
+
66
+ /** The render methods features may drive. */
67
+ export interface RenderHandle {
68
+ setPredictedCursor(row: number, col: number, active: boolean): void;
69
+ getCursorPx(): { left: number; top: number; cellH: number };
70
+ computeSize(): { cols: number; rows: number };
71
+ /** Point the renderer at a store and rebuild from it (tabs, on switch). */
72
+ bind(store: LineStore): void;
73
+ /** The store the renderer is currently bound to. */
74
+ boundStore(): LineStore;
75
+ /** Highest absolute line index the active store holds (-1 if empty). */
76
+ getHighestIndex(): number;
77
+ }
78
+
79
+ /** The scroll methods features may drive. */
80
+ export interface ScrollHandle {
81
+ scrollToBottom(): void;
82
+ isUserScrolledUp(): boolean;
83
+ }
84
+
85
+ // --- Typed event bus payloads (section 22.4) ---
86
+
87
+ /** The inbound-wire + lifecycle events features can subscribe to via ctx.on.
88
+ * No `unknown`: each event carries a typed payload. */
89
+ export interface TerminalEvents {
90
+ /** A tab switch completed; the payload is the newly-active session. */
91
+ "session:switch": SessionRef;
92
+ /** The kernel's connection state changed. */
93
+ "connection:state": ConnState;
94
+ /** A session's process exited (from the status SSE; reaches background tabs). */
95
+ "session:exited": { session: string };
96
+ /** An OSC title change for a session. */
97
+ "wire:title": { session: string; title: string };
98
+ /** Inbound OSC 52 clipboard content. */
99
+ "wire:clipboard": string;
100
+ /** A modes frame (mouse-mode gating, reverse video). */
101
+ "wire:modes": ModesMessage;
102
+ /** A screen frame (first-paint / activity hooks). */
103
+ "wire:screen": ScreenMessage;
104
+ /** The layout breakpoint changed (desktop <-> mobile), so a feature re-homes
105
+ * its chrome across regions (section 22.13). */
106
+ "layout:change": { mobile: boolean };
107
+ /** The user scrolled away from / back to the bottom (drives the
108
+ * scroll-to-bottom affordance). */
109
+ "scroll:state": { scrolledUp: boolean };
110
+ /** The rendered cursor position settled after a flush, so an overlay (the
111
+ * predicted cursor) can re-position against fresh row geometry. Payload-less. */
112
+ "render:cursor": undefined;
113
+ }
114
+
115
+ // --- Accessibility primitives (kernel-owned, single source) ---
116
+
117
+ /** Controls the ARIA tablist/tabpanel seam on the kernel's output surface. The
118
+ * kernel owns the tabpanel (its output surface); `tabs` registers each tab
119
+ * button through this so it never crosses into kernel-owned DOM. */
120
+ export interface TablistController {
121
+ /** The output surface's element id, for a tab's aria-controls. */
122
+ panelId(): string;
123
+ /** Register a tab button: sets role=tab + aria-controls on it and returns a
124
+ * handle to update its selected state and label, or remove it. */
125
+ registerTab(tab: HTMLElement): TabHandle;
126
+ }
127
+
128
+ /** Handle to one registered tab's ARIA state. */
129
+ export interface TabHandle {
130
+ /** Mark this tab selected; the kernel points the panel's aria-labelledby at it. */
131
+ setSelected(selected: boolean): void;
132
+ /** Set the accessible label (used for aria-labelledby on the panel). */
133
+ setLabel(text: string): void;
134
+ /** Deregister this tab. */
135
+ remove(): void;
136
+ }
137
+
138
+ // --- The context handed to each feature's setup ---
139
+
140
+ /** Everything a feature is given at setup. The only surface features use to
141
+ * affect the terminal; it closes the round-3 holes (typed APIs by reference,
142
+ * ctx.use tokens, a typed bus, a defined lifecycle, kernel-owned a11y). */
143
+ export interface TerminalContext {
144
+ /** Mount chrome into a named kernel region; DOM order in a region equals
145
+ * visual order. Returns a live element the feature appends into. */
146
+ region(name: RegionName, slot?: RegionSlot): HTMLElement;
147
+ /** The terminal scroll surface, for features that attach surface-level
148
+ * gestures (contextMenu right-click / long-press, the tabs swipe) or scope a
149
+ * selection to the output. Read-only use; features own only their region
150
+ * chrome (section 22.9, single-operator trust). */
151
+ surface(): HTMLElement;
152
+
153
+ /** The single sanitizing, session-routed input path: bracket, strip control
154
+ * bytes, normalize NBSP, apply the col-0 backspace brake. Features never
155
+ * touch the raw socket. */
156
+ send(bytes: Uint8Array): void;
157
+ /** Paste text through the sanitizing funnel (bracketed-paste + newline
158
+ * normalization). */
159
+ paste(text: string): void;
160
+ /** Register an outbound-byte transform (e.g. mobileToolbar sticky-Ctrl). The
161
+ * kernel composes transforms in registration order around send; a transform
162
+ * returning an empty array drops the input (e.g. the col-0 backspace brake). */
163
+ registerInputTransform(fn: (bytes: Uint8Array) => Uint8Array): Unsubscribe;
164
+ /** Observe accepted outbound bytes (e.g. predictiveEcho advances its cursor). */
165
+ registerInputObserver(fn: (bytes: Uint8Array) => void): Unsubscribe;
166
+ /** Register a keydown handler that runs before the kernel's default key
167
+ * mapping. Return true to consume the event (the handler owns
168
+ * preventDefault); the kernel then sends nothing for it. Used by clipboard
169
+ * (Ctrl+Shift+C/V) and contextMenu (Escape-to-close). Runs in registration
170
+ * order; the first to return true wins. */
171
+ registerKeydown(fn: (ev: KeyboardEvent) => boolean): Unsubscribe;
172
+
173
+ /** Drive the engine renderer (never the raw resume layer). */
174
+ readonly render: RenderHandle;
175
+ /** Drive the engine scroll controller. */
176
+ readonly scroll: ScrollHandle;
177
+ /** Read-only active-session view. */
178
+ readonly session: SessionView;
179
+
180
+ /** Subscribe to a typed terminal event. Auto-disposed on destroy. */
181
+ on<K extends keyof TerminalEvents>(e: K, fn: (p: TerminalEvents[K]) => void): Unsubscribe;
182
+
183
+ /** Look up a peer feature's typed API by its factory value, or undefined if
184
+ * that feature is absent or not yet set up. Read lazily (at interaction
185
+ * time) so ordering within the feature list does not matter at runtime. */
186
+ use<A>(feature: TerminalFeature<A>): A | undefined;
187
+
188
+ /** Show a transient toast on the kernel-owned toast surface (a shared
189
+ * primitive, section 22.3), so a feature signals "Copied" etc. without
190
+ * owning its own surface and without needing connectionBanner present. */
191
+ toast(message: string, ms?: number): void;
192
+ /** Announce a message on the single kernel-owned polite (or assertive) live
193
+ * region, so features do not spawn competing aria-live regions. */
194
+ announce(message: string, politeness?: "polite" | "assertive"): void;
195
+ /** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
196
+ tablist(): TablistController;
197
+
198
+ /** Switch the live terminal to a session. tabs binds the renderer to the
199
+ * session's cached store (ctx.render.bind) first, then calls this; the kernel
200
+ * reconnects the terminal WS to that session (connection.setSession, using
201
+ * its per-tab resume state), invokes every feature's onSwitch (ordered,
202
+ * before input resumes), and emits session:switch for pure observers
203
+ * (sections 5, 22.4). Updates the active session the SessionView reports.
204
+ * This is how tabs drives the reconnect-on-switch swap without touching the
205
+ * raw connection layer (section 22.9). */
206
+ notifySwitch(session: SessionRef): void;
207
+ /** Drop a session's per-tab resume state (on tab close), so its outbox and
208
+ * byte counters are released. The kernel routes this to the connection
209
+ * layer; features never touch it directly. */
210
+ dropSession(id: string): void;
211
+
212
+ /** Observe feature errors (a feature's runtime callback threw). */
213
+ onError(fn: (feature: string, err: unknown) => void): Unsubscribe;
214
+ }
215
+
216
+ // --- The feature interface ---
217
+
218
+ /** A feature is a factory value implementing this: a name and a setup the
219
+ * kernel runs once. `Api` is the feature's own typed public API (void when it
220
+ * exposes none). */
221
+ export interface TerminalFeature<Api = void> {
222
+ readonly name: string;
223
+ setup(ctx: TerminalContext): FeatureInstance<Api> | Promise<FeatureInstance<Api>>;
224
+ /** Populated by the kernel after setup with the instance's api, so a consumer
225
+ * holding the feature value can read it (e.g. `tabs.api?.create()`). Read
226
+ * it lazily; it is undefined until the kernel has run this feature's setup.
227
+ * Readonly so a feature is covariant in Api (a TerminalFeature<X> is a
228
+ * TerminalFeature<unknown>); the kernel sets it through a narrow cast. */
229
+ readonly api?: Api;
230
+ /** True if this feature owns session selection and drives the first
231
+ * connection itself (via ctx.notifySwitch once it has resolved a session id).
232
+ * The kernel reads this synchronously from the feature list and, when any
233
+ * feature sets it, SKIPS its own startup connect: connecting to the bare
234
+ * wsPath before a session exists would hit a session-gated /ws (the
235
+ * SessionManager 404s a /ws with no ?session=) and churn the reconnect
236
+ * backoff until the feature set a session. `tabs` sets this; single-terminal
237
+ * presets leave it unset so the kernel connects to the bare wsPath as before. */
238
+ readonly managesSessions?: boolean;
239
+ }
240
+
241
+ /** What a feature's setup returns: its optional typed API, a teardown, and an
242
+ * optional onSwitch the kernel invokes on a tab switch. */
243
+ export interface FeatureInstance<Api = void> {
244
+ /** This feature's public API, surfaced on the feature value and via ctx.use. */
245
+ readonly api?: Api;
246
+ /** Remove this feature's DOM and listeners. Run in reverse order on destroy. */
247
+ teardown(): void;
248
+ /** Called by the kernel at the START of a tab switch (detach), before the
249
+ * connection is re-pointed at the incoming session, for features holding
250
+ * latched input state that must not fire against the next session: the mobile
251
+ * toolbar disarms its one-shot sticky-Ctrl here so a pending Ctrl cannot
252
+ * become an accidental Ctrl+C to the wrong agent (design 5.1). Runs for every
253
+ * feature before any onSwitch. */
254
+ onDetach?(): void;
255
+ /** Called by the kernel on a tab switch (attach), after the connection is
256
+ * re-pointed, for features that must re-point before input resumes
257
+ * (session:switch on the bus is for pure observers). */
258
+ onSwitch?(session: SessionRef): void;
259
+ }
260
+
261
+ // --- Entry point ---
262
+
263
+ /** Options for createTerminal. */
264
+ export interface CreateTerminalOptions {
265
+ /** The feature list; omitted or empty means the bare kernel (no chrome).
266
+ * Heterogeneous feature APIs are held as unknown here; a consumer reads a
267
+ * specific feature's api off the feature value it holds. */
268
+ features?: readonly TerminalFeature<unknown>[];
269
+ /** WebSocket endpoint path (default "/ws"). */
270
+ wsPath?: string;
271
+ /** CSS font shorthand awaited before the first resize. */
272
+ fontReady?: string;
273
+ /** Optional pre-JS loading overlay the kernel fades out on first paint. */
274
+ loading?: HTMLElement;
275
+ }
276
+
277
+ /** The handle createTerminal returns. Feature APIs are not materialized here
278
+ * (that could not be typed soundly); a consumer holds the feature value and
279
+ * reads its `api`. */
280
+ export interface TerminalHandle {
281
+ /** Focus the terminal input (opens the soft keyboard on touch). */
282
+ focus(): void;
283
+ /** Tear down every feature in reverse order, dispose all subscriptions, and
284
+ * release the kernel's DOM and engine wiring. */
285
+ destroy(): void;
286
+ }
package/src/presets.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Feature presets (design section 22.6). Presets are plain feature-array
2
+ // factories, so they are spreadable and editable: a consumer can drop or add a
3
+ // feature with a filter/spread. Importing a preset pulls in every feature it
4
+ // lists (convenient, full UI); a consumer wanting a minimal footprint imports
5
+ // individual features instead.
6
+ //
7
+ // presetTabbed (tabs + activityMonitor) is added once those features land.
8
+
9
+ import type { TerminalFeature } from "./kernel/types.js";
10
+ import { clipboard } from "./features/clipboard.js";
11
+ import { contextMenu } from "./features/context-menu.js";
12
+ import { scrollToBottom } from "./features/scroll-to-bottom.js";
13
+ import { predictiveEcho } from "./features/predictive-echo.js";
14
+ import { connectionBanner } from "./features/connection-banner.js";
15
+ import { mobileToolbar } from "./features/mobile-toolbar.js";
16
+ import { tabs } from "./features/tabs.js";
17
+ import { activityMonitor } from "./features/activity-monitor.js";
18
+ import { animations } from "./features/animations.js";
19
+
20
+ /** Single-pane desktop UI: context menu, clipboard, scroll-to-bottom, predictive
21
+ * echo, and the connection banner. No mobile toolbar, no tabs. */
22
+ export function presetSingle(): TerminalFeature<unknown>[] {
23
+ // contextMenu offers Copy/Paste through the clipboard feature (ctx.use), so it
24
+ // holds the same clipboard value the array includes.
25
+ const clip = clipboard();
26
+ return [
27
+ contextMenu({ clipboard: clip }),
28
+ clip,
29
+ scrollToBottom(),
30
+ predictiveEcho(),
31
+ connectionBanner(),
32
+ ];
33
+ }
34
+
35
+ /** Touch-first UI: presetSingle plus the on-screen key toolbar. */
36
+ export function presetTouch(): TerminalFeature<unknown>[] {
37
+ return [...presetSingle(), mobileToolbar()];
38
+ }
39
+
40
+ /** Full reference UI: presetTouch plus tabs, the activity monitor, and
41
+ * animations. activityMonitor is ordered before tabs because tabs reads its
42
+ * API via ctx.use during setup (to wire status dots). */
43
+ export function presetTabbed(): TerminalFeature<unknown>[] {
44
+ const monitor = activityMonitor();
45
+ return [...presetTouch(), monitor, tabs({ activityMonitor: monitor }), animations()];
46
+ }