@cplieger/web-terminal-ui 4.5.2 → 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.
- package/README.md +49 -45
- package/css/02-terminal.css +9 -4
- package/css/30-tabs.css +120 -0
- package/css/31-switcher.css +9 -0
- package/package.json +7 -4
- package/src/features/tabs/index.ts +678 -98
- package/src/features/tabs/model.ts +284 -182
- package/src/kernel/a11y.ts +16 -0
- package/src/kernel/kernel.ts +119 -47
- package/src/kernel/types.ts +41 -11
- package/src/presets/agent-tabbed.ts +4 -6
- package/src/presets/tabbed.ts +8 -3
package/src/kernel/kernel.ts
CHANGED
|
@@ -86,10 +86,74 @@ function pick(root: ParentNode, selector: string): HTMLElement {
|
|
|
86
86
|
return el;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/kernel/types.ts
CHANGED
|
@@ -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
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
*
|
|
19
|
-
* classifier
|
|
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
|
}
|
package/src/presets/tabbed.ts
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
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(),
|