@cplieger/web-terminal-ui 5.0.3 → 5.2.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 +63 -15
- package/css/00-tokens.css +53 -1
- package/css/30-tabs.css +194 -76
- package/css/31-switcher.css +29 -25
- package/css/page.css +8 -4
- package/package.json +4 -3
- package/src/features/activity-monitor.ts +4 -2
- package/src/features/tabs/index.ts +154 -39
- package/src/features/tabs/model.ts +184 -7
- package/src/features/tabs/notify.ts +265 -0
- package/src/features/tabs/strip.ts +56 -14
- package/src/index.ts +2 -0
- package/src/kernel/style-contract.ts +96 -0
- package/src/kernel/types.ts +13 -6
|
@@ -14,11 +14,175 @@ import type { TabHandle } from "../../kernel/types.js";
|
|
|
14
14
|
// contract has one home. Type-only import: erases at compile, no runtime dep.
|
|
15
15
|
export type { SessionInfo };
|
|
16
16
|
|
|
17
|
+
/** A status record as the tabs feature consumes it: the session's REST wire
|
|
18
|
+
* shape plus the percentage that exists only on the status STREAM
|
|
19
|
+
* (SessionStatus.progressValue). Both status sources flow through one code path
|
|
20
|
+
* this way, and the optionality is meaningful: the polling fallback lists
|
|
21
|
+
* SessionInfo with no percentage at all, which means "no information" and must
|
|
22
|
+
* not be read as "the percentage was cleared". */
|
|
23
|
+
export type StatusRecord = SessionInfo & { readonly progressValue?: number };
|
|
24
|
+
|
|
25
|
+
// The status vocabulary, module-private on purpose: every consumer asks one of
|
|
26
|
+
// the predicates below (isEndedStatus / statusRevealsDot / isCueStatus /
|
|
27
|
+
// statusPhrase) instead of comparing strings itself, so a status's
|
|
28
|
+
// MEANING has exactly one home. Adding a status means adding it to the
|
|
29
|
+
// predicates that should include it, not to a comparison at each call site.
|
|
30
|
+
//
|
|
17
31
|
// The server-side status of a session whose process has exited (mirrors the
|
|
18
32
|
// engine's terminal.StatusExited). Such a session is viewable history — its
|
|
19
33
|
// final screen replays and the kernel shows "Session ended" — but it can never
|
|
20
34
|
// produce output again, so session selection prefers live sessions everywhere.
|
|
21
|
-
|
|
35
|
+
const STATUS_EXITED = "exited";
|
|
36
|
+
|
|
37
|
+
/** The other end-of-process status (engine terminal.StatusCrashed): a non-zero
|
|
38
|
+
* exit status, or a terminating signal the program was not asked for. A
|
|
39
|
+
* server-initiated end (a closed tab, the idle reaper, a server shutdown) is
|
|
40
|
+
* reported as "exited", so a routine restart never paints as a failure. Like
|
|
41
|
+
* "exited" it is terminal: nothing clears it, and it outranks every progress
|
|
42
|
+
* state. */
|
|
43
|
+
const STATUS_CRASHED = "crashed";
|
|
44
|
+
|
|
45
|
+
/** OSC 9;4 progress state 2, the error state (iTerm2 semantics). A STATE, not an
|
|
46
|
+
* event: it persists until the program reports another progress state, or the
|
|
47
|
+
* process dies (which outranks it). */
|
|
48
|
+
const STATUS_FAILED = "failed";
|
|
49
|
+
|
|
50
|
+
/** OSC 9;4 progress state 4, the warning state (iTerm2 semantics; ConEmu calls
|
|
51
|
+
* the same state paused). Same persistence rules as STATUS_FAILED. */
|
|
52
|
+
const STATUS_WARNING = "warning";
|
|
53
|
+
|
|
54
|
+
/** isEndedStatus reports whether a status means the session's process is GONE,
|
|
55
|
+
* whichever way it went. Session selection asks this — not `=== "exited"` —
|
|
56
|
+
* because a crashed session is exactly as dead as an exited one: reloading onto
|
|
57
|
+
* either is the stuck-loading wedge the bootstrap's live-session preference
|
|
58
|
+
* exists to avoid. */
|
|
59
|
+
export function isEndedStatus(status: string): boolean {
|
|
60
|
+
return status === STATUS_EXITED || status === STATUS_CRASHED;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The statuses whose dot must be visible even if the server never set the
|
|
64
|
+
* session's sticky reportsActivity flag. `warning` and `failed` are OSC 9;4
|
|
65
|
+
* states, so the flag is set for them in practice and this only floors it;
|
|
66
|
+
* `crashed` is the case that needs it — a plain shell that dies badly has
|
|
67
|
+
* reported no activity in its life, and its red dot is the one thing the user
|
|
68
|
+
* most needs to see. An ordinary `exited` deliberately stays gated: a clean end
|
|
69
|
+
* is not news. */
|
|
70
|
+
export function statusRevealsDot(status: string): boolean {
|
|
71
|
+
return status === STATUS_WARNING || status === STATUS_FAILED || status === STATUS_CRASHED;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** statusPhrase is the human wording for a status, used for BOTH the dot's hover
|
|
75
|
+
* tooltip and the suffix in a tab's accessible name — one definition, so what a
|
|
76
|
+
* sighted user reads on hover and what a screen reader announces cannot
|
|
77
|
+
* disagree. An unknown status (a newer server: the wire is parsed, not
|
|
78
|
+
* validated) falls back to the raw value rather than being hidden. */
|
|
79
|
+
export function statusPhrase(status: string): string {
|
|
80
|
+
switch (status) {
|
|
81
|
+
case "working":
|
|
82
|
+
return "working";
|
|
83
|
+
case STATUS_WARNING:
|
|
84
|
+
return "warning reported";
|
|
85
|
+
case STATUS_FAILED:
|
|
86
|
+
return "error reported";
|
|
87
|
+
case "input":
|
|
88
|
+
return "waiting for you";
|
|
89
|
+
case "done":
|
|
90
|
+
return "turn finished";
|
|
91
|
+
case STATUS_EXITED:
|
|
92
|
+
return "session ended";
|
|
93
|
+
case STATUS_CRASHED:
|
|
94
|
+
return "process crashed";
|
|
95
|
+
case "idle":
|
|
96
|
+
case "":
|
|
97
|
+
return "idle";
|
|
98
|
+
default:
|
|
99
|
+
return status;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** PROGRESS_ABSENT is "no percentage" (the engine's own marker). Absence is -1,
|
|
104
|
+
* never 0: a session that reported nothing is not a session at 0%, and the two
|
|
105
|
+
* must render differently (no bar at all vs an empty bar). */
|
|
106
|
+
export const PROGRESS_ABSENT = -1;
|
|
107
|
+
|
|
108
|
+
/** normalizeProgress cleans a percentage off the wire into PROGRESS_ABSENT or
|
|
109
|
+
* 0-100. The engine already clamps, so this is the untrusted-JSON guard: a
|
|
110
|
+
* string, a NaN, an Infinity or a negative all mean "absent" rather than
|
|
111
|
+
* throwing or rendering a nonsense bar, and an out-of-range high value clamps
|
|
112
|
+
* rather than overflowing the chip. */
|
|
113
|
+
export function normalizeProgress(value: unknown): number {
|
|
114
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
115
|
+
return PROGRESS_ABSENT;
|
|
116
|
+
}
|
|
117
|
+
return Math.min(100, Math.round(value));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** statusOwnsProgress reports whether a status is one the program's OSC 9;4
|
|
121
|
+
* progress channel is currently speaking through. Only these may show a bar.
|
|
122
|
+
*
|
|
123
|
+
* The tab keeps the last percentage the server reported, and the server keeps
|
|
124
|
+
* sending it (correctly — a progress state persists until the program changes
|
|
125
|
+
* it), so this predicate is what stops that value from being painted underneath a
|
|
126
|
+
* status that came from somewhere else entirely. `input` and `done` come from the
|
|
127
|
+
* NOTIFICATION channel; `idle` is the absence of a progress state; `exited` and
|
|
128
|
+
* `crashed` are process facts. A percentage under any of those is a claim about
|
|
129
|
+
* a different channel than the one the reader is looking at. */
|
|
130
|
+
export function statusOwnsProgress(status: string): boolean {
|
|
131
|
+
return status === "working" || status === STATUS_FAILED || status === STATUS_WARNING;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** renderedProgress is the percentage a status may actually SHOW. The tab keeps
|
|
135
|
+
* the last value the server reported; this is the one place that decides whether
|
|
136
|
+
* it is still meaningful, so the clearing rule has a single home.
|
|
137
|
+
*
|
|
138
|
+
* Two things clear it, and both are honest — neither invents a state change the
|
|
139
|
+
* program never made:
|
|
140
|
+
*
|
|
141
|
+
* 1. The program's own clear: OSC 9;4;0, or the abbreviated OSC 9;4, which the
|
|
142
|
+
* engine reports as percentage -1. That is the spec path, and the value
|
|
143
|
+
* arriving as PROGRESS_ABSENT is all it takes.
|
|
144
|
+
* 2. The status is not one the progress channel owns (statusOwnsProgress) —
|
|
145
|
+
* checked here. That covers a dead process, whose progress is meaningless
|
|
146
|
+
* and which nothing will ever clear, and equally a `done` or `input` latch,
|
|
147
|
+
* which is the notification channel talking. Measured: kiro-cli parks
|
|
148
|
+
* progress state 4 at its context-usage percentage when idle, so a finished
|
|
149
|
+
* turn used to paint a green done dot beside a 72% bar — a number about the
|
|
150
|
+
* context window, rendered as how far along the work was.
|
|
151
|
+
*
|
|
152
|
+
* There is deliberately NO third clear. 100% is not special: state 1 at 100 is a
|
|
153
|
+
* STATE that persists, and the progress channel carries no "done" signal at all,
|
|
154
|
+
* so a program that pins 100 and goes quiet keeps its bar — the same "persists
|
|
155
|
+
* until the program says otherwise" property the indeterminate state already has.
|
|
156
|
+
* There is also no TIMEOUT: a timer would assert a change the program never
|
|
157
|
+
* reported (Ghostty's ~15s one does, which is why it also asks emitters to
|
|
158
|
+
* refresh once a second; nothing here requires a keep-alive). */
|
|
159
|
+
export function renderedProgress(status: string, progress: number): number {
|
|
160
|
+
return statusOwnsProgress(status) ? progress : PROGRESS_ABSENT;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** tabAccessibleName is a tab's announced name: its rendered label plus the
|
|
164
|
+
* session's state, and the percentage when one is showing.
|
|
165
|
+
*
|
|
166
|
+
* The percentage is announced but never DRAWN as text. No terminal emulator puts
|
|
167
|
+
* a number next to a tab label — Windows Terminal draws a ring over the tab icon,
|
|
168
|
+
* Ghostty and kitty a bar on a window edge, ConEmu a percentage in the WINDOW
|
|
169
|
+
* title — and the visible number cost label width in a chip that already shrinks
|
|
170
|
+
* to a 100px floor. The bar answers "how far" for the eye; this answers it for a
|
|
171
|
+
* screen reader, which cannot see the bar at all (it is decoration with no text).
|
|
172
|
+
*
|
|
173
|
+
* Pass PROGRESS_ABSENT for progress to announce the state alone. Callers hand in
|
|
174
|
+
* renderedProgress's output, so a percentage the tab is not showing is not
|
|
175
|
+
* announced either. */
|
|
176
|
+
export function tabAccessibleName(
|
|
177
|
+
renderedLabel: string,
|
|
178
|
+
status: string,
|
|
179
|
+
progress: number = PROGRESS_ABSENT,
|
|
180
|
+
): string {
|
|
181
|
+
const state = statusPhrase(status);
|
|
182
|
+
return progress < 0
|
|
183
|
+
? `${renderedLabel} — ${state}`
|
|
184
|
+
: `${renderedLabel} — ${state}, ${String(progress)}%`;
|
|
185
|
+
}
|
|
22
186
|
|
|
23
187
|
// localStorage key for the last active session id, so a page reload reopens the
|
|
24
188
|
// tab the user left on rather than always defaulting to the oldest one.
|
|
@@ -87,15 +251,20 @@ export function serializeTabOrder(ids: readonly string[]): string {
|
|
|
87
251
|
return JSON.stringify(ids.slice(0, MAX_PERSISTED_TAB_ORDER));
|
|
88
252
|
}
|
|
89
253
|
|
|
90
|
-
/** The
|
|
91
|
-
* cue
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
254
|
+
/** The session statuses that raise the mobile switcher's aggregate attention
|
|
255
|
+
* cue. The rule is "only states that want the user — it's like a notification":
|
|
256
|
+
* a background terminal blocked on the user ("input"), one whose turn finished
|
|
257
|
+
* ("done"), and one whose process died badly ("crashed"). The three animated
|
|
258
|
+
* progress states are deliberately excluded — working / warning / failed are
|
|
259
|
+
* ongoing and informational, and an animated dot pinned to the switch button
|
|
260
|
+
* would nag with nothing to act on. Declared once here so the raise test and
|
|
261
|
+
* the acknowledgement store cannot disagree about which statuses are
|
|
262
|
+
* cue-worthy. */
|
|
263
|
+
export type CueStatus = "input" | "done" | "crashed";
|
|
95
264
|
|
|
96
265
|
/** isCueStatus narrows a raw server status to a cue-worthy one. */
|
|
97
266
|
export function isCueStatus(status: string): status is CueStatus {
|
|
98
|
-
return status === "input" || status === "done";
|
|
267
|
+
return status === "input" || status === "done" || status === STATUS_CRASHED;
|
|
99
268
|
}
|
|
100
269
|
|
|
101
270
|
/** localStorage key for the cues this viewer has already SEEN: session id -> the
|
|
@@ -221,6 +390,14 @@ export interface Tab {
|
|
|
221
390
|
el: HTMLElement;
|
|
222
391
|
label: HTMLElement;
|
|
223
392
|
dot: HTMLElement;
|
|
393
|
+
/** The chip's determinate progress bar (the 2px line on its bottom edge).
|
|
394
|
+
* Present on every chip, hidden until a percentage exists. */
|
|
395
|
+
progressEl: HTMLElement;
|
|
396
|
+
/** The last OSC 9;4 percentage the server reported for this session, or
|
|
397
|
+
* PROGRESS_ABSENT. Held raw: whether it is currently SHOWN is a render-time
|
|
398
|
+
* question (renderedProgress), and the prefix it produces is never stored on
|
|
399
|
+
* any title field. */
|
|
400
|
+
progress: number;
|
|
224
401
|
aria: TabHandle;
|
|
225
402
|
scrollTop: number;
|
|
226
403
|
following: boolean;
|
|
@@ -0,0 +1,265 @@
|
|
|
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, happy-dom). */
|
|
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
|
+
}
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
// (it closes over the feature state). The mobile chrome templates live in
|
|
7
7
|
// switcher.ts; the session model in model.ts.
|
|
8
8
|
|
|
9
|
+
// The status vocabulary itself (which statuses reveal a dot, and how each one is
|
|
10
|
+
// worded for a human) lives in the DOM-free model, so the painters here and the
|
|
11
|
+
// accessible names index.ts builds read one definition.
|
|
12
|
+
import { statusPhrase, statusRevealsDot } from "./model.js";
|
|
13
|
+
|
|
9
14
|
// The +/x/keyboard glyphs are inline SVG (not font glyphs) so they center
|
|
10
15
|
// exactly in their flex-centered buttons and stay symmetric regardless of the UI
|
|
11
16
|
// font's metrics. Each is defined ONCE here and shared by every chip site and
|
|
@@ -21,15 +26,22 @@ const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" wi
|
|
|
21
26
|
const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
|
|
22
27
|
|
|
23
28
|
// chipContent is the ONE builder for a tab chip's content — a status dot, a
|
|
24
|
-
// label, and a close (x) — shared by all three chip
|
|
25
|
-
// (.wt-tab), the mobile active row
|
|
26
|
-
// mobile list row (.wt-switcher-row).
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
+
// label, a determinate progress bar, and a close (x) — shared by all three chip
|
|
30
|
+
// sites: the desktop strip (.wt-tab), the mobile active row
|
|
31
|
+
// (.wt-switcher-current), and each expanded mobile list row (.wt-switcher-row).
|
|
32
|
+
// Each site passes its OWN class set (never renamed) so every existing selector
|
|
33
|
+
// — and thus all CSS and all tests — still matches. The one structural
|
|
34
|
+
// difference is WHERE the close sits: the desktop
|
|
29
35
|
// chip nests it flat inside .wt-tab, while the two mobile chips place it as a
|
|
30
36
|
// sibling of the select/swipe button (a button can't nest in a button). So the
|
|
31
37
|
// builder returns two fragments — the dot+label pair and the close — that each
|
|
32
38
|
// site drops into its own structure.
|
|
39
|
+
//
|
|
40
|
+
// The progress bar rides in the dotLabel fragment rather than taking a per-site
|
|
41
|
+
// class: it is positioned against the chip, not laid out in its flex row, so one
|
|
42
|
+
// class (.wt-progress-bar) styles it everywhere and every chip site gets it for
|
|
43
|
+
// free. It starts `hidden` — a session with no percentage must render NO bar
|
|
44
|
+
// (paintProgress).
|
|
33
45
|
export function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
|
|
34
46
|
dotLabel: string;
|
|
35
47
|
close: string;
|
|
@@ -37,7 +49,8 @@ export function chipContent(v: { dot: string; label: string; close: string; clos
|
|
|
37
49
|
return {
|
|
38
50
|
dotLabel:
|
|
39
51
|
`<span class="${v.dot} wt-status-dot" aria-hidden="true"></span>` +
|
|
40
|
-
`<span class="${v.label}"></span
|
|
52
|
+
`<span class="${v.label}"></span>` +
|
|
53
|
+
`<span class="wt-progress-bar" aria-hidden="true" hidden></span>`,
|
|
41
54
|
close: `<button type="button" class="${v.close}" aria-label="Close terminal"${v.closeAttr ?? ""}>${CLOSE_SVG}</button>`,
|
|
42
55
|
};
|
|
43
56
|
}
|
|
@@ -80,15 +93,44 @@ export const TAB_HTML = `
|
|
|
80
93
|
${TAB_CHIP.close}
|
|
81
94
|
</div>`;
|
|
82
95
|
|
|
83
|
-
/** paintStatusDot applies a status dot's
|
|
84
|
-
* its appearance (idle / working /
|
|
85
|
-
* .wt-reports class controls its visibility
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
96
|
+
/** paintStatusDot applies a status dot's three orthogonal bits: data-status
|
|
97
|
+
* drives its appearance (idle / working / warning / failed / done / input /
|
|
98
|
+
* exited / crashed via CSS), the .wt-reports class controls its visibility, and
|
|
99
|
+
* `title` gives it a hover tooltip naming the state — the dots are decoration
|
|
100
|
+
* (aria-hidden), and a colour vocabulary that grew to eight states needs a way
|
|
101
|
+
* to be read rather than memorised. The tooltip wording is statusPhrase, the
|
|
102
|
+
* same source the tab's accessible name uses, so hover text and announced text
|
|
103
|
+
* cannot drift.
|
|
104
|
+
*
|
|
105
|
+
* Visibility: the dot is hidden by default and shown once the session has
|
|
106
|
+
* reported activity (OSC 9;4 progress or a classified OSC 9 notification), so a
|
|
107
|
+
* plain shell's tabs stay clean and label-only while an agent's light up. The
|
|
108
|
+
* reveal is FLOORED by the status itself (statusRevealsDot) for the states that
|
|
109
|
+
* are self-evidently news — a plain shell that crashes never reported activity
|
|
110
|
+
* in its life, and hiding its red dot would hide the only signal it ever
|
|
111
|
+
* produced. */
|
|
89
112
|
export function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
|
|
90
|
-
|
|
91
|
-
el.
|
|
113
|
+
const value = status || "idle";
|
|
114
|
+
el.dataset["status"] = value;
|
|
115
|
+
el.classList.toggle("wt-reports", reports || statusRevealsDot(value));
|
|
116
|
+
el.title = statusPhrase(value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** paintProgress renders one chip's determinate progress bar from a percentage.
|
|
120
|
+
*
|
|
121
|
+
* An absent percentage (PROGRESS_ABSENT / any negative) renders NO bar: the
|
|
122
|
+
* element is `hidden` and its width is cleared, rather than left as a
|
|
123
|
+
* zero-width or empty bar that would read as "0%, stalled". The width is the
|
|
124
|
+
* only inline style — the percentage is data, and everything about how the bar
|
|
125
|
+
* looks stays in .wt-progress-bar. */
|
|
126
|
+
export function paintProgress(el: HTMLElement, progress: number): void {
|
|
127
|
+
if (progress < 0) {
|
|
128
|
+
el.hidden = true;
|
|
129
|
+
el.style.removeProperty("width");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
el.hidden = false;
|
|
133
|
+
el.style.width = `${String(progress)}%`;
|
|
92
134
|
}
|
|
93
135
|
|
|
94
136
|
/** pick returns a required descendant element or throws (static chrome only). */
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
export { createTerminal } from "./kernel/kernel.js";
|
|
15
15
|
export { STARTUP_FAILURE_COPY } from "./kernel/startup-copy.js";
|
|
16
|
+
export { LOADING_OVERLAY_CLASSES, PUBLIC_THEME_TOKENS } from "./kernel/style-contract.js";
|
|
17
|
+
export type { PublicThemeToken } from "./kernel/style-contract.js";
|
|
16
18
|
export type {
|
|
17
19
|
CreateTerminalOptions,
|
|
18
20
|
TerminalStartupFailure,
|