@cplieger/web-terminal-ui 7.3.2 → 8.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 (48) hide show
  1. package/README.md +114 -18
  2. package/css/30-tabs.css +21 -0
  3. package/css/31-switcher.css +15 -0
  4. package/css/32-split.css +176 -0
  5. package/css/MANIFEST +1 -0
  6. package/css/MANIFEST.tabbed +1 -0
  7. package/package.json +13 -12
  8. package/src/composition.ts +165 -237
  9. package/src/features/activity-monitor.ts +20 -55
  10. package/src/features/animations.ts +12 -30
  11. package/src/features/clipboard.ts +36 -64
  12. package/src/features/connection-banner.ts +10 -19
  13. package/src/features/context-menu.ts +70 -145
  14. package/src/features/dom.ts +44 -54
  15. package/src/features/menu-position.ts +24 -28
  16. package/src/features/mobile-toolbar.ts +32 -62
  17. package/src/features/predictive-echo.ts +15 -49
  18. package/src/features/scroll-to-bottom.ts +12 -24
  19. package/src/features/tabs/index.ts +1398 -1806
  20. package/src/features/tabs/ink-centre.ts +45 -54
  21. package/src/features/tabs/model.ts +301 -383
  22. package/src/features/tabs/strip.ts +45 -97
  23. package/src/features/tabs/switcher.ts +70 -48
  24. package/src/index.ts +13 -13
  25. package/src/kernel/a11y.ts +106 -27
  26. package/src/kernel/attention.ts +157 -0
  27. package/src/kernel/conn-state.ts +28 -22
  28. package/src/kernel/fatal.ts +76 -0
  29. package/src/kernel/feature-host.ts +136 -0
  30. package/src/kernel/gesture.ts +9 -13
  31. package/src/kernel/kernel.ts +57 -2139
  32. package/src/kernel/layout-policy.ts +44 -0
  33. package/src/kernel/loading-status.ts +30 -88
  34. package/src/kernel/navigation.ts +11 -17
  35. package/src/kernel/notify.ts +158 -0
  36. package/src/kernel/pane.ts +1415 -0
  37. package/src/kernel/realm.ts +11 -0
  38. package/src/kernel/regions.ts +8 -11
  39. package/src/kernel/scrollback.ts +88 -168
  40. package/src/kernel/selection.ts +25 -0
  41. package/src/kernel/shell.ts +1434 -0
  42. package/src/kernel/split-handle.ts +280 -0
  43. package/src/kernel/status-share.ts +100 -0
  44. package/src/kernel/types.ts +457 -441
  45. package/src/predict.ts +144 -170
  46. package/src/viewport.ts +124 -197
  47. package/src/features/tabs/attention.ts +0 -250
  48. package/src/features/tabs/notify.ts +0 -265
@@ -0,0 +1,157 @@
1
+ import type { AttentionOptions, AttentionReporter, AttentionState } from "./types.js";
2
+
3
+ const NO_ATTENTION: AttentionState = { count: 0, icon: null };
4
+
5
+ /** The sinks a render writes, every one injected. An absent optional sink is a
6
+ * silent no-op: an unsupported surface is a normal state of the world. Two can
7
+ * also fail INVISIBLY (`setBadge` resolves on a desktop that paints no badge,
8
+ * `setIcon` assigns an href Safari ignores), which is why the title is gated on
9
+ * no capability and is the floor: never arrange these as a fallback ladder. */
10
+ export interface AttentionSinks {
11
+ /** Set the mark the document title starts with, or clear it with "". */
12
+ setTitleMark: (text: string) => void;
13
+ setBadge?: ((count: number) => void) | undefined;
14
+ setIcon?: ((variant: string | null) => void) | undefined;
15
+ }
16
+
17
+ /** The reporter plus its release. */
18
+ export interface AttentionSurface extends AttentionReporter {
19
+ /** Restore every surface and stop following the page lifecycle. */
20
+ dispose(): void;
21
+ }
22
+
23
+ /** The count goes FIRST because a browser tab strip truncates a title to its
24
+ * first few characters; parenthesised digits are the convention every mail and
25
+ * chat client uses, so it needs no legend. */
26
+ export function titleMarkFor(count: number): string {
27
+ return count > 0 ? `(${String(count)}) ` : "";
28
+ }
29
+
30
+ /** iconVariantHref rewrites an icon URL to its variant: the `favicon` token of
31
+ * the filename gains `-<variant>`, keeping the extension so each link keeps its
32
+ * own format. Null for a filename that does not start with `favicon`, which
33
+ * leaves that link alone rather than pointing it at a 404. */
34
+ export function iconVariantHref(href: string, variant: string): string | null {
35
+ const match = /(^|\/)favicon(?=[-.])/.exec(href);
36
+ if (!match) {
37
+ return null;
38
+ }
39
+ const at = match.index + match[0].length;
40
+ return `${href.slice(0, at)}-${variant}${href.slice(at)}`;
41
+ }
42
+
43
+ /** Render reports onto `sinks`, change-gated on the last rendered value: the
44
+ * title doubles as the browser-tab label and the bookmark name, and
45
+ * re-assigning an icon href makes some browsers re-fetch it. `pagehide` restores
46
+ * the surfaces (a browser remembers ONE icon per URL for the bookmark and the
47
+ * history row, so a tab closed on a lit cue would leave a status variant standing
48
+ * in for the app), `pageshow` repaints the last report for a page that came back
49
+ * from the back-forward cache, and `freeze` changes nothing because a frozen tab
50
+ * is still in the strip rendering its icon. */
51
+ export function createAttention(sinks: AttentionSinks, win: Window): AttentionSurface {
52
+ let reported: AttentionState = NO_ATTENTION;
53
+ let rendered: AttentionState | null = null;
54
+
55
+ function render(next: AttentionState): void {
56
+ const countChanged = next.count !== rendered?.count;
57
+ const iconChanged = next.icon !== rendered?.icon;
58
+ rendered = next;
59
+ if (countChanged) {
60
+ sinks.setTitleMark(titleMarkFor(next.count));
61
+ sinks.setBadge?.(next.count);
62
+ }
63
+ if (iconChanged) {
64
+ sinks.setIcon?.(next.icon);
65
+ }
66
+ }
67
+
68
+ function onPageGone(): void {
69
+ render(NO_ATTENTION);
70
+ }
71
+ function onPageBack(): void {
72
+ render(reported);
73
+ }
74
+ const listeners = new AbortController();
75
+ const { signal } = listeners;
76
+ try {
77
+ win.addEventListener("pagehide", onPageGone, { signal });
78
+ win.addEventListener("pageshow", onPageBack, { signal });
79
+ } catch (err) {
80
+ listeners.abort();
81
+ throw err;
82
+ }
83
+
84
+ return {
85
+ report(state) {
86
+ reported = state;
87
+ render(state);
88
+ },
89
+ dispose() {
90
+ listeners.abort();
91
+ render(NO_ATTENTION);
92
+ },
93
+ };
94
+ }
95
+
96
+ /** Bind the sinks to `doc`'s browser realm. Every capability decision is made
97
+ * HERE, once, so the render never probes. */
98
+ export function browserAttentionSinks(
99
+ setTitleMark: (text: string) => void,
100
+ opts: AttentionOptions,
101
+ doc: Document,
102
+ ): AttentionSinks {
103
+ const sinks: AttentionSinks = { setTitleMark };
104
+
105
+ // Read through `unknown` because the Badging API is absent on most browsers
106
+ // and exists only on an installed app's icon.
107
+ const nav: unknown = doc.defaultView?.navigator;
108
+ const setAppBadge = (nav as { setAppBadge?: unknown } | undefined)?.setAppBadge;
109
+ const clearAppBadge = (nav as { clearAppBadge?: unknown } | undefined)?.clearAppBadge;
110
+ if (typeof setAppBadge === "function") {
111
+ sinks.setBadge = (count: number): void => {
112
+ // Always a NUMBER, never the spec's bare flag form: iOS renders nothing for
113
+ // `setAppBadge()` with no argument. Zero clears through clearAppBadge where
114
+ // it exists and setAppBadge(0) otherwise (Chrome shipped the former later).
115
+ try {
116
+ const call =
117
+ count > 0
118
+ ? (setAppBadge as (n: number) => unknown).call(nav, count)
119
+ : typeof clearAppBadge === "function"
120
+ ? (clearAppBadge as () => unknown).call(nav)
121
+ : (setAppBadge as (n: number) => unknown).call(nav, 0);
122
+ // A rejection is an OS that will not paint a badge, not a page fault.
123
+ void Promise.resolve(call).catch(() => undefined);
124
+ } catch {
125
+ /* a synchronous throw is the same non-event */
126
+ }
127
+ };
128
+ }
129
+
130
+ if (opts.icons) {
131
+ // Every icon link, because which one a browser picks differs (Chrome prefers
132
+ // the SVG). `rel~="icon"` deliberately skips apple-touch-icon: the OS caches
133
+ // it at install time, so a swap cannot reach it.
134
+ const links = [...doc.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')];
135
+ const originals = new Map<HTMLLinkElement, string>();
136
+ for (const link of links) {
137
+ originals.set(link, link.getAttribute("href") ?? "");
138
+ }
139
+ if (links.length > 0) {
140
+ sinks.setIcon = (variant): void => {
141
+ for (const link of links) {
142
+ const original = originals.get(link) ?? "";
143
+ if (variant === null) {
144
+ link.setAttribute("href", original);
145
+ continue;
146
+ }
147
+ const next = iconVariantHref(original, variant);
148
+ if (next !== null) {
149
+ link.setAttribute("href", next);
150
+ }
151
+ }
152
+ };
153
+ }
154
+ }
155
+
156
+ return sinks;
157
+ }
@@ -1,17 +1,3 @@
1
- // Connection-state machine (kernel-owned, design section 22.3). The state, the
2
- // never-connects give-up, and the loading lifecycle are kernel; the visible
3
- // banner is the connectionBanner feature, which renders whatever state this
4
- // emits. This is the DOM-free half of the old status.ts (its banner/toast
5
- // rendering became the connectionBanner feature and the kernel toast surface).
6
- //
7
- // State machine:
8
- // open -> no banner.
9
- // connecting -> "Reconnecting..." (initial connect, before first open).
10
- // reconnecting -> "Reconnecting..." (after a close, during retry);
11
- // escalates to offline after >3 consecutive failures.
12
- // offline -> "Offline".
13
- // restarted -> "Server restarted - recent input may have been lost".
14
-
15
1
  import type { ConnState } from "./types.js";
16
2
 
17
3
  const CONNECTING_GRACE_MS = 600;
@@ -41,6 +27,9 @@ export interface ConnStateMachine {
41
27
  /** The engine refused an explicitly incompatible wire revision. This
42
28
  * terminal state bypasses the loading gate and persists until open(). */
43
29
  incompatible(): void;
30
+ /** The pane holds no session: nothing is connecting and nothing is retrying.
31
+ * Left by open() on the next attach. */
32
+ idle(): void;
44
33
  /** First screen frame rendered: the loading overlay is done, so the banner
45
34
  * may show reconnect state from here on. */
46
35
  setLoaded(): void;
@@ -50,13 +39,22 @@ export interface ConnStateMachine {
50
39
  destroy(): void;
51
40
  }
52
41
 
42
+ /** The timers the machine schedules on: the mounted window's, so a grace delay
43
+ * runs on the terminal's own clock. */
44
+ export interface ConnStateTimers {
45
+ setTimeout(handler: () => void, ms: number): number;
46
+ clearTimeout(id: number): void;
47
+ }
48
+
53
49
  export function createConnState(opts: {
54
50
  onState: (s: ConnState) => void;
55
51
  onGiveUp?: () => void;
52
+ timers: ConnStateTimers;
56
53
  }): ConnStateMachine {
54
+ const { timers } = opts;
57
55
  let state: ConnState = "connecting";
58
- let stableTimer: ReturnType<typeof setTimeout> | null = null;
59
- let restartedTimer: ReturnType<typeof setTimeout> | null = null;
56
+ let stableTimer: number | null = null;
57
+ let restartedTimer: number | null = null;
60
58
  let consecutiveFailures = 0;
61
59
  let loaded = false;
62
60
 
@@ -79,7 +77,7 @@ export function createConnState(opts: {
79
77
 
80
78
  function setState(next: ConnState, delay: number): void {
81
79
  if (stableTimer !== null) {
82
- clearTimeout(stableTimer);
80
+ timers.clearTimeout(stableTimer);
83
81
  stableTimer = null;
84
82
  }
85
83
  if (delay === 0) {
@@ -87,7 +85,7 @@ export function createConnState(opts: {
87
85
  emit();
88
86
  return;
89
87
  }
90
- stableTimer = setTimeout(() => {
88
+ stableTimer = timers.setTimeout(() => {
91
89
  stableTimer = null;
92
90
  state = next;
93
91
  emit();
@@ -118,9 +116,9 @@ export function createConnState(opts: {
118
116
  restarted(): void {
119
117
  setState("restarted", 0);
120
118
  if (restartedTimer !== null) {
121
- clearTimeout(restartedTimer);
119
+ timers.clearTimeout(restartedTimer);
122
120
  }
123
- restartedTimer = setTimeout(() => {
121
+ restartedTimer = timers.setTimeout(() => {
124
122
  restartedTimer = null;
125
123
  if (state === "restarted") {
126
124
  setState("open", 0);
@@ -140,6 +138,14 @@ export function createConnState(opts: {
140
138
  consecutiveFailures = 0;
141
139
  setState("incompatible", 0);
142
140
  },
141
+ idle(): void {
142
+ consecutiveFailures = 0;
143
+ if (restartedTimer !== null) {
144
+ timers.clearTimeout(restartedTimer);
145
+ restartedTimer = null;
146
+ }
147
+ setState("idle", 0);
148
+ },
143
149
  setLoaded(): void {
144
150
  loaded = true;
145
151
  },
@@ -148,11 +154,11 @@ export function createConnState(opts: {
148
154
  },
149
155
  destroy(): void {
150
156
  if (stableTimer !== null) {
151
- clearTimeout(stableTimer);
157
+ timers.clearTimeout(stableTimer);
152
158
  stableTimer = null;
153
159
  }
154
160
  if (restartedTimer !== null) {
155
- clearTimeout(restartedTimer);
161
+ timers.clearTimeout(restartedTimer);
156
162
  restartedTimer = null;
157
163
  }
158
164
  },
@@ -0,0 +1,76 @@
1
+ import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
2
+ import { reloadPage } from "./navigation.js";
3
+ import { windowOf } from "./realm.js";
4
+
5
+ /** Fade out and remove a consumer-supplied loading overlay. */
6
+ export function fadeOutOverlay(ld: HTMLElement | undefined): void {
7
+ if (!ld) {
8
+ return;
9
+ }
10
+ ld.classList.add("fade");
11
+ const removeOverlay = (): void => {
12
+ ld.remove();
13
+ };
14
+ ld.addEventListener("transitionend", removeOverlay, { once: true });
15
+ windowOf(ld.ownerDocument).setTimeout(removeOverlay, 1500);
16
+ }
17
+
18
+ export interface FatalPanelOptions {
19
+ message?: string;
20
+ /** `showModal()` (the terminal IS the page) or the bare `open` attribute (an
21
+ * embedded panel, or a pane beside a live one). */
22
+ modal: boolean;
23
+ /** Appended to the panel's two element ids, so two panels in one document keep
24
+ * their `aria-labelledby` targets unique. */
25
+ idSuffix?: string;
26
+ }
27
+
28
+ /** Render the "Terminal failed to start" panel into `root`, replacing its children. */
29
+ export function renderFatalStartupInto(root: HTMLElement, opts: FatalPanelOptions): void {
30
+ const suffix = opts.idSuffix ?? "";
31
+ const doc = root.ownerDocument;
32
+ const surface = doc.createElement("dialog");
33
+ surface.className = "wt-fatal";
34
+ // alertdialog over the implicit `dialog` role: an urgent message to act on. No
35
+ // `aria-modal`: showModal() conveys modality, and an authored value can
36
+ // contradict the UA.
37
+ surface.setAttribute("role", "alertdialog");
38
+ surface.setAttribute("aria-labelledby", `wt-fatal-title${suffix}`);
39
+ surface.setAttribute("aria-describedby", `wt-fatal-message${suffix}`);
40
+ // Nothing is behind this panel, so reload is the only recovery; Escape must not
41
+ // close it.
42
+ surface.addEventListener("cancel", (ev) => {
43
+ ev.preventDefault();
44
+ });
45
+
46
+ const card = doc.createElement("div");
47
+ card.className = "wt-fatal-card";
48
+ const title = doc.createElement("h2");
49
+ title.id = `wt-fatal-title${suffix}`;
50
+ title.className = "wt-fatal-title";
51
+ title.textContent = STARTUP_FAILURE_COPY.title;
52
+ const messageEl = doc.createElement("p");
53
+ messageEl.id = `wt-fatal-message${suffix}`;
54
+ messageEl.className = "wt-fatal-message";
55
+ messageEl.textContent = opts.message ?? STARTUP_FAILURE_COPY.message;
56
+ const reloadButton = doc.createElement("button");
57
+ reloadButton.className = "wt-btn wt-fatal-reload";
58
+ reloadButton.type = "button";
59
+ reloadButton.textContent = STARTUP_FAILURE_COPY.reloadLabel;
60
+ reloadButton.addEventListener("click", () => {
61
+ reloadPage(windowOf(doc));
62
+ });
63
+ card.append(title, messageEl, reloadButton);
64
+ surface.appendChild(card);
65
+ root.replaceChildren(surface);
66
+
67
+ // showModal() requires a connected element in a document that has a window; a
68
+ // detached root or a created document arrives here from a typed call, where a
69
+ // throw would bury the real startup cause.
70
+ if (opts.modal && surface.isConnected && doc.defaultView !== null) {
71
+ surface.showModal();
72
+ } else {
73
+ surface.open = true;
74
+ }
75
+ reloadButton.focus();
76
+ }
@@ -0,0 +1,136 @@
1
+ import type { FeatureInstance, TerminalContext, TerminalFeature, Unsubscribe } from "./types.js";
2
+
3
+ /** Releases run last-in first-out: what one feature acquired, or what a pane or
4
+ * the shell holds. */
5
+ export interface CleanupScope {
6
+ /** Hold `release` until the drain. After the drain it runs at once, so a setup
7
+ * still running past its owner's teardown cannot leave anything behind. */
8
+ push(release: Unsubscribe): void;
9
+ drain(): void;
10
+ }
11
+
12
+ export function createCleanupScope(reportError: (err: unknown) => void): CleanupScope {
13
+ const releases: Unsubscribe[] = [];
14
+ let drained = false;
15
+ const run = (release: Unsubscribe): void => {
16
+ try {
17
+ release();
18
+ } catch (err) {
19
+ reportError(err);
20
+ }
21
+ };
22
+ return {
23
+ push(release) {
24
+ if (drained) {
25
+ run(release);
26
+ return;
27
+ }
28
+ releases.push(release);
29
+ },
30
+ drain() {
31
+ drained = true;
32
+ while (releases.length > 0) {
33
+ const release = releases.pop();
34
+ if (release) {
35
+ run(release);
36
+ }
37
+ }
38
+ },
39
+ };
40
+ }
41
+
42
+ export type FeatureSetupOutcome =
43
+ | { readonly status: "ready" }
44
+ | { readonly status: "aborted" }
45
+ | { readonly status: "failed"; readonly feature: string; readonly cause: unknown };
46
+
47
+ interface Entry {
48
+ readonly feature: TerminalFeature<unknown>;
49
+ readonly instance: FeatureInstance<unknown>;
50
+ readonly scope: CleanupScope;
51
+ }
52
+
53
+ export interface FeatureHost {
54
+ /** Set one feature up. The scope exists before `setup` runs, so a throw drains
55
+ * what the feature acquired; a `destroyed()` true after the await tears the
56
+ * instance down instead of registering it. */
57
+ setup(
58
+ feature: TerminalFeature<unknown>,
59
+ makeContext: (scope: CleanupScope) => TerminalContext,
60
+ destroyed: () => boolean,
61
+ ): Promise<FeatureSetupOutcome>;
62
+ /** The set-up instances, in setup order. */
63
+ instances(): readonly FeatureInstance<unknown>[];
64
+ /** The api of a set-up feature, or undefined. */
65
+ use(feature: TerminalFeature<unknown>): unknown;
66
+ /** The scope of every setup still pending, then every instance's `teardown()`
67
+ * in reverse order, each followed by its scope. */
68
+ teardownAll(): void;
69
+ }
70
+
71
+ export function createFeatureHost(
72
+ reportError: (feature: string, err: unknown) => void,
73
+ ): FeatureHost {
74
+ const entries: Entry[] = [];
75
+ const pending = new Set<CleanupScope>();
76
+ const apiMap = new Map<TerminalFeature<unknown>, unknown>();
77
+
78
+ return {
79
+ async setup(feature, makeContext, destroyed) {
80
+ // A feature enters `entries` only after `setup` returns, so the scope is
81
+ // the one owner of what a throwing or still-pending `setup` acquired.
82
+ const scope = createCleanupScope((err) => {
83
+ reportError(feature.name, err);
84
+ });
85
+ pending.add(scope);
86
+ try {
87
+ const instance = await feature.setup(makeContext(scope));
88
+ pending.delete(scope);
89
+ if (destroyed()) {
90
+ try {
91
+ instance.teardown();
92
+ } catch (err) {
93
+ reportError(feature.name, err);
94
+ }
95
+ scope.drain();
96
+ return { status: "aborted" };
97
+ }
98
+ entries.push({ feature, instance, scope });
99
+ (feature as { api?: unknown }).api = instance.api;
100
+ apiMap.set(feature, instance.api);
101
+ } catch (cause) {
102
+ pending.delete(scope);
103
+ scope.drain();
104
+ if (destroyed()) {
105
+ return { status: "aborted" };
106
+ }
107
+ reportError(feature.name, cause);
108
+ console.error(`web-terminal-ui: feature "${feature.name}" setup failed`, cause);
109
+ return { status: "failed", feature: feature.name, cause };
110
+ }
111
+ return { status: "ready" };
112
+ },
113
+ instances: () => entries.map((e) => e.instance),
114
+ use: (feature) => apiMap.get(feature),
115
+ teardownAll() {
116
+ for (const scope of pending) {
117
+ scope.drain();
118
+ }
119
+ pending.clear();
120
+ for (let i = entries.length - 1; i >= 0; i--) {
121
+ const entry = entries[i];
122
+ if (!entry) {
123
+ continue;
124
+ }
125
+ try {
126
+ entry.instance.teardown();
127
+ } catch (err) {
128
+ reportError(entry.feature.name, err);
129
+ }
130
+ entry.scope.drain();
131
+ }
132
+ entries.length = 0;
133
+ apiMap.clear();
134
+ },
135
+ };
136
+ }
@@ -16,17 +16,13 @@ export const TAP_MOVEMENT_PX = 10;
16
16
  * belongs to native text selection or the context menu. */
17
17
  export const TAP_MAX_MS = 500;
18
18
 
19
- /**
20
- * Whether a press landed on a LINK, in which case neither handler claims it: the
21
- * platform's own link affordances (preview on hold, activate on tap) win.
22
- *
23
- * Shared for the same reason the thresholds are, and it was NOT: the kernel
24
- * matched `.term-link` only while the menu matched `a[href], .term-link`, so an
25
- * ordinary anchor in the output was a link to one half of the boundary and not the
26
- * other — the menu stood down for the platform while tap-to-focus stole the press.
27
- * `.term-link` is the engine linkifier's class; `a[href]` covers anchors a consumer
28
- * or an application wrote itself.
29
- */
30
- export function isLinkTarget(target: EventTarget | null): boolean {
31
- return target instanceof Element && target.closest("a[href], .term-link") !== null;
19
+ /** Whether a press landed on a LINK, in which case neither handler claims it:
20
+ * the platform's own link affordances (preview on hold, activate on tap) win.
21
+ * One matcher for both halves of the boundary: the kernel once matched
22
+ * `.term-link` (the engine linkifier's class) while the menu also matched
23
+ * `a[href]`, so an ordinary anchor was a link to one half and not the other.
24
+ * `win` is the target's own realm: an element from another document is not an
25
+ * `Element` of the importing one. */
26
+ export function isLinkTarget(win: Window & typeof globalThis, target: EventTarget | null): boolean {
27
+ return target instanceof win.Element && target.closest("a[href], .term-link") !== null;
32
28
  }