@cplieger/web-terminal-ui 7.3.2 → 8.0.1

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 (50) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +114 -18
  3. package/THIRD_PARTY_NOTICES.md +7 -0
  4. package/css/30-tabs.css +21 -0
  5. package/css/31-switcher.css +15 -0
  6. package/css/32-split.css +176 -0
  7. package/css/MANIFEST +1 -0
  8. package/css/MANIFEST.tabbed +1 -0
  9. package/package.json +15 -12
  10. package/src/composition.ts +165 -237
  11. package/src/features/activity-monitor.ts +20 -55
  12. package/src/features/animations.ts +12 -30
  13. package/src/features/clipboard.ts +36 -64
  14. package/src/features/connection-banner.ts +10 -19
  15. package/src/features/context-menu.ts +70 -145
  16. package/src/features/dom.ts +44 -54
  17. package/src/features/menu-position.ts +24 -28
  18. package/src/features/mobile-toolbar.ts +32 -62
  19. package/src/features/predictive-echo.ts +15 -49
  20. package/src/features/scroll-to-bottom.ts +12 -24
  21. package/src/features/tabs/index.ts +1398 -1806
  22. package/src/features/tabs/ink-centre.ts +45 -54
  23. package/src/features/tabs/model.ts +301 -383
  24. package/src/features/tabs/strip.ts +45 -97
  25. package/src/features/tabs/switcher.ts +70 -48
  26. package/src/index.ts +13 -13
  27. package/src/kernel/a11y.ts +106 -27
  28. package/src/kernel/attention.ts +157 -0
  29. package/src/kernel/conn-state.ts +28 -22
  30. package/src/kernel/fatal.ts +76 -0
  31. package/src/kernel/feature-host.ts +136 -0
  32. package/src/kernel/gesture.ts +9 -13
  33. package/src/kernel/kernel.ts +57 -2139
  34. package/src/kernel/layout-policy.ts +44 -0
  35. package/src/kernel/loading-status.ts +30 -88
  36. package/src/kernel/navigation.ts +11 -17
  37. package/src/kernel/notify.ts +158 -0
  38. package/src/kernel/pane.ts +1415 -0
  39. package/src/kernel/realm.ts +11 -0
  40. package/src/kernel/regions.ts +8 -11
  41. package/src/kernel/scrollback.ts +88 -168
  42. package/src/kernel/selection.ts +25 -0
  43. package/src/kernel/shell.ts +1434 -0
  44. package/src/kernel/split-handle.ts +280 -0
  45. package/src/kernel/status-share.ts +100 -0
  46. package/src/kernel/types.ts +457 -441
  47. package/src/predict.ts +144 -170
  48. package/src/viewport.ts +124 -197
  49. package/src/features/tabs/attention.ts +0 -250
  50. package/src/features/tabs/notify.ts +0 -265
@@ -0,0 +1,44 @@
1
+ // The layout breakpoints, in ROOT pixels: root dimensions rather than viewport
2
+ // dimensions, so an embedded terminal in a narrow panel counts as narrow. For
3
+ // every full-page consumer root size equals viewport size, so the width half
4
+ // matches the old (max-width: 600px) media queries exactly.
5
+
6
+ import type { PaneSide } from "./types.js";
7
+
8
+ /** A root at or under this width is narrow (a portrait phone, a narrow panel). */
9
+ const NARROW_MAX_PX = 600;
10
+ /** A root at or under this height is narrow: a landscape phone (an iPhone 14 Pro
11
+ * Max is 932x430) is wider than NARROW_MAX_PX but still a phone in the hand,
12
+ * and the smallest iPad is 744 CSS px tall in landscape, so 500 separates the
13
+ * two with margin on both sides. */
14
+ const SHORT_MAX_PX = 500;
15
+
16
+ /** Whether a root of this size takes the compact (phone) layout. */
17
+ export function isNarrow(width: number, height: number): boolean {
18
+ return width <= NARROW_MAX_PX || height <= SHORT_MAX_PX;
19
+ }
20
+
21
+ /** Whether an untyped caller's value names a pane side. */
22
+ export function isPaneSide(value: unknown): value is PaneSide {
23
+ return value === "left" || value === "right";
24
+ }
25
+
26
+ /** The narrowest pane the split keeps: the narrowest common phone width. */
27
+ export const MIN_PANE_PX = 360;
28
+ /** The grid column between the two panes, showing the shell root's background. */
29
+ export const SPLIT_GUTTER_PX = 10;
30
+ /** The pane-row width two panes need: two minimum panes and the gutter. Under
31
+ * it the split buttons hide and an open split collapses to the selected pane. */
32
+ export const MIN_SPLIT_AREA_PX = 2 * MIN_PANE_PX + SPLIT_GUTTER_PX;
33
+
34
+ /** The EFFECTIVE left share a remembered `committed` share yields on a pane row
35
+ * `width` px wide: both panes are kept at or over MIN_PANE_PX without the
36
+ * remembered value being rewritten, the two bounds meet at 0.5 at exactly
37
+ * MIN_SPLIT_AREA_PX, and a narrower row (a collapsed split) reads 0.5. */
38
+ export function clampRatio(committed: number, width: number): number {
39
+ if (width <= MIN_SPLIT_AREA_PX) {
40
+ return 0.5;
41
+ }
42
+ const min = MIN_PANE_PX / (width - SPLIT_GUTTER_PX);
43
+ return Math.min(Math.max(committed, min), 1 - min);
44
+ }
@@ -1,39 +1,10 @@
1
- // Progressive status text for the consumer's pre-JS loading overlay.
2
- //
3
- // The problem this solves: the overlay is an opaque full-viewport box the
4
- // consumer paints before any JS runs, and the kernel only lowers it once the
5
- // first terminal frame arrives. Every piece of terminal chrome the library could
6
- // speak through -- the toast layer, the connection banner -- lives inside
7
- // .wt-root and paints UNDER that overlay, so for as long as startup takes, the
8
- // library can say nothing a sighted user can see. On a fast boot that does not
9
- // matter; the overlay is gone in well under a second. On web-terminal-kiro's
10
- // first boot it matters a lot: the server answers session creation with 503
11
- // "tools installing" while its tool engine installs, the session owner retries
12
- // for up to twenty minutes, and the user watched a black screen with an
13
- // unlabelled sweep bar the whole time, unable to tell a server that is
14
- // deliberately waiting from an app that has hung.
15
- //
16
- // The design, in order of what a user experiences:
17
- //
18
- // 1. SILENCE FIRST. Nothing is written for the first few seconds. A message
19
- // that flashes up and vanishes on a normal boot is worse than no message,
20
- // and most boots never reach the first threshold at all.
21
- // 2. ONE calm line once the wait is real ("Loading terminal...").
22
- // 3. After a minute, ROTATING reassurance. A single sentence frozen on screen
23
- // for nineteen more minutes reads exactly as hung as no sentence -- the
24
- // whole point is to show the system is still alive, so the line changes.
25
- // 4. A LIVE REASON supersedes all of the above the moment one is known. The
26
- // truthful wording for "tools installing" lives on the server, arrives in
27
- // the 503 body, and is pushed in by the session owner; it is strictly more
28
- // useful than anything this module could guess.
29
- //
30
- // Accessibility, and the trap that shapes the DOM here: the overlay is a
31
- // role="status" live region, so anything written into it is ANNOUNCED. Rotating
32
- // four messages every twenty seconds for twenty minutes would interrupt a screen
33
- // reader roughly sixty times to say nothing new. So the visible line is
34
- // aria-hidden and a separate off-screen live line carries only MEANINGFUL
35
- // changes -- the first threshold, and each new live reason. Sighted users get
36
- // motion; assistive tech gets information; neither gets the other's noise.
1
+ // Progressive status text for the consumer's pre-JS loading overlay: the one
2
+ // surface the library can speak through during startup, because the overlay
3
+ // paints over every piece of chrome inside .wt-root until the first frame lands.
4
+ // Silence first, one calm line once the wait is real, rotating reassurance after
5
+ // a minute, and a live reason from the server supersedes all of it.
6
+
7
+ import { windowOf } from "./realm.js";
37
8
 
38
9
  /** Wording for the progressive loading status. Every field has a library
39
10
  * default; a consumer overrides only what it wants to reword. */
@@ -91,11 +62,12 @@ export interface LoadingStatus {
91
62
  export function attachLoadingStatus(
92
63
  overlay: HTMLElement | undefined,
93
64
  messages: LoadingMessages = DEFAULT_LOADING_MESSAGES,
94
- doc: Document = document,
95
65
  ): LoadingStatus {
96
66
  if (!overlay) {
97
67
  return { reason: () => undefined, stop: () => undefined };
98
68
  }
69
+ const doc = overlay.ownerDocument;
70
+ const win = windowOf(doc);
99
71
 
100
72
  // The visible line. aria-hidden because the overlay is a live region and this
101
73
  // element's whole job is to CHANGE often; the live line below is what speaks.
@@ -129,17 +101,13 @@ export function attachLoadingStatus(
129
101
  let current = ""; // last reason shown, so a repeat call is a no-op
130
102
 
131
103
  const later = (fn: () => void, ms: number): void => {
132
- timers.push(window.setTimeout(fn, ms));
104
+ timers.push(win.setTimeout(fn, ms));
133
105
  };
134
106
 
135
- // Swap the visible text through a fade so a change reads as deliberate rather
136
- // than as a glitch. The first write skips the fade (there is nothing to fade
137
- // out) so the line does not arrive half-transparent.
138
- //
139
- // No `stopped` check, here or in the deferred half below, and that is
140
- // deliberate rather than an omission: see stop(), which owns the invariant.
107
+ // Swap through a fade so a change reads as deliberate. No `stopped` check here
108
+ // or in the deferred half: stop() owns that invariant.
141
109
  const show = (text: string): void => {
142
- // First write has nothing to fade out, so it must not arrive half-transparent.
110
+ // The first write has nothing to fade out, so it must not arrive half-transparent.
143
111
  if (visible.textContent === "") {
144
112
  visible.textContent = text;
145
113
  return;
@@ -165,19 +133,11 @@ export function attachLoadingStatus(
165
133
 
166
134
  later(() => {
167
135
  const waiting = messages.waiting;
168
- // `pinned` only. Not `stopped`: this callback is held by `timers`, so stop()
169
- // has cancelled it before it could read the flag. That disjunct was here and
170
- // was as dead as the ones deleted above — and invisible to the mutation
171
- // report, because a mutator works on binary-expression nodes and never
172
- // isolates a bare identifier operand, so `pinned || stopped` is mutated as a
173
- // pair and the pair is killable through `pinned`.
136
+ // `pinned` only, not `stopped`: this callback is held by `timers`, so stop()
137
+ // has cancelled it before it could read the flag.
174
138
  if (pinned || waiting.length === 0) {
175
139
  return;
176
140
  }
177
- // showAt reads through an undefined check rather than an index assertion:
178
- // under noUncheckedIndexedAccess every element read is `string | undefined`,
179
- // and the check is the only form both the assertion-style and
180
- // no-non-null-assertion lint rules accept.
181
141
  const showAt = (i: number): void => {
182
142
  const text = waiting[i];
183
143
  if (text !== undefined) {
@@ -187,14 +147,10 @@ export function attachLoadingStatus(
187
147
  // Deliberately NOT announced, now or on any rotation: these carry no
188
148
  // information a screen-reader user has not already been told once.
189
149
  showAt(0);
190
- rotation = window.setInterval(() => {
191
- // No pinned/stopped guard here: both reason() and stop() clear this
192
- // interval, so a tick cannot reach this line after either. The guard that
193
- // matters is the `pinned` check on the ENCLOSING timer above, because the
194
- // real production sequence puts reason() FIRST -- the server refuses
195
- // session creation within a second or two, long before this 60s threshold
196
- // -- and without it the rotation would start late and overwrite the
197
- // server's own explanation with generic reassurance.
150
+ rotation = win.setInterval(() => {
151
+ // No pinned/stopped guard: reason() and stop() both clear this interval.
152
+ // The `pinned` check on the enclosing timer is the one that matters, since
153
+ // the server's refusal arrives within seconds, long before this threshold.
198
154
  rotateIndex = (rotateIndex + 1) % waiting.length;
199
155
  showAt(rotateIndex);
200
156
  }, ROTATE_EVERY_MS);
@@ -202,50 +158,36 @@ export function attachLoadingStatus(
202
158
 
203
159
  return {
204
160
  reason(text: string): void {
205
- // Idempotent by text, and that is load-bearing rather than an
206
- // optimisation: the natural caller is a retry loop that knows the reason
207
- // on every tick, and re-writing the announced line would re-announce the
208
- // same sentence to a screen reader every few seconds for the whole wait.
209
- // Guarding here means a caller may call this as often as it likes.
161
+ // Idempotent by text: the caller is a retry loop that knows the reason on
162
+ // every tick, and a re-written live line is re-announced to a screen reader.
210
163
  if (stopped || text === "" || text === current) {
211
164
  return;
212
165
  }
213
166
  current = text;
214
167
  pinned = true;
215
168
  if (rotation !== undefined) {
216
- window.clearInterval(rotation);
169
+ win.clearInterval(rotation);
217
170
  rotation = undefined;
218
171
  }
219
172
  show(text);
220
173
  announce(text);
221
174
  },
222
175
  stop(): void {
223
- // THE OWNER of "nothing further happens", and the only one.
224
- //
225
- // It discharges that by cancelling every callback this controller armed:
226
- // `later()` is the sole way to schedule here, everything it schedules is in
227
- // `timers`, and the rotation interval is the one handle held separately.
228
- // So the only call that can arrive after this line is one from OUTSIDE a
229
- // cancellable callback — which is `reason()`, and `reason()` is where
230
- // `stopped` is read.
231
- //
232
- // `show()` and `announce()` used to re-test it as well, and the mutation
233
- // pass measured what that was worth: five permanently unkillable mutants,
234
- // because no input could reach either function with `stopped` true. They
235
- // were not even protecting anything if they had — the two nodes are
236
- // detached three lines below, so a late write goes nowhere. The invariant
237
- // is pinned by a test instead ("stop() leaves no timer armed"), which,
238
- // unlike a guard, fails for the change that would actually break it: a
239
- // future timer armed outside `later()`.
176
+ // The one owner of "nothing further happens": every callback is armed
177
+ // through later() and lands in `timers`, the rotation is the one handle
178
+ // held apart, so after this line only reason(), the one entry outside a
179
+ // cancellable callback, can run, and it is the one reader of `stopped`.
180
+ // show() and announce() do not re-test it; a timer armed outside later()
181
+ // is what the "stop() leaves no timer armed" test catches.
240
182
  if (stopped) {
241
183
  return;
242
184
  }
243
185
  stopped = true;
244
186
  for (const t of timers) {
245
- window.clearTimeout(t);
187
+ win.clearTimeout(t);
246
188
  }
247
189
  if (rotation !== undefined) {
248
- window.clearInterval(rotation);
190
+ win.clearInterval(rotation);
249
191
  }
250
192
  visible.remove();
251
193
  live.remove();
@@ -1,19 +1,13 @@
1
- // navigation.ts — the library's one page-navigation call, behind its own module.
2
- //
3
- // `window.location.reload` cannot be substituted in a real browser: `reload` is
4
- // an own, sealed property of the location instance (`configurable: false`,
5
- // `writable: false`), `Location.prototype` carries no `reload` to spy on, and
6
- // `window`, `globalThis.window` and `globalThis.location` are all
7
- // non-configurable. So a test cannot redefine it, assign over it, or stub the
8
- // global, and letting the real call run navigates the test runner's own frame
9
- // and takes the whole run down with it.
10
- //
11
- // A separate module is the seam that works: a caller's import is a live binding
12
- // the test framework can replace, which an intra-module call is not. Keeping the
13
- // call here also means there is exactly one place in the library that navigates,
14
- // so the next one has to be added deliberately.
1
+ // The library's one page-navigation call, in its own module because a test
2
+ // cannot substitute `window.location.reload`: `reload` is an own sealed property
3
+ // of the location instance, `Location.prototype` has no `reload`, and `window`
4
+ // and `globalThis.location` are non-configurable, so the real call would
5
+ // navigate the test runner's own frame. A caller's import of this module is the
6
+ // live binding a test can replace; keeping the call here also means exactly one
7
+ // place in the library navigates.
15
8
 
16
- /** Reload the current page. The only navigation this library performs. */
17
- export function reloadPage(): void {
18
- window.location.reload();
9
+ /** Reload the page `win` shows, the terminal's own document. The only navigation
10
+ * this library performs. */
11
+ export function reloadPage(win: Window): void {
12
+ win.location.reload();
19
13
  }
@@ -0,0 +1,158 @@
1
+ // The non-persistent `Notification` constructor is the one notification surface
2
+ // a page without a service worker has. browserNotifierEnv() is the one place
3
+ // that reads globals; everything else takes a NotifierEnv.
4
+
5
+ import type { Notifier } from "./types.js";
6
+
7
+ interface NotificationLike {
8
+ onclick: ((event: Event) => void) | null;
9
+ close: () => void;
10
+ }
11
+
12
+ /** The slice of `window.Notification` used here. */
13
+ export type NotificationCtorLike = new (
14
+ title: string,
15
+ options?: { body?: string; tag?: string },
16
+ ) => NotificationLike;
17
+
18
+ /** The capabilities a notifier needs. `permission` is a plain string rather than
19
+ * the DOM's union: a browser may report a value this code does not know, and an
20
+ * unknown value must degrade rather than be asserted. */
21
+ export interface NotifierEnv {
22
+ /** The Notification constructor, or undefined where the API does not exist
23
+ * (iOS Safari outside an installed PWA, a non-secure context). */
24
+ ctor: NotificationCtorLike | undefined;
25
+ /** Current permission: "default" | "granted" | "denied" (or anything else). */
26
+ permission: () => string;
27
+ /** Ask for permission. Must be called from a user gesture. */
28
+ request: () => void;
29
+ /** Is the page currently visible to the user? */
30
+ pageVisible: () => boolean;
31
+ }
32
+
33
+ /** The suppression rule: skip ONLY when the user is already looking at the
34
+ * terminal that produced the notification. A hidden page (another browser tab, a
35
+ * locked phone) notifies even for the active session; keying on the active tab
36
+ * alone would swallow the notification of the session the user left running. */
37
+ export function shouldNotify(sessionIsActive: boolean, pageVisible: boolean): boolean {
38
+ return !(sessionIsActive && pageVisible);
39
+ }
40
+
41
+ /** Bound on the per-session dedupe map: each key is a live session server-side,
42
+ * so a real page is nowhere near this and a hostile stream cannot grow it. */
43
+ const MAX_TRACKED_SESSIONS = 200;
44
+
45
+ export function createNotifier(env: NotifierEnv): Notifier {
46
+ // Highest sequence delivered per session. The engine never replays a
47
+ // notification; this makes a doubled frame unable to notify twice for one event.
48
+ const lastSeq = new Map<string, number>();
49
+ let armed = false;
50
+ let requested = false;
51
+
52
+ function evict(): void {
53
+ while (lastSeq.size > MAX_TRACKED_SESSIONS) {
54
+ const oldest = lastSeq.keys().next().value;
55
+ if (oldest === undefined) {
56
+ return;
57
+ }
58
+ lastSeq.delete(oldest);
59
+ }
60
+ }
61
+
62
+ return {
63
+ deliver(ev, view): boolean {
64
+ const text = ev.notification ?? "";
65
+ if (text === "") {
66
+ return false;
67
+ }
68
+ const seq = ev.notificationSeq ?? 0;
69
+ if (seq !== 0) {
70
+ if (seq <= (lastSeq.get(ev.id) ?? 0)) {
71
+ return false;
72
+ }
73
+ lastSeq.set(ev.id, seq);
74
+ evict();
75
+ }
76
+ if (!shouldNotify(view.sessionIsActive, env.pageVisible())) {
77
+ return false;
78
+ }
79
+ const Ctor = env.ctor;
80
+ if (!Ctor || env.permission() !== "granted") {
81
+ return false;
82
+ }
83
+ try {
84
+ // The message is untrusted program output: it is a DATA string to the
85
+ // Notification API and never enters this document.
86
+ const posted = new Ctor(view.label, { body: text, tag: ev.id });
87
+ // The click's own default already focuses the page, so no preventDefault
88
+ // and no window.focus(). Not the `navigate` option: it reloads the page,
89
+ // dropping every socket to land where the page already is.
90
+ posted.onclick = (): void => {
91
+ view.activate();
92
+ posted.close();
93
+ };
94
+ return true;
95
+ } catch {
96
+ // Safari throws on `new Notification` outside an installed PWA.
97
+ return false;
98
+ }
99
+ },
100
+ arm(): void {
101
+ armed = true;
102
+ },
103
+ gesture(): void {
104
+ // Safari rejects and Chrome penalises a prompt with no gesture behind it.
105
+ // Gated on `armed` so a plain shell, which emits no OSC 9, never prompts.
106
+ if (!armed || requested) {
107
+ return;
108
+ }
109
+ const Ctor = env.ctor;
110
+ if (!Ctor || env.permission() !== "default") {
111
+ return;
112
+ }
113
+ requested = true;
114
+ try {
115
+ env.request();
116
+ } catch {
117
+ /* a browser that refuses to be asked is a tab-only browser */
118
+ }
119
+ },
120
+ forget(id): void {
121
+ lastSeq.delete(id);
122
+ },
123
+ };
124
+ }
125
+
126
+ /** Bind a notifier to `doc`'s browser realm. `Notification` is read through
127
+ * `unknown` because it is absent on iOS Safari and in test DOMs, and a
128
+ * non-callable value (a host page's shim) reads as absent. Both
129
+ * `requestPermission` shapes are tolerated, since the result is not awaited: the
130
+ * answer is read from `permission()` at the next notification. */
131
+ export function browserNotifierEnv(doc: Document): NotifierEnv {
132
+ const ctor = (): unknown => (doc.defaultView as { Notification?: unknown } | null)?.Notification;
133
+ const api = (): { permission?: unknown; requestPermission?: unknown } | undefined => {
134
+ const value = ctor();
135
+ return typeof value === "function"
136
+ ? (value as unknown as { permission?: unknown; requestPermission?: unknown })
137
+ : undefined;
138
+ };
139
+ return {
140
+ get ctor(): NotificationCtorLike | undefined {
141
+ const value = ctor();
142
+ return typeof value === "function" ? (value as NotificationCtorLike) : undefined;
143
+ },
144
+ permission: (): string => {
145
+ const value = api()?.permission;
146
+ return typeof value === "string" ? value : "denied";
147
+ },
148
+ request: (): void => {
149
+ const fn = api()?.requestPermission;
150
+ if (typeof fn === "function") {
151
+ (fn as () => unknown).call(api());
152
+ }
153
+ },
154
+ // Not document.hasFocus(): that is false for a visible-but-unfocused window,
155
+ // where the terminal IS on screen.
156
+ pageVisible: (): boolean => doc.visibilityState !== "hidden",
157
+ };
158
+ }