@cplieger/web-terminal-ui 7.3.1 → 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 +52 -13
  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
@@ -1,265 +0,0 @@
1
- // tabs/notify.ts — OSC 9 Form B notifications (`OSC 9 ; <message> ST`) as
2
- // BROWSER notifications. The spec names the display mode ("post a
3
- // notification"), which is why this one signal is not tab-only chrome: a
4
- // finished turn or a permission prompt has to be able to reach a user who is
5
- // looking at another app. Clicking one takes the user to the session that raised
6
- // it (see deliver).
7
- //
8
- // NON-PERSISTENT notifications (the `new Notification()` constructor), which is
9
- // the standard surface available to a page with no service worker. Their lifetime
10
- // is tied to the page's, which suits this app exactly: the notification is about
11
- // a live PTY the page is attached to, and there is nothing useful to click once
12
- // the page is gone. Per the Notifications API the constructor throws on most
13
- // mobile browsers, where persistent (service-worker) notifications are the only
14
- // route; that throw is caught below and degrades to tab-only chrome. Registering
15
- // a service worker for an app that is otherwise entirely live is a cost this does
16
- // not pay — mobile support for the standard constructor is the platforms' side of
17
- // the contract.
18
- //
19
- // A notification is an EVENT, not a state (that is the structural difference
20
- // from OSC 9;4 progress, which is a state and drives the status dot): the engine
21
- // latches nothing for it and never replays it, so it is delivered once, on the
22
- // sweep that first observes it.
23
- //
24
- // DOM-free and global-free by construction: every capability comes in through
25
- // NotifierEnv, so the decisions here are unit-testable without a browser
26
- // permission model, and the one place that touches globals is
27
- // browserNotifierEnv() below. The notification text NEVER enters the DOM — see
28
- // the note on deliver().
29
-
30
- /** The slice of a non-persistent `Notification` instance this feature uses: the
31
- * `click` handler and `close()`. The instance is no longer discarded — attaching
32
- * a click handler is what lets a notification finish its job (see deliver).
33
- * Module-private: it is the ctor type's return shape, not a consumer surface. */
34
- interface NotificationLike {
35
- onclick: ((event: Event) => void) | null;
36
- close: () => void;
37
- }
38
-
39
- /** The slice of `window.Notification` used here. */
40
- export type NotificationCtorLike = new (
41
- title: string,
42
- options?: { body?: string; tag?: string },
43
- ) => NotificationLike;
44
-
45
- /** The capabilities a notifier needs, all injected. `permission` and `request`
46
- * are read as plain strings rather than the DOM's NotificationPermission union:
47
- * they come from a browser that may report something newer, and a value we do
48
- * not recognise must degrade rather than be asserted. */
49
- export interface NotifierEnv {
50
- /** The Notification constructor, or undefined where the API does not exist
51
- * (iOS Safari outside an installed PWA, a non-secure context). */
52
- ctor: NotificationCtorLike | undefined;
53
- /** Current permission: "default" | "granted" | "denied" (or anything else). */
54
- permission: () => string;
55
- /** Ask for permission. Must be called from a user gesture. */
56
- request: () => void;
57
- /** Is the page currently visible to the user? */
58
- pageVisible: () => boolean;
59
- }
60
-
61
- /** One status event's notification payload (the fields SessionStatus carries for
62
- * it). Both are optional: an older server, or any event that carries no
63
- * notification, simply omits them. */
64
- interface NotificationEvent {
65
- readonly id: string;
66
- readonly notification?: string | undefined;
67
- readonly notificationSeq?: number | undefined;
68
- }
69
-
70
- /** shouldNotify is the suppression rule, alone and testable.
71
- *
72
- * Skip a notification ONLY when the user is already looking at the terminal that
73
- * produced it: its tab is the active one AND the page is visible. Both halves
74
- * are required, and the second is the one that matters most — when the page is
75
- * HIDDEN (another browser tab, a locked phone, a minimised window) the
76
- * notification fires even for the active session, because that is exactly the
77
- * situation where the user cannot see the terminal at all. A rule that keyed
78
- * only on "is this the active tab" would silently swallow the notification of
79
- * the very session the user left running. */
80
- export function shouldNotify(sessionIsActive: boolean, pageVisible: boolean): boolean {
81
- return !(sessionIsActive && pageVisible);
82
- }
83
-
84
- /** Bound on the per-session dedupe map, same reasoning as the model's other
85
- * bounded stores: each key is a live session server-side, so a real one is
86
- * nowhere near this, and a hostile or buggy stream cannot grow it without end. */
87
- const MAX_TRACKED_SESSIONS = 200;
88
-
89
- export interface Notifier {
90
- /** Deliver one status event's notification, if it carries a new one. Returns
91
- * true when a browser notification was actually posted (false covers every
92
- * degraded path: no notification in the event, a repeat, suppression, no API,
93
- * no permission, a throwing constructor).
94
- *
95
- * `view.activate` is what the notification's click performs in the page. The
96
- * caller supplies it, so this module still knows nothing about sessions, tabs
97
- * or the DOM. */
98
- deliver(
99
- ev: NotificationEvent,
100
- view: { sessionIsActive: boolean; label: string; activate: () => void },
101
- ): boolean;
102
- /** Note that this page has a session capable of notifying, which is what makes
103
- * a permission prompt worth raising at all. */
104
- arm(): void;
105
- /** Note a user gesture: the ONLY moment a permission prompt may be raised. */
106
- gesture(): void;
107
- /** Drop a closed session's dedupe state. */
108
- forget(id: string): void;
109
- }
110
-
111
- export function createNotifier(env: NotifierEnv): Notifier {
112
- // Highest notification sequence delivered per session. The engine never
113
- // replays a notification, so this is a belt-and-braces guard rather than the
114
- // mechanism that makes re-delivery safe: it costs one map and it means a
115
- // duplicated frame (a doubled subscriber, a consumer re-emitting a snapshot)
116
- // cannot notify twice for one event.
117
- const lastSeq = new Map<string, number>();
118
- let armed = false;
119
- let requested = false;
120
-
121
- function evict(): void {
122
- while (lastSeq.size > MAX_TRACKED_SESSIONS) {
123
- const oldest = lastSeq.keys().next().value;
124
- if (oldest === undefined) {
125
- return;
126
- }
127
- lastSeq.delete(oldest);
128
- }
129
- }
130
-
131
- return {
132
- deliver(ev, view): boolean {
133
- const text = ev.notification ?? "";
134
- if (text === "") {
135
- return false;
136
- }
137
- const seq = ev.notificationSeq ?? 0;
138
- if (seq !== 0) {
139
- if (seq <= (lastSeq.get(ev.id) ?? 0)) {
140
- return false; // already delivered this event
141
- }
142
- lastSeq.set(ev.id, seq);
143
- evict();
144
- }
145
- if (!shouldNotify(view.sessionIsActive, env.pageVisible())) {
146
- return false;
147
- }
148
- const Ctor = env.ctor;
149
- if (!Ctor || env.permission() !== "granted") {
150
- // Degrade silently to tab-only: the session's dot and the switcher's
151
- // aggregate cue already carry that something happened. No throw, no
152
- // console noise — an unpermitted notification is a normal state of the
153
- // world, not an error.
154
- return false;
155
- }
156
- try {
157
- // The message is UNTRUSTED program output. It is passed as DATA to the
158
- // Notification API (a string option, rendered by the browser's own
159
- // notification surface) and is never written into the DOM — not as
160
- // innerHTML, not as a template string that becomes markup, not into a
161
- // label. The engine already strips control/bidi runes and clamps the
162
- // length; that is not widened here. If this ever needs an in-page
163
- // surface, it must use textContent.
164
- const posted = new Ctor(view.label, { body: text, tag: ev.id });
165
- // A notification the user clicks should land them on the terminal that
166
- // raised it. Half of that is the click event's OWN default behaviour —
167
- // per the Notifications API, activating a non-persistent notification
168
- // moves focus to the viewport of its browsing context — so this handler
169
- // deliberately does NOT preventDefault() and does NOT call
170
- // window.focus(): the platform already brings the page forward, and the
171
- // only missing half is switching to the right session inside it. That
172
- // asymmetry is the whole bug this fixes: the page came forward and then
173
- // showed whichever tab the user happened to leave active.
174
- //
175
- // NOT the `navigate` option, which is the declarative alternative and
176
- // bypasses the click event entirely: it NAVIGATES, which for a live
177
- // terminal means dropping every WebSocket and replaying scrollback to
178
- // arrive somewhere the page already was. A same-page switch is both
179
- // cheaper and correct. Do not "modernise" this into `navigate`.
180
- posted.onclick = (): void => {
181
- view.activate();
182
- // The user has just acted on it, so it is no longer relevant — the
183
- // documented reason to close one. (Closing is only wrong when used as
184
- // a display timer, which also strips it from the notification tray
185
- // before the user can interact.)
186
- posted.close();
187
- };
188
- return true;
189
- } catch {
190
- // Safari throws on `new Notification` outside an installed PWA even when
191
- // the constructor exists. Tab-only, silently.
192
- return false;
193
- }
194
- },
195
- arm(): void {
196
- armed = true;
197
- },
198
- gesture(): void {
199
- // Permission is requested from a USER GESTURE, never at load: Safari
200
- // rejects (and Chrome penalises) a prompt with no gesture behind it, and a
201
- // prompt on page load is the pattern browsers added those rules for.
202
- //
203
- // Gated on `armed` as well, so only a page that actually has a
204
- // notification-capable session ever prompts: a plain shell emits no OSC 9,
205
- // and asking its user for notification permission is a prompt they can
206
- // only answer wrongly. The cost of that gate is that the FIRST
207
- // notification of a session cannot be shown (the prompt has not been
208
- // answered yet) — unavoidable in any case, since permission cannot be
209
- // obtained without a gesture, and a first signal rarely coincides with one.
210
- if (!armed || requested) {
211
- return;
212
- }
213
- const Ctor = env.ctor;
214
- if (!Ctor || env.permission() !== "default") {
215
- return; // absent API, or already granted/denied: nothing to ask
216
- }
217
- requested = true; // once per page, whatever the answer
218
- try {
219
- env.request();
220
- } catch {
221
- /* a browser that refuses to be asked is a tab-only browser */
222
- }
223
- },
224
- forget(id): void {
225
- lastSeq.delete(id);
226
- },
227
- };
228
- }
229
-
230
- /** browserNotifierEnv binds a notifier to the real browser APIs. The single
231
- * place in this feature that touches globals, and it reads them through
232
- * `unknown` on purpose: `Notification` is absent (not merely denied) on iOS
233
- * Safari and in test DOMs, so nothing here may assume the declared global
234
- * exists. Both `requestPermission` shapes are tolerated — modern browsers
235
- * return a promise, older Safari takes a callback and returns undefined — since
236
- * the result is not awaited: the answer is read from `permission()` at the next
237
- * notification. */
238
- export function browserNotifierEnv(): NotifierEnv {
239
- const api = (): { permission?: unknown; requestPermission?: unknown } | undefined => {
240
- const value: unknown = (globalThis as { Notification?: unknown }).Notification;
241
- return typeof value === "function"
242
- ? (value as unknown as { permission?: unknown; requestPermission?: unknown })
243
- : undefined;
244
- };
245
- return {
246
- get ctor(): NotificationCtorLike | undefined {
247
- const value: unknown = (globalThis as { Notification?: unknown }).Notification;
248
- return typeof value === "function" ? (value as NotificationCtorLike) : undefined;
249
- },
250
- permission: (): string => {
251
- const value = api()?.permission;
252
- return typeof value === "string" ? value : "denied";
253
- },
254
- request: (): void => {
255
- const fn = api()?.requestPermission;
256
- if (typeof fn === "function") {
257
- (fn as () => unknown).call(api());
258
- }
259
- },
260
- // document.visibilityState is the only reliable "can the user see this page"
261
- // signal; document.hasFocus() is false for a visible-but-unfocused window,
262
- // where the terminal IS on screen and a notification would be redundant.
263
- pageVisible: (): boolean => document.visibilityState !== "hidden",
264
- };
265
- }