@cplieger/web-terminal-ui 4.6.0 → 4.7.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.
@@ -86,10 +86,74 @@ function pick(root: ParentNode, selector: string): HTMLElement {
86
86
  return el;
87
87
  }
88
88
 
89
- export function createTerminal(
90
- root: HTMLElement,
91
- opts: CreateTerminalOptions = {},
92
- ): TerminalHandle {
89
+ /** Fade out and remove a consumer-supplied loading overlay.
90
+ *
91
+ * Module scope because BOTH startup-failure phases need it and they live on
92
+ * opposite sides of createTerminal's closure: the async `feature-setup` path
93
+ * reaches it through dismissLoadingOverlay (which adds the once-only guard),
94
+ * the synchronous `kernel-init` path calls it directly from the catch, where
95
+ * no kernel state exists yet. */
96
+ function fadeOutOverlay(ld: HTMLElement | undefined): void {
97
+ if (!ld) {
98
+ return;
99
+ }
100
+ ld.classList.add("fade");
101
+ const removeOverlay = (): void => {
102
+ ld.remove();
103
+ };
104
+ ld.addEventListener("transitionend", removeOverlay, { once: true });
105
+ window.setTimeout(removeOverlay, 1500);
106
+ }
107
+
108
+ /** Render the kernel-owned "Terminal failed to start" recovery surface into root.
109
+ *
110
+ * Module scope for the same reason as fadeOutOverlay: it is the ONE
111
+ * implementation of this surface, shared by the async `feature-setup` phase and
112
+ * the synchronous `kernel-init` phase, so the two can never drift apart. It
113
+ * depends on nothing but its two arguments. */
114
+ function renderFatalStartupInto(root: HTMLElement, layoutMode: "viewport" | "container"): void {
115
+ const surface = document.createElement("section");
116
+ surface.className = "wt-fatal";
117
+ surface.setAttribute("role", "alertdialog");
118
+ surface.setAttribute("aria-labelledby", "wt-fatal-title");
119
+ surface.setAttribute("aria-describedby", "wt-fatal-message");
120
+ if (layoutMode === "viewport") {
121
+ // A full-page terminal has no usable host UI behind it, so this is the
122
+ // page's modal recovery state. An embedded terminal is only one panel in
123
+ // a larger app and must not claim that the rest of the app is inert.
124
+ surface.setAttribute("aria-modal", "true");
125
+ }
126
+
127
+ const card = document.createElement("div");
128
+ card.className = "wt-fatal-card";
129
+ const title = document.createElement("h2");
130
+ title.id = "wt-fatal-title";
131
+ title.className = "wt-fatal-title";
132
+ title.textContent = "Terminal failed to start";
133
+ const message = document.createElement("p");
134
+ message.id = "wt-fatal-message";
135
+ message.className = "wt-fatal-message";
136
+ message.textContent = "A required interface could not be loaded. Reload the page to try again.";
137
+ const reloadButton = document.createElement("button");
138
+ reloadButton.className = "wt-btn wt-fatal-reload";
139
+ reloadButton.type = "button";
140
+ reloadButton.textContent = "Reload page";
141
+ reloadButton.addEventListener("click", () => {
142
+ window.location.reload();
143
+ });
144
+ card.append(title, message, reloadButton);
145
+ surface.appendChild(card);
146
+ root.replaceChildren(surface);
147
+
148
+ // The terminal input held focus before setup failed. Move that focus to the
149
+ // only recovery action; container mode remains non-modal because no trap or
150
+ // aria-modal claim prevents the user from leaving the terminal panel.
151
+ reloadButton.focus();
152
+ }
153
+
154
+ /** Build the terminal. See createTerminal, which wraps this to give a
155
+ * SYNCHRONOUS failure the same recovery surface an async one already gets. */
156
+ function buildTerminal(root: HTMLElement, opts: CreateTerminalOptions = {}): TerminalHandle {
93
157
  const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
94
158
  const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
95
159
  const featureList = opts.features ?? [];
@@ -267,12 +331,7 @@ export function createTerminal(
267
331
  return;
268
332
  }
269
333
  overlayDismissed = true;
270
- ld.classList.add("fade");
271
- const removeOverlay = (): void => {
272
- ld.remove();
273
- };
274
- ld.addEventListener("transitionend", removeOverlay, { once: true });
275
- window.setTimeout(removeOverlay, 1500);
334
+ fadeOutOverlay(ld);
276
335
  }
277
336
  function markReady(): void {
278
337
  if (ready) {
@@ -873,43 +932,7 @@ export function createTerminal(
873
932
  }
874
933
 
875
934
  function renderFatalStartup(): void {
876
- const surface = document.createElement("section");
877
- surface.className = "wt-fatal";
878
- surface.setAttribute("role", "alertdialog");
879
- surface.setAttribute("aria-labelledby", "wt-fatal-title");
880
- surface.setAttribute("aria-describedby", "wt-fatal-message");
881
- if (layoutMode === "viewport") {
882
- // A full-page terminal has no usable host UI behind it, so this is the
883
- // page's modal recovery state. An embedded terminal is only one panel in
884
- // a larger app and must not claim that the rest of the app is inert.
885
- surface.setAttribute("aria-modal", "true");
886
- }
887
-
888
- const card = document.createElement("div");
889
- card.className = "wt-fatal-card";
890
- const title = document.createElement("h2");
891
- title.id = "wt-fatal-title";
892
- title.className = "wt-fatal-title";
893
- title.textContent = "Terminal failed to start";
894
- const message = document.createElement("p");
895
- message.id = "wt-fatal-message";
896
- message.className = "wt-fatal-message";
897
- message.textContent = "A required interface could not be loaded. Reload the page to try again.";
898
- const reloadButton = document.createElement("button");
899
- reloadButton.className = "wt-btn wt-fatal-reload";
900
- reloadButton.type = "button";
901
- reloadButton.textContent = "Reload page";
902
- reloadButton.addEventListener("click", () => {
903
- window.location.reload();
904
- });
905
- card.append(title, message, reloadButton);
906
- surface.appendChild(card);
907
- root.replaceChildren(surface);
908
-
909
- // The terminal input held focus before setup failed. Move that focus to the
910
- // only recovery action; container mode remains non-modal because no trap or
911
- // aria-modal claim prevents the user from leaving the terminal panel.
912
- reloadButton.focus();
935
+ renderFatalStartupInto(root, layoutMode);
913
936
  }
914
937
 
915
938
  function enterFatalStartup(feature: string, cause: unknown): void {
@@ -1060,3 +1083,52 @@ export function createTerminal(
1060
1083
  },
1061
1084
  };
1062
1085
  }
1086
+
1087
+ /** Build the terminal UI inside `root`. Call exactly once.
1088
+ *
1089
+ * Wraps buildTerminal so that a SYNCHRONOUS startup failure gets the same
1090
+ * treatment an asynchronous one already got. Before this, the two halves of
1091
+ * "the terminal did not start" were handled inconsistently: an async
1092
+ * feature-setup rejection ran enterFatalStartup (overlay dismissed,
1093
+ * onFatalError delivered, recovery surface rendered), while a synchronous throw
1094
+ * propagated bare, leaving the consumer's loading overlay spinning forever over
1095
+ * a page with no terminal and no explanation. Consumers were left hand-building
1096
+ * their own surface to cover the gap, diverging from this one in shape and copy.
1097
+ *
1098
+ * The error still propagates: a consumer with its own handling sees it exactly
1099
+ * as before, only now against a page that is no longer stuck on a spinner. A
1100
+ * handler returning `true` suppresses the built-in surface, same contract as the
1101
+ * async phase. */
1102
+ export function createTerminal(
1103
+ root: HTMLElement,
1104
+ opts: CreateTerminalOptions = {},
1105
+ ): TerminalHandle {
1106
+ try {
1107
+ return buildTerminal(root, opts);
1108
+ } catch (cause) {
1109
+ const layoutMode = opts.layout ?? "viewport";
1110
+ // Stamp the boundary classes before rendering. buildTerminal normally does
1111
+ // this early, but a throw can precede it (the multiple-session-owner guard
1112
+ // fires before any DOM work by design), and every .wt-fatal rule is scoped
1113
+ // :where(.wt-root) -- so without this the recovery surface would render
1114
+ // completely unstyled exactly when it matters most. Idempotent when
1115
+ // buildTerminal already stamped them.
1116
+ root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
1117
+ // The overlay must come down even though nothing was built: it is the
1118
+ // consumer's pre-JS spinner, and leaving it up hides the surface below it.
1119
+ fadeOutOverlay(opts.loading);
1120
+
1121
+ let handled = false;
1122
+ try {
1123
+ handled = opts.onFatalError?.({ phase: "kernel-init", cause }) === true;
1124
+ } catch (handlerErr) {
1125
+ // Same posture as the async phase: a reporting failure must not leave the
1126
+ // page blank, so fall through to the built-in surface.
1127
+ console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
1128
+ }
1129
+ if (!handled) {
1130
+ renderFatalStartupInto(root, layoutMode);
1131
+ }
1132
+ throw cause;
1133
+ }
1134
+ }
@@ -147,6 +147,18 @@ export interface TabHandle {
147
147
  setSelected(selected: boolean): void;
148
148
  /** Set the accessible label (used for aria-labelledby on the panel). */
149
149
  setLabel(text: string): void;
150
+ /** Enter or leave inline-edit mode. A textbox is not valid content for
151
+ * `role="tab"` (ARIA marks a tab's children presentational, so a focusable
152
+ * descendant may get no accessibility-tree node at all), so a feature that
153
+ * puts an input inside its chip drops the tab semantics for the duration.
154
+ *
155
+ * One atomic transition each way. On: `role` and `aria-selected` are removed
156
+ * and the chip leaves the roving sequence while the field owns focus. Off: the
157
+ * role is restored along with `aria-selected` and the roving tabindex derived
158
+ * from `selected` AS OF NOW — the active tab can change while an edit is open
159
+ * on a different chip, so the caller passes the current state rather than the
160
+ * handle replaying the state it saw at entry. */
161
+ setEditing(editing: boolean, selected: boolean): void;
150
162
  /** Deregister this tab. */
151
163
  remove(): void;
152
164
  }
@@ -303,17 +315,35 @@ export interface FeatureInstance<Api = void> {
303
315
 
304
316
  // --- Entry point ---
305
317
 
306
- /** A fatal failure while createTerminal is starting its feature composition.
307
- * The kernel has already stopped the connection, released every listener and
308
- * singleton, torn down completed features, and cleared the terminal root when
309
- * it delivers this value to `onFatalError`. */
310
- export interface TerminalStartupFailure {
311
- readonly phase: "feature-setup";
312
- /** Name of the feature whose setup threw or rejected. */
313
- readonly feature: string;
314
- /** The original thrown or rejected value. */
315
- readonly cause: unknown;
316
- }
318
+ /** A fatal failure while createTerminal is starting up.
319
+ *
320
+ * Two phases can fail, and both are delivered here so a consumer never has to
321
+ * hand-build its own startup-failure surface:
322
+ *
323
+ * - `kernel-init`: a SYNCHRONOUS throw out of createTerminal (an invalid
324
+ * feature list, a DOM invariant, a feature constructor). The kernel renders
325
+ * the recovery surface and then RETHROWS, so a caller with its own handling
326
+ * still sees the error.
327
+ * - `feature-setup`: an async feature-composition failure. The kernel has
328
+ * already stopped the connection, released every listener and singleton,
329
+ * torn down completed features, and cleared the terminal root when it
330
+ * delivers this value. Nothing is rethrown; the failure is asynchronous.
331
+ *
332
+ * Discriminate on `phase`: `feature-setup` names the offending feature,
333
+ * `kernel-init` has no feature to name because composition never began. */
334
+ export type TerminalStartupFailure =
335
+ | {
336
+ readonly phase: "feature-setup";
337
+ /** Name of the feature whose setup threw or rejected. */
338
+ readonly feature: string;
339
+ /** The original thrown or rejected value. */
340
+ readonly cause: unknown;
341
+ }
342
+ | {
343
+ readonly phase: "kernel-init";
344
+ /** The original thrown value, rethrown to the caller after this returns. */
345
+ readonly cause: unknown;
346
+ };
317
347
 
318
348
  /** Options for createTerminal. */
319
349
  export interface CreateTerminalOptions {
@@ -5,18 +5,16 @@ import { buildTabbed } from "./tabbed.js";
5
5
 
6
6
  /** Tabbed UI for an agent shell (web-terminal-kiro). Same features as
7
7
  * presetTabbed, tuned for sessions that ARE agents:
8
- * - `preferInputTitle`: the agent's program (kiro-cli) emits a non-empty but
9
- * useless OSC 0/2 title, so each tab's label follows the latest submitted
10
- * line (persisted server-side and recovered on reload) and the OSC title
11
- * is ignored.
12
8
  * - `presumeReports`: every session's program is an agent that WILL report
13
9
  * OSC 9;4 progress, so the idle activity dot shows from tab creation
14
10
  * instead of popping in seconds later when the agent has booted far enough
15
11
  * to first report; the server's sticky reportsActivity flag then merely
16
12
  * confirms. (presetTabbed keeps the evidence-driven reveal instead: a
17
13
  * plain shell never grows a meaningless dot.)
18
- * The agent-vs-generic status distinction remains server-side (a status
19
- * classifier mapping OSC 9 notifications to done/needs-input). */
14
+ * Two agent-vs-generic distinctions live SERVER-side rather than here: the
15
+ * status classifier that maps OSC 9 notifications to done/needs-input, and the
16
+ * input-derived session name (terminal.WithInputTitle), which the agent host
17
+ * enables because its program's own window title is not worth showing. */
20
18
  export function presetAgentTabbed(): TerminalFeature<unknown>[] {
21
19
  return buildTabbed(true);
22
20
  }
@@ -19,8 +19,14 @@ import { presetSingle } from "./single.js";
19
19
  * included; a plain shell under presetTabbed just never shows a dot. The
20
20
  * toolbar and monitor are ordered before tabs because tabs reads their APIs
21
21
  * via ctx.use. Shared by presetTabbed and presetAgentTabbed (agent-tabbed.ts),
22
- * which differ only in the agent-shell tuning: input-derived titles
23
- * (preferInputTitle) + presumed activity reporting (presumeReports). */
22
+ * which now differ only in presumed activity reporting (presumeReports).
23
+ *
24
+ * Titles are no longer a preset concern. The ENGINE resolves a session's name —
25
+ * its pinned name, an input-derived name when the host asked for one
26
+ * (terminal.WithInputTitle), the program's OSC window title, or its own
27
+ * foreground-process/cwd inference — and both presets render what it reports. A
28
+ * browser that re-derived any of that could only disagree with the server and
29
+ * with every other client attached to the same session. */
24
30
  export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
25
31
  const kb = mobileToolbar({ externalToggle: true });
26
32
  const monitor = activityMonitor();
@@ -31,7 +37,6 @@ export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
31
37
  tabs({
32
38
  keyboardToggle: kb,
33
39
  activityMonitor: monitor,
34
- preferInputTitle: agentShell,
35
40
  presumeReports: agentShell,
36
41
  }),
37
42
  animations(),