@cplieger/web-terminal-ui 3.5.0 → 4.1.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 (41) hide show
  1. package/README.md +60 -27
  2. package/css/00-tokens.css +18 -2
  3. package/css/01-scope.css +76 -0
  4. package/css/{02-app.css → 02-terminal.css} +113 -239
  5. package/css/10-primitives.css +98 -0
  6. package/css/20-connection-banner.css +42 -0
  7. package/css/21-scroll-to-bottom.css +65 -0
  8. package/css/22-context-menu.css +45 -0
  9. package/css/23-toolbar.css +181 -0
  10. package/css/{05-tabs.css → 30-tabs.css} +127 -66
  11. package/css/{06-mobile.css → 31-switcher.css} +85 -80
  12. package/css/{07-animations.css → 40-animations.css} +15 -15
  13. package/css/MANIFEST +19 -11
  14. package/css/MANIFEST.single +12 -0
  15. package/css/MANIFEST.tabbed +16 -0
  16. package/css/MANIFEST.touch +12 -0
  17. package/css/{01-reset.css → page.css} +18 -19
  18. package/package.json +19 -6
  19. package/scaffold/index.html +5 -0
  20. package/src/features/activity-monitor.ts +32 -2
  21. package/src/features/connection-banner.ts +1 -0
  22. package/src/features/context-menu.ts +6 -33
  23. package/src/features/menu-position.ts +50 -0
  24. package/src/features/mobile-toolbar.ts +5 -4
  25. package/src/features/{tabs.ts → tabs/index.ts} +310 -721
  26. package/src/features/tabs/model.ts +342 -0
  27. package/src/features/tabs/strip.ts +101 -0
  28. package/src/features/tabs/switcher.ts +129 -0
  29. package/src/index.ts +1 -0
  30. package/src/kernel/a11y.ts +30 -5
  31. package/src/kernel/conn-state.ts +16 -6
  32. package/src/kernel/kernel.ts +170 -59
  33. package/src/kernel/types.ts +68 -15
  34. package/src/presets/agent-tabbed.ts +15 -0
  35. package/src/presets/single.ts +27 -0
  36. package/src/presets/tabbed.ts +42 -0
  37. package/src/presets/touch.ts +13 -0
  38. package/src/presets.ts +12 -76
  39. package/src/viewport.ts +28 -15
  40. package/css/03-primitives.css +0 -83
  41. package/css/04-feedback.css +0 -180
@@ -38,6 +38,9 @@ export interface ConnStateMachine {
38
38
  * the state shows immediately and persists until open() (a switch to a
39
39
  * live session) replaces it. */
40
40
  ended(): void;
41
+ /** The engine refused an explicitly incompatible wire revision. This
42
+ * terminal state bypasses the loading gate and persists until open(). */
43
+ incompatible(): void;
41
44
  /** First screen frame rendered: the loading overlay is done, so the banner
42
45
  * may show reconnect state from here on. */
43
46
  setLoaded(): void;
@@ -59,13 +62,14 @@ export function createConnState(opts: {
59
62
 
60
63
  function emit(): void {
61
64
  // Suppress transient states until the initial load is over (the loading
62
- // overlay owns the screen), with two exceptions that must show through:
63
- // "offline" once the initial-failure limit is reached, and "ended" (a
64
- // definitive process exit — nothing further will ever fire, so suppressing
65
- // it would hide the only explanation the page will get). connectionBanner
66
- // renders "open" as hidden.
65
+ // overlay owns the screen). Terminal states ("ended" and "incompatible")
66
+ // must pass through because no later frame or reconnect will explain the
67
+ // failure; "offline" also passes once the initial-failure limit is reached.
68
+ // connectionBanner renders "open" as hidden.
67
69
  const passesLoadingGate =
68
- state === "ended" || (state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT);
70
+ state === "ended" ||
71
+ state === "incompatible" ||
72
+ (state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT);
69
73
  if (!loaded && !passesLoadingGate) {
70
74
  opts.onState("open"); // nothing to show yet
71
75
  return;
@@ -130,6 +134,12 @@ export function createConnState(opts: {
130
134
  consecutiveFailures = 0;
131
135
  setState("ended", 0);
132
136
  },
137
+ incompatible(): void {
138
+ // Wire refusal is terminal for this page instance. Like ended, it is not
139
+ // part of the failure ladder and must remain visible without a timer.
140
+ consecutiveFailures = 0;
141
+ setState("incompatible", 0);
142
+ },
133
143
  setLoaded(): void {
134
144
  loaded = true;
135
145
  },
@@ -49,6 +49,24 @@ const TAP_MOVEMENT_PX = 10;
49
49
  // native text selection / the context menu — so tap-to-focus bows out above
50
50
  // this duration and never steals a long-press or a selection.
51
51
  const TAP_MAX_MS = 500;
52
+ // The narrow-layout breakpoints, in ROOT pixels. The single source of the
53
+ // numbers: the kernel mirrors the resulting fact into the .wt-narrow root
54
+ // class for CSS (paired there with pointer-coarseness media queries where
55
+ // touch matters) and features read the same fact via ctx.layout(). Root
56
+ // dimensions — not viewport dimensions — so an embedded terminal in a narrow
57
+ // panel counts as narrow. For every full-page consumer root size equals
58
+ // viewport size, so the width half matches the old (max-width: 600px) media
59
+ // queries exactly.
60
+ const NARROW_MAX_PX = 600;
61
+ // A root can be compact by HEIGHT as well as width: a large phone rotated to
62
+ // landscape (e.g. an iPhone 14 Pro Max at 932x430) is wider than
63
+ // NARROW_MAX_PX, but its short viewport is still a phone in the hand — the
64
+ // thumb-reach switcher bar is the right UX and the desktop strip wastes
65
+ // scarce rows. Tablets are never this short (the smallest iPad is 744 CSS px
66
+ // tall in landscape), so height <= 500 cleanly separates "landscape phone"
67
+ // from "tablet/desktop" with margin on both sides (the tallest current
68
+ // phones are ~440 in landscape, less with browser chrome).
69
+ const SHORT_MAX_PX = 500;
52
70
 
53
71
  // Kernel-owned core subtree: the display-only output, the hidden textarea (the
54
72
  // single keyboard target), and the IME composition view. No chrome; features
@@ -75,6 +93,16 @@ export function createTerminal(
75
93
  const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
76
94
  const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
77
95
  const featureList = opts.features ?? [];
96
+ // At most one feature owns session selection (fail fast, before any DOM
97
+ // work): the kernel drives the first connect through its registration.
98
+ const owners = featureList.filter((f) => f.sessionOwner !== undefined);
99
+ if (owners.length > 1) {
100
+ throw new Error(
101
+ `web-terminal-ui: multiple session-owning features: ${owners.map((f) => f.name).join(", ")}`,
102
+ );
103
+ }
104
+ const sessionOwner = owners[0]?.sessionOwner;
105
+ const sessionOwnerName = owners[0]?.name ?? "";
78
106
  const encoder = new TextEncoder();
79
107
  const kernelAbort = new AbortController();
80
108
  const { signal } = kernelAbort;
@@ -91,6 +119,15 @@ export function createTerminal(
91
119
  }
92
120
  }
93
121
 
122
+ // Stamp the boundary classes: .wt-root scopes every library token and style
123
+ // rule to this subtree, and the layout-mode class decides how the root claims
124
+ // space — wt-viewport pins the root itself to the full viewport (the
125
+ // full-page product), wt-container fills the parent element (the embedded
126
+ // case). All chrome positions against the root, never the page. destroy()
127
+ // removes them.
128
+ const layoutMode = opts.layout ?? "viewport";
129
+ root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
130
+
94
131
  // --- Build the core subtree ---
95
132
  const tpl = document.createElement("template");
96
133
  tpl.innerHTML = CORE_TEMPLATE;
@@ -106,6 +143,22 @@ export function createTerminal(
106
143
  const announcer = createAnnouncer(root);
107
144
  const tablistController = createTablist(outputEl);
108
145
 
146
+ // --- Narrow-layout driver (.wt-narrow mirrors root size for CSS) ---
147
+ // Narrow = compact in EITHER dimension: skinny (a phone in portrait, a
148
+ // narrow embedded panel) or short (a phone in landscape). The one
149
+ // ResizeObserver below fires on both width and height changes.
150
+ const isNarrow = (): boolean =>
151
+ root.clientWidth <= NARROW_MAX_PX || root.clientHeight <= SHORT_MAX_PX;
152
+ function updateNarrow(): void {
153
+ root.classList.toggle("wt-narrow", isNarrow());
154
+ }
155
+ updateNarrow();
156
+ let narrowObserver: ResizeObserver | null = null;
157
+ if (typeof ResizeObserver === "function") {
158
+ narrowObserver = new ResizeObserver(updateNarrow);
159
+ narrowObserver.observe(root);
160
+ }
161
+
109
162
  // Toast surface (kernel-owned shared primitive, section 22.3).
110
163
  const toastEl = document.createElement("div");
111
164
  toastEl.className = "wt-toast";
@@ -187,13 +240,13 @@ export function createTerminal(
187
240
  let activeSession: SessionRef | null = null;
188
241
  // Whether the first connection has been kicked off. The wake-reconnect
189
242
  // handlers (visibilitychange/pageshow/online) must not open a socket before
190
- // it has: a session-managing feature (tabs) drives the first connect via
191
- // notifySwitch once it resolves a session id, and connecting to the bare
192
- // wsPath before then hits a session-gated endpoint (the SessionManager 404s a
193
- // /ws with no ?session=). pageshow fires on the initial load, so without this
194
- // gate a slow session list lets pageshow's reconnectNow open a bare /ws that
195
- // 404s (seen in Firefox, where the list loses the race). Flips true on the
196
- // startup connect (unmanaged) or the first setSession (managed).
243
+ // it has: under a session owner the kernel drives the first connect only
244
+ // once resolveInitialSession() returns a session id, and connecting to the
245
+ // bare wsPath before then hits a session-gated endpoint (the SessionManager
246
+ // 404s a /ws with no ?session=). pageshow fires on the initial load, so
247
+ // without this gate a slow session list lets pageshow's reconnectNow open a
248
+ // bare /ws that 404s (seen in Firefox, where the list loses the race). Flips
249
+ // true on the startup connect (no owner) or the first setSession (owned).
197
250
  let connectionInitiated = false;
198
251
 
199
252
  // --- Loading lifecycle ---
@@ -326,6 +379,13 @@ export function createTerminal(
326
379
  markReady();
327
380
  connState.ended();
328
381
  },
382
+ onWireIncompatible() {
383
+ // Compatibility refusal is terminal for this page instance. Lower the
384
+ // pre-JS overlay even if no screen frame arrived, then leave the
385
+ // actionable banner visible while the engine suppresses reconnects.
386
+ markReady();
387
+ connState.incompatible();
388
+ },
329
389
  onServerRestart() {
330
390
  render.resetScrollback();
331
391
  render.resetScreen();
@@ -544,6 +604,7 @@ export function createTerminal(
544
604
  // --- Viewport ---
545
605
  viewport.init({
546
606
  termWrap,
607
+ root,
547
608
  suppressKeyboardInset: hasFinePointer,
548
609
  onSettled() {
549
610
  render.updateFontMetrics();
@@ -615,6 +676,42 @@ export function createTerminal(
615
676
  const apiMap = new Map<TerminalFeature<unknown>, unknown>();
616
677
  const instances: { feature: TerminalFeature<unknown>; instance: FeatureInstance<unknown> }[] = [];
617
678
 
679
+ // The one switch path: ctx.notifySwitch (tab switches) and the kernel's own
680
+ // owner-resolved first connect both land here.
681
+ function performSwitch(session: SessionRef): void {
682
+ // A feature's un-cancelled async (tabs create()/pollOnce()) can resolve
683
+ // after destroy() and request a switch; ignore it so a torn-down terminal
684
+ // never re-points or reopens the socket (connection.setSession below).
685
+ if (isDestroyed()) {
686
+ return;
687
+ }
688
+ // Detach (design 5.1): make input safe before the socket is re-pointed.
689
+ // End any in-flight IME composition and clear the textarea so
690
+ // half-composed text is not delivered to either session, and let every
691
+ // feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
692
+ // cannot fire against the incoming session. This all runs before
693
+ // setSession, and the switch is synchronous, so input is inert between
694
+ // detach here and the onSwitch attach below.
695
+ composition.cancelComposition();
696
+ resetToPlaceholder(input);
697
+ for (const { instance } of instances) {
698
+ instance.onDetach?.();
699
+ }
700
+ activeSession = session;
701
+ // Reconnect the terminal WS to this session using its per-tab resume
702
+ // state; the renderer was already pointed at its store by the owner.
703
+ connection.setSession(session.id);
704
+ // The owned first connect has happened (session id is now on the WS URL);
705
+ // wake-reconnect handlers may fire from here on.
706
+ connectionInitiated = true;
707
+ for (const { instance } of instances) {
708
+ if (instance.onSwitch) {
709
+ instance.onSwitch(session);
710
+ }
711
+ }
712
+ bus.emit("session:switch", session);
713
+ }
714
+
618
715
  function makeContext(featureName: string): TerminalContext {
619
716
  return {
620
717
  region: (name, slot) => regions.region(name, slot),
@@ -678,38 +775,13 @@ export function createTerminal(
678
775
  announcer.announce(message, politeness);
679
776
  },
680
777
  tablist: () => tablistController,
778
+ layout: () => ({
779
+ narrow: isNarrow(),
780
+ coarse:
781
+ typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
782
+ }),
681
783
  notifySwitch(session) {
682
- // A feature's un-cancelled async (tabs bootstrap/create()/pollOnce()) can
683
- // resolve after destroy() and call this; ignore it so a torn-down terminal
684
- // never re-points or reopens the socket (connection.setSession below).
685
- if (isDestroyed()) {
686
- return;
687
- }
688
- // Detach (design 5.1): make input safe before the socket is re-pointed.
689
- // End any in-flight IME composition and clear the textarea so
690
- // half-composed text is not delivered to either session, and let every
691
- // feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
692
- // cannot fire against the incoming session. This all runs before
693
- // setSession, and the switch is synchronous, so input is inert between
694
- // detach here and the onSwitch attach below.
695
- composition.cancelComposition();
696
- resetToPlaceholder(input);
697
- for (const { instance } of instances) {
698
- instance.onDetach?.();
699
- }
700
- activeSession = session;
701
- // Reconnect the terminal WS to this session using its per-tab resume
702
- // state; the renderer was already pointed at its store by tabs.
703
- connection.setSession(session.id);
704
- // The managed first connect has happened (session id is now on the WS
705
- // URL); wake-reconnect handlers may fire from here on.
706
- connectionInitiated = true;
707
- for (const { instance } of instances) {
708
- if (instance.onSwitch) {
709
- instance.onSwitch(session);
710
- }
711
- }
712
- bus.emit("session:switch", session);
784
+ performSwitch(session);
713
785
  },
714
786
  dropSession(id) {
715
787
  connection.forgetSession(id);
@@ -744,10 +816,14 @@ export function createTerminal(
744
816
  instances.length = 0;
745
817
  }
746
818
 
747
- async function setupFeatures(): Promise<void> {
819
+ // Resolves true when every feature set up; false when setup was aborted (a
820
+ // feature threw and everything rolled back, or destroy() ran mid-setup). The
821
+ // session-owner first connect below runs only on true — a resolver must never
822
+ // run against torn-down chrome.
823
+ async function setupFeatures(): Promise<boolean> {
748
824
  for (const feature of featureList) {
749
825
  if (destroyed) {
750
- return;
826
+ return false;
751
827
  }
752
828
  try {
753
829
  const instance = await feature.setup(makeContext(feature.name));
@@ -762,7 +838,7 @@ export function createTerminal(
762
838
  } catch (err) {
763
839
  reportError(feature.name, err);
764
840
  }
765
- return;
841
+ return false;
766
842
  }
767
843
  instances.push({ feature, instance });
768
844
  // Populate the feature value's readonly api (consumer pattern:
@@ -778,27 +854,47 @@ export function createTerminal(
778
854
  `web-terminal-ui: feature "${feature.name}" setup failed; terminal has no chrome`,
779
855
  err,
780
856
  );
781
- return;
857
+ return false;
782
858
  }
783
859
  }
860
+ return true;
784
861
  }
785
- // A session-managing feature (tabs) drives the first connect itself once it
786
- // has resolved a session id (ctx.notifySwitch -> connection.setSession, which
787
- // adds ?session=<id>). Connecting here first would open a bare /ws that a
788
- // SessionManager 404s (no ?session=), flashing a disconnect banner and
789
- // churning the reconnect backoff until the feature sets a session. So skip the
790
- // startup connect when managed; the single-terminal presets leave it to us.
791
- const sessionManaged = featureList.some((f) => f.managesSessions === true);
792
-
793
862
  // Features set up in the background; first paint is never gated on them.
794
- void setupFeatures().then(() => {
795
- // A session-managing feature that finished setup WITHOUT initiating a
796
- // connection means its bootstrap failed (no session could be listed or
797
- // spawned) or its setup threw. markReady (needs a screen frame) and onGiveUp
798
- // (needs the connect to be attempted and fail) then never fire, so the
799
- // loading overlay would stay up forever, hiding the retry chrome the feature
800
- // keeps alive (tabs' "+"). Dismiss it so the page shows its real state.
801
- if (sessionManaged && !connectionInitiated && !destroyed) {
863
+ // Under a session owner the kernel then drives the first connect itself:
864
+ // await the owner's resolveInitialSession() and switch to the session it
865
+ // returns through the same path a tab switch uses. Connecting to the bare
866
+ // wsPath first would open a /ws that a SessionManager 404s (no ?session=),
867
+ // flashing a disconnect banner and churning the reconnect backoff — hence no
868
+ // startup connect below when an owner is registered. A null (or thrown)
869
+ // resolution means the bootstrap failed: the owner keeps its retry chrome
870
+ // alive, and the kernel — which now SEES the failure instead of inferring it
871
+ // from a missing side effect — dismisses the loading overlay so that chrome
872
+ // is visible instead of an eternal spinner.
873
+ void setupFeatures().then(async (ok) => {
874
+ if (!ok) {
875
+ // Setup aborted (rolled back or destroyed). Under a session owner no
876
+ // connect will ever be attempted, so nothing else can lower the loading
877
+ // overlay — dismiss it so the page shows its real state.
878
+ if (!connectionInitiated && !destroyed) {
879
+ dismissLoadingOverlay();
880
+ }
881
+ return;
882
+ }
883
+ if (!sessionOwner || isDestroyed()) {
884
+ return;
885
+ }
886
+ let resolved: SessionRef | null = null;
887
+ try {
888
+ resolved = await sessionOwner.resolveInitialSession();
889
+ } catch (err) {
890
+ reportError(sessionOwnerName, err);
891
+ }
892
+ if (isDestroyed()) {
893
+ return;
894
+ }
895
+ if (resolved) {
896
+ performSwitch(resolved);
897
+ } else if (!connectionInitiated) {
802
898
  dismissLoadingOverlay();
803
899
  }
804
900
  });
@@ -806,7 +902,7 @@ export function createTerminal(
806
902
  // --- Connect + focus ---
807
903
  render.updateFontMetrics();
808
904
  composition.positionCompositionView();
809
- if (!sessionManaged) {
905
+ if (!sessionOwner) {
810
906
  connection.connect();
811
907
  connectionInitiated = true;
812
908
  }
@@ -814,6 +910,19 @@ export function createTerminal(
814
910
 
815
911
  return {
816
912
  focus: focusTerminal,
913
+ send(bytes) {
914
+ if (destroyed) {
915
+ return;
916
+ }
917
+ sendBytes(bytes);
918
+ },
919
+ reset() {
920
+ if (destroyed) {
921
+ return;
922
+ }
923
+ render.resetScrollback();
924
+ render.resetScreen();
925
+ },
817
926
  destroy() {
818
927
  if (destroyed) {
819
928
  return;
@@ -821,6 +930,7 @@ export function createTerminal(
821
930
  destroyed = true;
822
931
  teardownFeatures();
823
932
  kernelAbort.abort();
933
+ narrowObserver?.disconnect();
824
934
  viewport.teardown();
825
935
  // Reset the composition singleton (kernel-driven, no feature teardown): a
826
936
  // destroy() mid-IME-composition otherwise leaves module-level `composing`
@@ -839,6 +949,7 @@ export function createTerminal(
839
949
  bus.clear();
840
950
  announcer.destroy();
841
951
  regions.destroy();
952
+ root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
842
953
  root.replaceChildren();
843
954
  },
844
955
  };
@@ -60,7 +60,10 @@ export type ConnState =
60
60
  // The active session's process has EXITED (the engine's definitive 4001
61
61
  // close): not a connectivity problem, so no reconnect is coming. Cleared
62
62
  // only by connecting somewhere live (a tab switch / a new session).
63
- | "ended";
63
+ | "ended"
64
+ // The engine refused an explicitly incompatible wire revision. This is a
65
+ // terminal state, not a transient disconnect; page reload/update is required.
66
+ | "incompatible";
64
67
 
65
68
  // --- Engine drive handles ---
66
69
  //
@@ -89,6 +92,13 @@ export interface RenderHandle {
89
92
  export interface ScrollHandle {
90
93
  scrollToBottom(): void;
91
94
  isUserScrolledUp(): boolean;
95
+ /** The viewport's current scroll offset — the read half of per-tab scroll
96
+ * memory. The engine's scroll controller owns the container's scrollTop;
97
+ * features go through this seam, never the DOM element (engine >= v3). */
98
+ currentScrollTop(): number;
99
+ /** Restore a saved offset; follow/hold re-derives from the resulting scroll
100
+ * event exactly as for a user scroll — the write half of scroll memory. */
101
+ restoreScrollTop(top: number): void;
92
102
  }
93
103
 
94
104
  // --- Typed event bus payloads (section 22.4) ---
@@ -110,9 +120,6 @@ export interface TerminalEvents {
110
120
  "wire:modes": ModesMessage;
111
121
  /** A screen frame (first-paint / activity hooks). */
112
122
  "wire:screen": ScreenMessage;
113
- /** The layout breakpoint changed (desktop <-> mobile), so a feature re-homes
114
- * its chrome across regions (section 22.13). */
115
- "layout:change": { mobile: boolean };
116
123
  /** The user scrolled away from / back to the bottom (drives the
117
124
  * scroll-to-bottom affordance). */
118
125
  "scroll:state": { scrolledUp: boolean };
@@ -204,6 +211,16 @@ export interface TerminalContext {
204
211
  /** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
205
212
  tablist(): TablistController;
206
213
 
214
+ /** The current layout facts a feature keys touch-vs-desktop behavior on.
215
+ * `narrow` is a ROOT compact in EITHER dimension against the kernel's
216
+ * breakpoints: skinny (width, a portrait phone or a narrow embedded panel)
217
+ * or short (height, a landscape phone) — the same fact the .wt-narrow root
218
+ * class exposes to CSS; root size, not viewport size, so an embedded
219
+ * terminal in a narrow panel counts as narrow. `coarse` is the primary
220
+ * pointer's coarseness (a live media-query read). Read lazily at
221
+ * interaction time. */
222
+ layout(): { narrow: boolean; coarse: boolean };
223
+
207
224
  /** Switch the live terminal to a session. tabs binds the renderer to the
208
225
  * session's cached store (ctx.render.bind) first, then calls this; the kernel
209
226
  * reconnects the terminal WS to that session (connection.setSession, using
@@ -236,15 +253,32 @@ export interface TerminalFeature<Api = void> {
236
253
  * Readonly so a feature is covariant in Api (a TerminalFeature<X> is a
237
254
  * TerminalFeature<unknown>); the kernel sets it through a narrow cast. */
238
255
  readonly api?: Api;
239
- /** True if this feature owns session selection and drives the first
240
- * connection itself (via ctx.notifySwitch once it has resolved a session id).
241
- * The kernel reads this synchronously from the feature list and, when any
242
- * feature sets it, SKIPS its own startup connect: connecting to the bare
243
- * wsPath before a session exists would hit a session-gated /ws (the
244
- * SessionManager 404s a /ws with no ?session=) and churn the reconnect
245
- * backoff until the feature set a session. `tabs` sets this; single-terminal
246
- * presets leave it unset so the kernel connects to the bare wsPath as before. */
247
- readonly managesSessions?: boolean;
256
+ /** Present on the ONE feature that owns session selection (at most one per
257
+ * terminal; createTerminal throws when two features register). Its presence
258
+ * makes the kernel SKIP the startup connect to the bare wsPath (which a
259
+ * session-gated server 404s, churning the reconnect backoff); the kernel
260
+ * instead drives the first connect through resolveInitialSession() once
261
+ * feature setup completes. Single-terminal presets leave it unset so the
262
+ * kernel connects to the bare wsPath at startup. */
263
+ readonly sessionOwner?: SessionOwnerRegistration;
264
+ }
265
+
266
+ /** The session-owner registration: how the one session-owning feature and the
267
+ * kernel split the first connect. The feature owns session selection (list /
268
+ * spawn / pick) and its own bootstrap state; the kernel owns the connect
269
+ * itself, so a failed bootstrap is SEEN by the kernel (which dismisses the
270
+ * loading overlay) instead of inferred from a missing side effect. */
271
+ export interface SessionOwnerRegistration {
272
+ /** Resolve the initial session: list (or spawn) one, build per-session
273
+ * state, and bind the renderer to the session's store (ctx.render.bind) —
274
+ * but do NOT call ctx.notifySwitch for it. The kernel performs the switch
275
+ * with the returned ref through the same path notifySwitch uses. Return
276
+ * null when no session could be resolved (the bootstrap failed): the
277
+ * feature keeps its retry chrome alive, and the kernel dismisses the
278
+ * loading overlay so that chrome is visible. A throw is treated as null
279
+ * (and reported through the feature-error channel). Called exactly once,
280
+ * after every feature's setup has resolved. */
281
+ resolveInitialSession(): Promise<SessionRef | null>;
248
282
  }
249
283
 
250
284
  /** What a feature's setup returns: its optional typed API, a teardown, and an
@@ -275,6 +309,14 @@ export interface CreateTerminalOptions {
275
309
  * Heterogeneous feature APIs are held as unknown here; a consumer reads a
276
310
  * specific feature's api off the feature value it holds. */
277
311
  features?: readonly TerminalFeature<unknown>[];
312
+ /** How the terminal claims space (default "viewport").
313
+ * "viewport": the root becomes a fixed full-viewport box — the full-page
314
+ * product (web-terminal-server, web-terminal-kiro, the scaffold page).
315
+ * "container": the root fills its parent element, which becomes the
316
+ * boundary — the embedded case (vibekit's panel). Either way the kernel
317
+ * stamps the matching class (wt-viewport / wt-container) on the root and
318
+ * every piece of chrome positions against the root, never the page. */
319
+ layout?: "viewport" | "container";
278
320
  /** WebSocket endpoint path (default "/ws"). */
279
321
  wsPath?: string;
280
322
  /** CSS font shorthand awaited before the first resize. */
@@ -286,8 +328,8 @@ export interface CreateTerminalOptions {
286
328
  * Keys must be CSS custom-property names (start with "--"); values are any
287
329
  * CSS value. The library ships the defaults (css/00-tokens.css) — the
288
330
  * "template"; these are the consumer's "settings" and override them for this
289
- * instance. Known tokens: --accent, --tab-hover-bg, --tab-active-bg,
290
- * --tab-active-fg. */
331
+ * instance. Known tokens: --accent, --tab-bg, --tab-hover-bg,
332
+ * --tab-active-bg, --tab-active-fg, --tab-active-border. */
291
333
  theme?: Readonly<Record<string, string>>;
292
334
  }
293
335
 
@@ -297,6 +339,17 @@ export interface CreateTerminalOptions {
297
339
  export interface TerminalHandle {
298
340
  /** Focus the terminal input (opens the soft keyboard on touch). */
299
341
  focus(): void;
342
+ /** Send bytes to the active session through the kernel's sanitizing,
343
+ * session-routed input funnel — the same path features use: input
344
+ * transforms apply, and the view snaps to the bottom exactly like typed
345
+ * input. The supported host path for "type this command" affordances
346
+ * (a run-in-shell button). No-op after destroy(). */
347
+ send(bytes: Uint8Array): void;
348
+ /** Reset the LOCAL display: drop the client-side scrollback and screen (the
349
+ * same reset the engine performs on a server restart). Deliberately injects
350
+ * no keystroke — a host that wants a freshly drawn prompt sends one itself
351
+ * (e.g. Ctrl+L via send()). No-op after destroy(). */
352
+ reset(): void;
300
353
  /** Tear down every feature in reverse order, dispose all subscriptions, and
301
354
  * release the kernel's DOM and engine wiring. */
302
355
  destroy(): void;
@@ -0,0 +1,15 @@
1
+ // presetAgentTabbed: the tabbed composition tuned for an agent shell.
2
+
3
+ import type { TerminalFeature } from "../kernel/types.js";
4
+ import { buildTabbed } from "./tabbed.js";
5
+
6
+ /** Tabbed UI for an agent shell (web-terminal-kiro). Same features as
7
+ * presetTabbed, but with `preferInputTitle`: the agent's program (kiro-cli)
8
+ * emits a non-empty but useless OSC 0/2 title, so each tab's label follows the
9
+ * latest submitted line (persisted server-side and recovered on reload) and
10
+ * the OSC title is ignored. The agent-vs-generic status distinction remains
11
+ * server-side (a status classifier mapping OSC 9 notifications to
12
+ * done/needs-input). */
13
+ export function presetAgentTabbed(): TerminalFeature<unknown>[] {
14
+ return buildTabbed(true);
15
+ }
@@ -0,0 +1,27 @@
1
+ // presetSingle: the single-pane desktop composition. Its own entry module so a
2
+ // consumer importing "@cplieger/web-terminal-ui/presets/single" pulls in ONLY
3
+ // this feature graph — none of the toolbar/tabs modules (the ./presets barrel
4
+ // statically reaches everything; these per-preset entries are the
5
+ // graph-minimal imports).
6
+
7
+ import type { TerminalFeature } from "../kernel/types.js";
8
+ import { clipboard } from "../features/clipboard.js";
9
+ import { contextMenu } from "../features/context-menu.js";
10
+ import { scrollToBottom } from "../features/scroll-to-bottom.js";
11
+ import { predictiveEcho } from "../features/predictive-echo.js";
12
+ import { connectionBanner } from "../features/connection-banner.js";
13
+
14
+ /** Single-pane desktop UI: context menu, clipboard, scroll-to-bottom, predictive
15
+ * echo, and the connection banner. No mobile toolbar, no tabs. */
16
+ export function presetSingle(): TerminalFeature<unknown>[] {
17
+ // contextMenu offers Copy/Paste through the clipboard feature (ctx.use), so it
18
+ // holds the same clipboard value the array includes.
19
+ const clip = clipboard();
20
+ return [
21
+ contextMenu({ clipboard: clip }),
22
+ clip,
23
+ scrollToBottom(),
24
+ predictiveEcho(),
25
+ connectionBanner(),
26
+ ];
27
+ }
@@ -0,0 +1,42 @@
1
+ // presetTabbed (and the shared tabbed composition builder): the full
2
+ // multi-session UI. Requires a server that speaks the session API
3
+ // (`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
4
+ // such as `web-terminal-server` or `web-terminal-kiro`.
5
+
6
+ import type { TerminalFeature } from "../kernel/types.js";
7
+ import { mobileToolbar } from "../features/mobile-toolbar.js";
8
+ import { tabs } from "../features/tabs/index.js";
9
+ import { activityMonitor } from "../features/activity-monitor.js";
10
+ import { animations } from "../features/animations.js";
11
+ import { presetSingle } from "./single.js";
12
+
13
+ /** buildTabbed composes the tabbed UI: the single-pane touch features, an
14
+ * externally-driven mobile toolbar (its grid is opened from a keyboard button
15
+ * in the tab bar, not its own toggle), the activity monitor (the status-SSE
16
+ * data source), tabs wired to both, and animations. The activity dot reveals
17
+ * itself per tab only when a session reports activity (OSC 9;4), so the
18
+ * monitor is always included — a plain shell just never shows a dot. The
19
+ * toolbar and monitor are ordered before tabs because tabs reads their APIs
20
+ * via ctx.use. Shared by presetTabbed and presetAgentTabbed (agent-tabbed.ts),
21
+ * which differ only in the title source. */
22
+ export function buildTabbed(preferInputTitle: boolean): TerminalFeature<unknown>[] {
23
+ const kb = mobileToolbar({ externalToggle: true });
24
+ const monitor = activityMonitor();
25
+ return [
26
+ ...presetSingle(),
27
+ kb,
28
+ monitor,
29
+ tabs({ keyboardToggle: kb, activityMonitor: monitor, preferInputTitle }),
30
+ animations(),
31
+ ];
32
+ }
33
+
34
+ /** Tabbed UI: the touch features, tabs, the mobile keyboard bar, the activity
35
+ * monitor, and animations. Each tab's title follows the process OSC 0/2 window
36
+ * title when the program sets one, else the last submitted line; each tab's
37
+ * activity dot stays hidden until its session reports activity via OSC 9;4
38
+ * progress (kiro-cli, Claude Code, …), so a plain bash/sh keeps clean,
39
+ * label-only tabs. */
40
+ export function presetTabbed(): TerminalFeature<unknown>[] {
41
+ return buildTabbed(false);
42
+ }
@@ -0,0 +1,13 @@
1
+ // presetTouch: the touch-first composition (single + the on-screen key
2
+ // toolbar). Its own entry module so a touch consumer's delivered import graph
3
+ // excludes the tabs/activity/animations modules entirely (vibekit's embedded
4
+ // panel imports exactly this).
5
+
6
+ import type { TerminalFeature } from "../kernel/types.js";
7
+ import { mobileToolbar } from "../features/mobile-toolbar.js";
8
+ import { presetSingle } from "./single.js";
9
+
10
+ /** Touch-first UI: presetSingle plus the on-screen key toolbar. */
11
+ export function presetTouch(): TerminalFeature<unknown>[] {
12
+ return [...presetSingle(), mobileToolbar()];
13
+ }