@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.
- package/README.md +114 -18
- package/css/30-tabs.css +52 -13
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +13 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- 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
|
-
}
|