@cplieger/web-terminal-ui 3.4.5 → 4.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 (38) hide show
  1. package/README.md +53 -23
  2. package/css/00-tokens.css +8 -2
  3. package/css/01-scope.css +56 -0
  4. package/css/{02-app.css → 02-terminal.css} +64 -239
  5. package/css/{03-primitives.css → 10-primitives.css} +21 -19
  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} +96 -57
  11. package/css/{06-mobile.css → 31-switcher.css} +66 -81
  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 +21 -5
  19. package/scaffold/index.html +5 -0
  20. package/src/features/connection-banner.ts +3 -0
  21. package/src/features/context-menu.ts +6 -33
  22. package/src/features/menu-position.ts +50 -0
  23. package/src/features/{tabs.ts → tabs/index.ts} +207 -636
  24. package/src/features/tabs/model.ts +334 -0
  25. package/src/features/tabs/strip.ts +101 -0
  26. package/src/features/tabs/switcher.ts +129 -0
  27. package/src/index.ts +1 -0
  28. package/src/kernel/a11y.ts +30 -5
  29. package/src/kernel/conn-state.ts +20 -3
  30. package/src/kernel/kernel.ts +162 -59
  31. package/src/kernel/types.ts +65 -15
  32. package/src/presets/agent-tabbed.ts +15 -0
  33. package/src/presets/single.ts +27 -0
  34. package/src/presets/tabbed.ts +42 -0
  35. package/src/presets/touch.ts +13 -0
  36. package/src/presets.ts +12 -76
  37. package/src/viewport.ts +28 -15
  38. package/css/04-feedback.css +0 -180
@@ -49,6 +49,14 @@ 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 breakpoint, in ROOT-width pixels. The single source of the
53
+ // number: the kernel mirrors it into the .wt-narrow root class for CSS (paired
54
+ // there with pointer-coarseness media queries where touch matters) and features
55
+ // read the same fact via ctx.layout(). Root width — not viewport width — so an
56
+ // embedded terminal in a narrow panel counts as narrow. For every full-page
57
+ // consumer root width equals viewport width, so the behavior matches the old
58
+ // (max-width: 600px) media queries exactly.
59
+ const NARROW_MAX_PX = 600;
52
60
 
53
61
  // Kernel-owned core subtree: the display-only output, the hidden textarea (the
54
62
  // single keyboard target), and the IME composition view. No chrome; features
@@ -75,6 +83,16 @@ export function createTerminal(
75
83
  const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
76
84
  const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
77
85
  const featureList = opts.features ?? [];
86
+ // At most one feature owns session selection (fail fast, before any DOM
87
+ // work): the kernel drives the first connect through its registration.
88
+ const owners = featureList.filter((f) => f.sessionOwner !== undefined);
89
+ if (owners.length > 1) {
90
+ throw new Error(
91
+ `web-terminal-ui: multiple session-owning features: ${owners.map((f) => f.name).join(", ")}`,
92
+ );
93
+ }
94
+ const sessionOwner = owners[0]?.sessionOwner;
95
+ const sessionOwnerName = owners[0]?.name ?? "";
78
96
  const encoder = new TextEncoder();
79
97
  const kernelAbort = new AbortController();
80
98
  const { signal } = kernelAbort;
@@ -91,6 +109,15 @@ export function createTerminal(
91
109
  }
92
110
  }
93
111
 
112
+ // Stamp the boundary classes: .wt-root scopes every library token and style
113
+ // rule to this subtree, and the layout-mode class decides how the root claims
114
+ // space — wt-viewport pins the root itself to the full viewport (the
115
+ // full-page product), wt-container fills the parent element (the embedded
116
+ // case). All chrome positions against the root, never the page. destroy()
117
+ // removes them.
118
+ const layoutMode = opts.layout ?? "viewport";
119
+ root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
120
+
94
121
  // --- Build the core subtree ---
95
122
  const tpl = document.createElement("template");
96
123
  tpl.innerHTML = CORE_TEMPLATE;
@@ -106,6 +133,18 @@ export function createTerminal(
106
133
  const announcer = createAnnouncer(root);
107
134
  const tablistController = createTablist(outputEl);
108
135
 
136
+ // --- Narrow-layout driver (.wt-narrow mirrors root width for CSS) ---
137
+ const isNarrow = (): boolean => root.clientWidth <= NARROW_MAX_PX;
138
+ function updateNarrow(): void {
139
+ root.classList.toggle("wt-narrow", isNarrow());
140
+ }
141
+ updateNarrow();
142
+ let narrowObserver: ResizeObserver | null = null;
143
+ if (typeof ResizeObserver === "function") {
144
+ narrowObserver = new ResizeObserver(updateNarrow);
145
+ narrowObserver.observe(root);
146
+ }
147
+
109
148
  // Toast surface (kernel-owned shared primitive, section 22.3).
110
149
  const toastEl = document.createElement("div");
111
150
  toastEl.className = "wt-toast";
@@ -187,13 +226,13 @@ export function createTerminal(
187
226
  let activeSession: SessionRef | null = null;
188
227
  // Whether the first connection has been kicked off. The wake-reconnect
189
228
  // 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).
229
+ // it has: under a session owner the kernel drives the first connect only
230
+ // once resolveInitialSession() returns a session id, and connecting to the
231
+ // bare wsPath before then hits a session-gated endpoint (the SessionManager
232
+ // 404s a /ws with no ?session=). pageshow fires on the initial load, so
233
+ // without this gate a slow session list lets pageshow's reconnectNow open a
234
+ // bare /ws that 404s (seen in Firefox, where the list loses the race). Flips
235
+ // true on the startup connect (no owner) or the first setSession (owned).
197
236
  let connectionInitiated = false;
198
237
 
199
238
  // --- Loading lifecycle ---
@@ -313,6 +352,19 @@ export function createTerminal(
313
352
  onOutboxFull() {
314
353
  connState.closed();
315
354
  },
355
+ onProcessExit() {
356
+ // The engine's definitive 4001 close: the session's process has exited
357
+ // and the engine will not reconnect it. Two jobs here. markReady()
358
+ // guarantees the page is usable even when the exit lands before any
359
+ // screen frame (attaching to an already-dead session on a server that
360
+ // races the replay) — without it the loading overlay would sit on top of
361
+ // the tabs chrome forever, which is exactly the reported stuck-loading
362
+ // wedge. Then surface the end state: "Session ended", not a flapping
363
+ // "Reconnecting…", since no reconnect is coming. The final screen (when
364
+ // the server delivered it) stays rendered behind the banner.
365
+ markReady();
366
+ connState.ended();
367
+ },
316
368
  onServerRestart() {
317
369
  render.resetScrollback();
318
370
  render.resetScreen();
@@ -531,6 +583,7 @@ export function createTerminal(
531
583
  // --- Viewport ---
532
584
  viewport.init({
533
585
  termWrap,
586
+ root,
534
587
  suppressKeyboardInset: hasFinePointer,
535
588
  onSettled() {
536
589
  render.updateFontMetrics();
@@ -602,6 +655,42 @@ export function createTerminal(
602
655
  const apiMap = new Map<TerminalFeature<unknown>, unknown>();
603
656
  const instances: { feature: TerminalFeature<unknown>; instance: FeatureInstance<unknown> }[] = [];
604
657
 
658
+ // The one switch path: ctx.notifySwitch (tab switches) and the kernel's own
659
+ // owner-resolved first connect both land here.
660
+ function performSwitch(session: SessionRef): void {
661
+ // A feature's un-cancelled async (tabs create()/pollOnce()) can resolve
662
+ // after destroy() and request a switch; ignore it so a torn-down terminal
663
+ // never re-points or reopens the socket (connection.setSession below).
664
+ if (isDestroyed()) {
665
+ return;
666
+ }
667
+ // Detach (design 5.1): make input safe before the socket is re-pointed.
668
+ // End any in-flight IME composition and clear the textarea so
669
+ // half-composed text is not delivered to either session, and let every
670
+ // feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
671
+ // cannot fire against the incoming session. This all runs before
672
+ // setSession, and the switch is synchronous, so input is inert between
673
+ // detach here and the onSwitch attach below.
674
+ composition.cancelComposition();
675
+ resetToPlaceholder(input);
676
+ for (const { instance } of instances) {
677
+ instance.onDetach?.();
678
+ }
679
+ activeSession = session;
680
+ // Reconnect the terminal WS to this session using its per-tab resume
681
+ // state; the renderer was already pointed at its store by the owner.
682
+ connection.setSession(session.id);
683
+ // The owned first connect has happened (session id is now on the WS URL);
684
+ // wake-reconnect handlers may fire from here on.
685
+ connectionInitiated = true;
686
+ for (const { instance } of instances) {
687
+ if (instance.onSwitch) {
688
+ instance.onSwitch(session);
689
+ }
690
+ }
691
+ bus.emit("session:switch", session);
692
+ }
693
+
605
694
  function makeContext(featureName: string): TerminalContext {
606
695
  return {
607
696
  region: (name, slot) => regions.region(name, slot),
@@ -665,38 +754,13 @@ export function createTerminal(
665
754
  announcer.announce(message, politeness);
666
755
  },
667
756
  tablist: () => tablistController,
757
+ layout: () => ({
758
+ narrow: isNarrow(),
759
+ coarse:
760
+ typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
761
+ }),
668
762
  notifySwitch(session) {
669
- // A feature's un-cancelled async (tabs bootstrap/create()/pollOnce()) can
670
- // resolve after destroy() and call this; ignore it so a torn-down terminal
671
- // never re-points or reopens the socket (connection.setSession below).
672
- if (isDestroyed()) {
673
- return;
674
- }
675
- // Detach (design 5.1): make input safe before the socket is re-pointed.
676
- // End any in-flight IME composition and clear the textarea so
677
- // half-composed text is not delivered to either session, and let every
678
- // feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
679
- // cannot fire against the incoming session. This all runs before
680
- // setSession, and the switch is synchronous, so input is inert between
681
- // detach here and the onSwitch attach below.
682
- composition.cancelComposition();
683
- resetToPlaceholder(input);
684
- for (const { instance } of instances) {
685
- instance.onDetach?.();
686
- }
687
- activeSession = session;
688
- // Reconnect the terminal WS to this session using its per-tab resume
689
- // state; the renderer was already pointed at its store by tabs.
690
- connection.setSession(session.id);
691
- // The managed first connect has happened (session id is now on the WS
692
- // URL); wake-reconnect handlers may fire from here on.
693
- connectionInitiated = true;
694
- for (const { instance } of instances) {
695
- if (instance.onSwitch) {
696
- instance.onSwitch(session);
697
- }
698
- }
699
- bus.emit("session:switch", session);
763
+ performSwitch(session);
700
764
  },
701
765
  dropSession(id) {
702
766
  connection.forgetSession(id);
@@ -731,10 +795,14 @@ export function createTerminal(
731
795
  instances.length = 0;
732
796
  }
733
797
 
734
- async function setupFeatures(): Promise<void> {
798
+ // Resolves true when every feature set up; false when setup was aborted (a
799
+ // feature threw and everything rolled back, or destroy() ran mid-setup). The
800
+ // session-owner first connect below runs only on true — a resolver must never
801
+ // run against torn-down chrome.
802
+ async function setupFeatures(): Promise<boolean> {
735
803
  for (const feature of featureList) {
736
804
  if (destroyed) {
737
- return;
805
+ return false;
738
806
  }
739
807
  try {
740
808
  const instance = await feature.setup(makeContext(feature.name));
@@ -749,7 +817,7 @@ export function createTerminal(
749
817
  } catch (err) {
750
818
  reportError(feature.name, err);
751
819
  }
752
- return;
820
+ return false;
753
821
  }
754
822
  instances.push({ feature, instance });
755
823
  // Populate the feature value's readonly api (consumer pattern:
@@ -765,27 +833,47 @@ export function createTerminal(
765
833
  `web-terminal-ui: feature "${feature.name}" setup failed; terminal has no chrome`,
766
834
  err,
767
835
  );
768
- return;
836
+ return false;
769
837
  }
770
838
  }
839
+ return true;
771
840
  }
772
- // A session-managing feature (tabs) drives the first connect itself once it
773
- // has resolved a session id (ctx.notifySwitch -> connection.setSession, which
774
- // adds ?session=<id>). Connecting here first would open a bare /ws that a
775
- // SessionManager 404s (no ?session=), flashing a disconnect banner and
776
- // churning the reconnect backoff until the feature sets a session. So skip the
777
- // startup connect when managed; the single-terminal presets leave it to us.
778
- const sessionManaged = featureList.some((f) => f.managesSessions === true);
779
-
780
841
  // Features set up in the background; first paint is never gated on them.
781
- void setupFeatures().then(() => {
782
- // A session-managing feature that finished setup WITHOUT initiating a
783
- // connection means its bootstrap failed (no session could be listed or
784
- // spawned) or its setup threw. markReady (needs a screen frame) and onGiveUp
785
- // (needs the connect to be attempted and fail) then never fire, so the
786
- // loading overlay would stay up forever, hiding the retry chrome the feature
787
- // keeps alive (tabs' "+"). Dismiss it so the page shows its real state.
788
- if (sessionManaged && !connectionInitiated && !destroyed) {
842
+ // Under a session owner the kernel then drives the first connect itself:
843
+ // await the owner's resolveInitialSession() and switch to the session it
844
+ // returns through the same path a tab switch uses. Connecting to the bare
845
+ // wsPath first would open a /ws that a SessionManager 404s (no ?session=),
846
+ // flashing a disconnect banner and churning the reconnect backoff — hence no
847
+ // startup connect below when an owner is registered. A null (or thrown)
848
+ // resolution means the bootstrap failed: the owner keeps its retry chrome
849
+ // alive, and the kernel — which now SEES the failure instead of inferring it
850
+ // from a missing side effect — dismisses the loading overlay so that chrome
851
+ // is visible instead of an eternal spinner.
852
+ void setupFeatures().then(async (ok) => {
853
+ if (!ok) {
854
+ // Setup aborted (rolled back or destroyed). Under a session owner no
855
+ // connect will ever be attempted, so nothing else can lower the loading
856
+ // overlay — dismiss it so the page shows its real state.
857
+ if (!connectionInitiated && !destroyed) {
858
+ dismissLoadingOverlay();
859
+ }
860
+ return;
861
+ }
862
+ if (!sessionOwner || isDestroyed()) {
863
+ return;
864
+ }
865
+ let resolved: SessionRef | null = null;
866
+ try {
867
+ resolved = await sessionOwner.resolveInitialSession();
868
+ } catch (err) {
869
+ reportError(sessionOwnerName, err);
870
+ }
871
+ if (isDestroyed()) {
872
+ return;
873
+ }
874
+ if (resolved) {
875
+ performSwitch(resolved);
876
+ } else if (!connectionInitiated) {
789
877
  dismissLoadingOverlay();
790
878
  }
791
879
  });
@@ -793,7 +881,7 @@ export function createTerminal(
793
881
  // --- Connect + focus ---
794
882
  render.updateFontMetrics();
795
883
  composition.positionCompositionView();
796
- if (!sessionManaged) {
884
+ if (!sessionOwner) {
797
885
  connection.connect();
798
886
  connectionInitiated = true;
799
887
  }
@@ -801,6 +889,19 @@ export function createTerminal(
801
889
 
802
890
  return {
803
891
  focus: focusTerminal,
892
+ send(bytes) {
893
+ if (destroyed) {
894
+ return;
895
+ }
896
+ sendBytes(bytes);
897
+ },
898
+ reset() {
899
+ if (destroyed) {
900
+ return;
901
+ }
902
+ render.resetScrollback();
903
+ render.resetScreen();
904
+ },
804
905
  destroy() {
805
906
  if (destroyed) {
806
907
  return;
@@ -808,6 +909,7 @@ export function createTerminal(
808
909
  destroyed = true;
809
910
  teardownFeatures();
810
911
  kernelAbort.abort();
912
+ narrowObserver?.disconnect();
811
913
  viewport.teardown();
812
914
  // Reset the composition singleton (kernel-driven, no feature teardown): a
813
915
  // destroy() mid-IME-composition otherwise leaves module-level `composing`
@@ -826,6 +928,7 @@ export function createTerminal(
826
928
  bus.clear();
827
929
  announcer.destroy();
828
930
  regions.destroy();
931
+ root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
829
932
  root.replaceChildren();
830
933
  },
831
934
  };
@@ -51,7 +51,16 @@ export interface SessionView {
51
51
  // --- Connection state (mirrors the kernel's connection-state machine) ---
52
52
 
53
53
  /** Connection state the kernel owns and broadcasts on `connection:state`. */
54
- export type ConnState = "open" | "connecting" | "reconnecting" | "offline" | "restarted";
54
+ export type ConnState =
55
+ | "open"
56
+ | "connecting"
57
+ | "reconnecting"
58
+ | "offline"
59
+ | "restarted"
60
+ // The active session's process has EXITED (the engine's definitive 4001
61
+ // close): not a connectivity problem, so no reconnect is coming. Cleared
62
+ // only by connecting somewhere live (a tab switch / a new session).
63
+ | "ended";
55
64
 
56
65
  // --- Engine drive handles ---
57
66
  //
@@ -101,9 +110,6 @@ export interface TerminalEvents {
101
110
  "wire:modes": ModesMessage;
102
111
  /** A screen frame (first-paint / activity hooks). */
103
112
  "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
113
  /** The user scrolled away from / back to the bottom (drives the
108
114
  * scroll-to-bottom affordance). */
109
115
  "scroll:state": { scrolledUp: boolean };
@@ -195,6 +201,14 @@ export interface TerminalContext {
195
201
  /** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
196
202
  tablist(): TablistController;
197
203
 
204
+ /** The current layout facts a feature keys touch-vs-desktop behavior on.
205
+ * `narrow` is ROOT width at or below the kernel's single breakpoint (the
206
+ * same fact the .wt-narrow root class exposes to CSS — root width, not
207
+ * viewport width, so an embedded terminal in a narrow panel counts as
208
+ * narrow). `coarse` is the primary pointer's coarseness (a live media-query
209
+ * read). Read lazily at interaction time. */
210
+ layout(): { narrow: boolean; coarse: boolean };
211
+
198
212
  /** Switch the live terminal to a session. tabs binds the renderer to the
199
213
  * session's cached store (ctx.render.bind) first, then calls this; the kernel
200
214
  * reconnects the terminal WS to that session (connection.setSession, using
@@ -227,15 +241,32 @@ export interface TerminalFeature<Api = void> {
227
241
  * Readonly so a feature is covariant in Api (a TerminalFeature<X> is a
228
242
  * TerminalFeature<unknown>); the kernel sets it through a narrow cast. */
229
243
  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;
244
+ /** Present on the ONE feature that owns session selection (at most one per
245
+ * terminal; createTerminal throws when two features register). Its presence
246
+ * makes the kernel SKIP the startup connect to the bare wsPath (which a
247
+ * session-gated server 404s, churning the reconnect backoff); the kernel
248
+ * instead drives the first connect through resolveInitialSession() once
249
+ * feature setup completes. Single-terminal presets leave it unset so the
250
+ * kernel connects to the bare wsPath at startup. */
251
+ readonly sessionOwner?: SessionOwnerRegistration;
252
+ }
253
+
254
+ /** The session-owner registration: how the one session-owning feature and the
255
+ * kernel split the first connect. The feature owns session selection (list /
256
+ * spawn / pick) and its own bootstrap state; the kernel owns the connect
257
+ * itself, so a failed bootstrap is SEEN by the kernel (which dismisses the
258
+ * loading overlay) instead of inferred from a missing side effect. */
259
+ export interface SessionOwnerRegistration {
260
+ /** Resolve the initial session: list (or spawn) one, build per-session
261
+ * state, and bind the renderer to the session's store (ctx.render.bind) —
262
+ * but do NOT call ctx.notifySwitch for it. The kernel performs the switch
263
+ * with the returned ref through the same path notifySwitch uses. Return
264
+ * null when no session could be resolved (the bootstrap failed): the
265
+ * feature keeps its retry chrome alive, and the kernel dismisses the
266
+ * loading overlay so that chrome is visible. A throw is treated as null
267
+ * (and reported through the feature-error channel). Called exactly once,
268
+ * after every feature's setup has resolved. */
269
+ resolveInitialSession(): Promise<SessionRef | null>;
239
270
  }
240
271
 
241
272
  /** What a feature's setup returns: its optional typed API, a teardown, and an
@@ -266,6 +297,14 @@ export interface CreateTerminalOptions {
266
297
  * Heterogeneous feature APIs are held as unknown here; a consumer reads a
267
298
  * specific feature's api off the feature value it holds. */
268
299
  features?: readonly TerminalFeature<unknown>[];
300
+ /** How the terminal claims space (default "viewport").
301
+ * "viewport": the root becomes a fixed full-viewport box — the full-page
302
+ * product (web-terminal-server, web-terminal-kiro, the scaffold page).
303
+ * "container": the root fills its parent element, which becomes the
304
+ * boundary — the embedded case (vibekit's panel). Either way the kernel
305
+ * stamps the matching class (wt-viewport / wt-container) on the root and
306
+ * every piece of chrome positions against the root, never the page. */
307
+ layout?: "viewport" | "container";
269
308
  /** WebSocket endpoint path (default "/ws"). */
270
309
  wsPath?: string;
271
310
  /** CSS font shorthand awaited before the first resize. */
@@ -277,8 +316,8 @@ export interface CreateTerminalOptions {
277
316
  * Keys must be CSS custom-property names (start with "--"); values are any
278
317
  * CSS value. The library ships the defaults (css/00-tokens.css) — the
279
318
  * "template"; these are the consumer's "settings" and override them for this
280
- * instance. Known tokens: --accent, --tab-hover-bg, --tab-active-bg,
281
- * --tab-active-fg. */
319
+ * instance. Known tokens: --accent, --tab-bg, --tab-hover-bg,
320
+ * --tab-active-bg, --tab-active-fg, --tab-active-border. */
282
321
  theme?: Readonly<Record<string, string>>;
283
322
  }
284
323
 
@@ -288,6 +327,17 @@ export interface CreateTerminalOptions {
288
327
  export interface TerminalHandle {
289
328
  /** Focus the terminal input (opens the soft keyboard on touch). */
290
329
  focus(): void;
330
+ /** Send bytes to the active session through the kernel's sanitizing,
331
+ * session-routed input funnel — the same path features use: input
332
+ * transforms apply, and the view snaps to the bottom exactly like typed
333
+ * input. The supported host path for "type this command" affordances
334
+ * (a run-in-shell button). No-op after destroy(). */
335
+ send(bytes: Uint8Array): void;
336
+ /** Reset the LOCAL display: drop the client-side scrollback and screen (the
337
+ * same reset the engine performs on a server restart). Deliberately injects
338
+ * no keystroke — a host that wants a freshly drawn prompt sends one itself
339
+ * (e.g. Ctrl+L via send()). No-op after destroy(). */
340
+ reset(): void;
291
341
  /** Tear down every feature in reverse order, dispose all subscriptions, and
292
342
  * release the kernel's DOM and engine wiring. */
293
343
  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
+ }