@cplieger/web-terminal-ui 5.4.6 → 5.6.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/css/30-tabs.css +4 -3
- package/css/31-switcher.css +38 -9
- package/package.json +1 -1
- package/src/features/tabs/attention.ts +234 -0
- package/src/features/tabs/index.ts +116 -4
- package/src/features/tabs/model.ts +70 -11
- package/src/kernel/kernel.ts +277 -25
- package/src/kernel/types.ts +15 -0
- package/src/presets/agent-tabbed.ts +3 -3
- package/src/presets/tabbed.ts +19 -3
- package/src/presets.ts +1 -0
package/css/30-tabs.css
CHANGED
|
@@ -875,9 +875,10 @@
|
|
|
875
875
|
opacity only, so it composites without repaints (a gradient background is
|
|
876
876
|
rasterized once, unlike an animated box-shadow spread, which repaints every
|
|
877
877
|
frame). It exists only in the three animated states — no stale layer to pin
|
|
878
|
-
when a dot changes status. (The switcher notification dot
|
|
879
|
-
them
|
|
880
|
-
|
|
878
|
+
when a dot changes status. (The switcher notification dot can now show one of
|
|
879
|
+
them, `failed`, since that status became cue-worthy; 31-switcher.css neutralises
|
|
880
|
+
this rule and the position: relative above for that surface, so its
|
|
881
|
+
position: absolute badge placement still holds.)
|
|
881
882
|
|
|
882
883
|
The final scale is deliberately capped at today's 27px footprint: the mobile
|
|
883
884
|
overview row (.wt-switcher-row-select) clips at overflow: hidden 12.5px from
|
package/css/31-switcher.css
CHANGED
|
@@ -326,22 +326,51 @@
|
|
|
326
326
|
/* Latest-wins background-tab notification dot on the switch button — the moved
|
|
327
327
|
attention cue: the input colour when a background terminal needs input, the
|
|
328
328
|
done colour when a background turn finished, the failed red when a background
|
|
329
|
-
process crashed. Those
|
|
330
|
-
want the user, like a notification", so the
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
the
|
|
334
|
-
(
|
|
329
|
+
process crashed or reported an error. Those four only (CueStatus) — the rule
|
|
330
|
+
the user set is "only states that want the user, like a notification", so the
|
|
331
|
+
ongoing progress states (working / warning) never raise it. `failed` DOES,
|
|
332
|
+
because OSC 9;4 state 2 is a result the program parked rather than a phase in
|
|
333
|
+
progress; the override below is what keeps it looking like a notification here.
|
|
334
|
+
Reuses the exact .wt-status-dot rules (30-tabs.css, --status-* tokens) so it
|
|
335
|
+
matches the per-tab dots. Hidden until the tabs feature sets data-status;
|
|
336
|
+
cleared when the list opens (expandSwitcher). */
|
|
335
337
|
:where(.wt-root) .wt-switcher-switch-dot {
|
|
336
338
|
position: absolute;
|
|
337
339
|
top: 6px;
|
|
338
340
|
right: 6px;
|
|
339
341
|
}
|
|
340
342
|
|
|
343
|
+
/* `failed` is one of 30-tabs.css's three ANIMATED states, and on this surface it
|
|
344
|
+
must not be. That rule set is why this override exists at all rather than the
|
|
345
|
+
dot simply inheriting:
|
|
346
|
+
|
|
347
|
+
- `.wt-status-dot[data-status="failed"]` is specificity (0,2,0) and sets
|
|
348
|
+
`position: relative`, which BEATS the (0,1,0) `position: absolute` above
|
|
349
|
+
(`:where()` contributes nothing), so the dot would leave its badge corner and
|
|
350
|
+
land in the button's normal flow, displacing the icon.
|
|
351
|
+
- It also attaches the glow ::before and the travelling-wave ::after, pinning a
|
|
352
|
+
perpetual animation to the switch button for a state that persists until the
|
|
353
|
+
program's next status change.
|
|
354
|
+
|
|
355
|
+
So `failed` renders here the way `crashed` already does: static solid red in the
|
|
356
|
+
badge corner. Colour needs no restating — background/border-color resolve
|
|
357
|
+
through --dot-color to the same --status-failed either way. */
|
|
358
|
+
:where(.wt-root) .wt-switcher-switch-dot[data-status="failed"] {
|
|
359
|
+
position: absolute;
|
|
360
|
+
animation: none;
|
|
361
|
+
transform: none;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
:where(.wt-root) .wt-switcher-switch-dot[data-status="failed"]::before,
|
|
365
|
+
:where(.wt-root) .wt-switcher-switch-dot[data-status="failed"]::after {
|
|
366
|
+
content: none;
|
|
367
|
+
}
|
|
368
|
+
|
|
341
369
|
/* The dot carries the .wt-status-dot class, so its size, shape, and colour come
|
|
342
|
-
straight from the shared .wt-status-dot[data-status="input"|"done"|"crashed"
|
|
343
|
-
rules (30-tabs.css) — the SAME dot colours every tab already uses,
|
|
344
|
-
once.
|
|
370
|
+
straight from the shared .wt-status-dot[data-status="input"|"done"|"crashed"|
|
|
371
|
+
"failed"] rules (30-tabs.css) — the SAME dot colours every tab already uses,
|
|
372
|
+
defined once. Three of the four animate nothing; `failed` is neutralised
|
|
373
|
+
directly above, so nothing here can pin a wave layer.
|
|
345
374
|
.wt-status-dot is display:none by default; force it visible only while a
|
|
346
375
|
notification is set (data-status present). */
|
|
347
376
|
:where(.wt-root) .wt-switcher-switch-dot[data-status] {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// tabs/attention.ts — the unseen-cue set, rendered onto the surfaces that exist
|
|
2
|
+
// OUTSIDE this page's chrome: the browser tab title, the installed app's icon
|
|
3
|
+
// badge, and the tab icon.
|
|
4
|
+
//
|
|
5
|
+
// Why these are needed at all: a tab holding a latched status paints a dot on its
|
|
6
|
+
// chip, and that dot is not reliably on screen. The desktop strip scrolls
|
|
7
|
+
// horizontally once it is full, a narrow coarse-pointer device hides the strip
|
|
8
|
+
// entirely (only the active row plus the switcher's aggregate dot remain), and a
|
|
9
|
+
// hidden page shows no chrome at all. notify.ts already covers the last case with
|
|
10
|
+
// a browser Notification, but that API is absent on iOS Safari outside an
|
|
11
|
+
// installed web app, which is exactly where this UI spends its time.
|
|
12
|
+
//
|
|
13
|
+
// STATE, not events. A notification fires once per occurrence and is deduped on a
|
|
14
|
+
// sequence number; everything here is a pure render of what is currently true, so
|
|
15
|
+
// it is safe to recompute on every status sweep and each sink no-ops when nothing
|
|
16
|
+
// changed. That split is why this module and notify.ts stay separate.
|
|
17
|
+
//
|
|
18
|
+
// The set is CueStatus and nothing else, so these surfaces cannot disagree with
|
|
19
|
+
// the switcher's aggregate dot about what wants the user; the fold and the raise
|
|
20
|
+
// share isUnseenCue (model.ts).
|
|
21
|
+
//
|
|
22
|
+
// DOM-free and global-free by construction, like notify.ts: every capability
|
|
23
|
+
// arrives through AttentionEnv, so the decisions are unit-testable with no
|
|
24
|
+
// document, no navigator and no icon assets, and browserAttentionEnv() below is
|
|
25
|
+
// the one place that touches globals.
|
|
26
|
+
|
|
27
|
+
import { type CueStatus, cueIconName, isUnseenCue, worseCue } from "./model.js";
|
|
28
|
+
|
|
29
|
+
/** What the fold reads per tab. A structural type, so the caller passes its own
|
|
30
|
+
* tab objects without this module knowing what else they carry. */
|
|
31
|
+
export interface CueCandidate {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly status: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The whole attention state, and the only thing the sinks are allowed to see. */
|
|
37
|
+
export interface Attention {
|
|
38
|
+
/** How many sessions hold an unacknowledged cue. */
|
|
39
|
+
readonly count: number;
|
|
40
|
+
/** The most severe of them, or "" when there is none. */
|
|
41
|
+
readonly worst: CueStatus | "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const NO_ATTENTION: Attention = { count: 0, worst: "" };
|
|
45
|
+
|
|
46
|
+
/** summarize folds the tab list and this viewer's acknowledgements into the one
|
|
47
|
+
* value every surface renders.
|
|
48
|
+
*
|
|
49
|
+
* A COUNT for the title and the badge, a single WORST for the icon. That split
|
|
50
|
+
* is deliberate: a count is set-valued, so it needs no rule for choosing among
|
|
51
|
+
* sessions, and severity is a total order, so the icon's choice is not arbitrary
|
|
52
|
+
* either. Neither surface can name a session, which is the standing constraint
|
|
53
|
+
* on anything written to a page-wide surface here (see the decision against
|
|
54
|
+
* per-session progress in the document title, index.ts). */
|
|
55
|
+
export function summarize(
|
|
56
|
+
tabs: readonly CueCandidate[],
|
|
57
|
+
seen: ReadonlyMap<string, CueStatus>,
|
|
58
|
+
): Attention {
|
|
59
|
+
let count = 0;
|
|
60
|
+
let worst: CueStatus | "" = "";
|
|
61
|
+
for (const tab of tabs) {
|
|
62
|
+
if (!isUnseenCue(tab.status, tab.id, seen)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
count += 1;
|
|
66
|
+
worst = worseCue(worst, tab.status);
|
|
67
|
+
}
|
|
68
|
+
return { count, worst };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The capabilities the sinks need, all injected, all optional except the title.
|
|
72
|
+
*
|
|
73
|
+
* An absent capability is an absent sink and therefore a silent no-op, the same
|
|
74
|
+
* contract notify.ts uses for a missing Notification constructor: an unsupported
|
|
75
|
+
* surface is a normal state of the world, not an error to report.
|
|
76
|
+
*
|
|
77
|
+
* Two of these can also fail INVISIBLY, and that is accepted rather than
|
|
78
|
+
* handled. `setBadge` resolves on Linux where the desktop paints no badge at
|
|
79
|
+
* all, and `setIcon` assigns an href that Safari ignores because it caches the
|
|
80
|
+
* first icon it fetched. Neither is detectable, and neither costs anything when
|
|
81
|
+
* it fails, because the title is not gated on any capability and so is the
|
|
82
|
+
* floor: every platform that renders a title at all gets the count. Do not
|
|
83
|
+
* arrange these into a fallback ladder where the title only appears if the
|
|
84
|
+
* others are missing — on those two platforms the detection would report
|
|
85
|
+
* success and the user would be left with nothing. */
|
|
86
|
+
export interface AttentionEnv {
|
|
87
|
+
/** Set (or clear, with "") the document-title prefix. The kernel's composing
|
|
88
|
+
* writer, so this cannot be erased by a program's OSC 0/2 window title. */
|
|
89
|
+
titlePrefix: (text: string) => void;
|
|
90
|
+
/** Set the installed app's icon badge to a count, or clear it at zero. */
|
|
91
|
+
setBadge?: ((count: number) => void) | undefined;
|
|
92
|
+
/** Point the page's icon links at a variant, or restore them with null. */
|
|
93
|
+
setIcon?: ((variant: "input" | "done" | "alert" | null) => void) | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AttentionSurfaces {
|
|
97
|
+
/** Render an attention state. Idempotent: a value equal to the last one
|
|
98
|
+
* applied touches nothing. */
|
|
99
|
+
apply(next: Attention): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** titlePrefixFor is the title text for a count, and the format is load-bearing
|
|
103
|
+
* enough to name: the count goes FIRST, because a browser tab strip truncates a
|
|
104
|
+
* title to its first few characters and a suffix would be the part that is cut.
|
|
105
|
+
* Parenthesised digits are also the convention every mail and chat client uses,
|
|
106
|
+
* so it needs no legend. */
|
|
107
|
+
export function titlePrefixFor(count: number): string {
|
|
108
|
+
return count > 0 ? `(${String(count)}) ` : "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createAttention(env: AttentionEnv): AttentionSurfaces {
|
|
112
|
+
// Last applied, so each sink is called only on a real change. This matters
|
|
113
|
+
// beyond cost: the document title doubles as the browser-tab label and the
|
|
114
|
+
// bookmark name, and re-assigning an icon href makes some browsers re-fetch it.
|
|
115
|
+
let applied: Attention = NO_ATTENTION;
|
|
116
|
+
let first = true;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
apply(next: Attention): void {
|
|
120
|
+
const countChanged = first || next.count !== applied.count;
|
|
121
|
+
const worstChanged = first || next.worst !== applied.worst;
|
|
122
|
+
first = false;
|
|
123
|
+
applied = next;
|
|
124
|
+
|
|
125
|
+
if (countChanged) {
|
|
126
|
+
env.titlePrefix(titlePrefixFor(next.count));
|
|
127
|
+
// The badge takes the SAME number as the title, which is the whole
|
|
128
|
+
// reason both read one fold: two surfaces disagreeing about how many
|
|
129
|
+
// things want you is worse than either being absent.
|
|
130
|
+
env.setBadge?.(next.count);
|
|
131
|
+
}
|
|
132
|
+
if (worstChanged) {
|
|
133
|
+
env.setIcon?.(next.worst === "" ? null : cueIconName(next.worst));
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** iconVariantHref rewrites an icon URL to its variant, by the convention the
|
|
140
|
+
* asset generator writes (scripts/gen-attention-icons.py): the `favicon` token
|
|
141
|
+
* of the filename gains `-<variant>`, so `/favicon.svg` becomes
|
|
142
|
+
* `/favicon-input.svg` and `/favicon-32x32.png` becomes
|
|
143
|
+
* `/favicon-input-32x32.png`. The extension is preserved, so each link keeps
|
|
144
|
+
* pointing at its own format and no `type` attribute has to change.
|
|
145
|
+
*
|
|
146
|
+
* A URL whose filename does not start with `favicon` is returned unchanged,
|
|
147
|
+
* which leaves that link alone rather than pointing it at a 404. Exported for
|
|
148
|
+
* the test that pins this against the generator's own naming. */
|
|
149
|
+
export function iconVariantHref(href: string, variant: string): string | null {
|
|
150
|
+
const match = /(^|\/)favicon(?=[-.])/.exec(href);
|
|
151
|
+
if (!match) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const at = match.index + match[0].length;
|
|
155
|
+
return `${href.slice(0, at)}-${variant}${href.slice(at)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** browserAttentionEnv binds the sinks to the real browser, given the kernel's
|
|
159
|
+
* title setter. Everything optional is decided HERE, once, so the core never
|
|
160
|
+
* probes for a capability.
|
|
161
|
+
*
|
|
162
|
+
* `icons` opts the icon sink in. It is off unless the consumer asks for it,
|
|
163
|
+
* because it needs asset files the library cannot ship: the dot's colour comes
|
|
164
|
+
* from the app's own `--status-*` theme, so the variants are per-app artifacts.
|
|
165
|
+
*/
|
|
166
|
+
export function browserAttentionEnv(
|
|
167
|
+
titlePrefix: (text: string) => void,
|
|
168
|
+
icons: boolean,
|
|
169
|
+
): AttentionEnv {
|
|
170
|
+
const env: AttentionEnv = { titlePrefix };
|
|
171
|
+
|
|
172
|
+
// The Badging API, read through `unknown` for the same reason notify.ts reads
|
|
173
|
+
// Notification that way: it is absent on most browsers and must degrade rather
|
|
174
|
+
// than be asserted. Installed apps only — a badge lives on an app icon, which
|
|
175
|
+
// exists only after the app is installed.
|
|
176
|
+
const nav: unknown = globalThis.navigator;
|
|
177
|
+
const setAppBadge = (nav as { setAppBadge?: unknown } | undefined)?.setAppBadge;
|
|
178
|
+
const clearAppBadge = (nav as { clearAppBadge?: unknown } | undefined)?.clearAppBadge;
|
|
179
|
+
if (typeof setAppBadge === "function") {
|
|
180
|
+
env.setBadge = (count: number): void => {
|
|
181
|
+
// Always a NUMBER, never the spec's bare flag form: iOS renders nothing at
|
|
182
|
+
// all for `setAppBadge()` with no argument. Zero clears, via clearAppBadge
|
|
183
|
+
// where it exists (the documented way) and setAppBadge(0) otherwise.
|
|
184
|
+
//
|
|
185
|
+
// Both return promises that reject on an unsupported platform, so the
|
|
186
|
+
// rejection is swallowed: a badge the OS declines to paint is not an error
|
|
187
|
+
// this page can do anything about, and an unhandled rejection in a status
|
|
188
|
+
// sweep would be reported as a page fault.
|
|
189
|
+
try {
|
|
190
|
+
const call =
|
|
191
|
+
count > 0
|
|
192
|
+
? (setAppBadge as (n: number) => unknown).call(nav, count)
|
|
193
|
+
: typeof clearAppBadge === "function"
|
|
194
|
+
? (clearAppBadge as () => unknown).call(nav)
|
|
195
|
+
: (setAppBadge as (n: number) => unknown).call(nav, 0);
|
|
196
|
+
void Promise.resolve(call).catch(() => {
|
|
197
|
+
/* an OS that will not paint a badge is a title-only OS */
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
/* a synchronous throw is the same non-event */
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (icons) {
|
|
206
|
+
// Every icon link, not one of them: which link a browser picks differs
|
|
207
|
+
// between browsers (Chrome prefers the SVG), so mutating a single element is
|
|
208
|
+
// unreliable. apple-touch-icon is deliberately NOT matched — `rel~="icon"`
|
|
209
|
+
// does not select it — because that icon and the manifest's are cached by the
|
|
210
|
+
// OS when the app is installed and a swap cannot reach them.
|
|
211
|
+
const links = [...document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')];
|
|
212
|
+
const originals = new Map<HTMLLinkElement, string>();
|
|
213
|
+
for (const link of links) {
|
|
214
|
+
originals.set(link, link.getAttribute("href") ?? "");
|
|
215
|
+
}
|
|
216
|
+
if (links.length > 0) {
|
|
217
|
+
env.setIcon = (variant): void => {
|
|
218
|
+
for (const link of links) {
|
|
219
|
+
const original = originals.get(link) ?? "";
|
|
220
|
+
if (variant === null) {
|
|
221
|
+
link.setAttribute("href", original);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const next = iconVariantHref(original, variant);
|
|
225
|
+
if (next !== null) {
|
|
226
|
+
link.setAttribute("href", next);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return env;
|
|
234
|
+
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
hasPinnedName,
|
|
33
33
|
isCueStatus,
|
|
34
34
|
isEndedStatus,
|
|
35
|
+
isUnseenCue,
|
|
35
36
|
normalizeProgress,
|
|
36
37
|
orderedInsertIndex,
|
|
37
38
|
parseCueSeen,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
statusPhrase,
|
|
42
43
|
tabAccessibleName,
|
|
43
44
|
} from "./model.js";
|
|
45
|
+
import { NO_ATTENTION, browserAttentionEnv, createAttention, summarize } from "./attention.js";
|
|
44
46
|
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
45
47
|
import {
|
|
46
48
|
REORDER_MOVE_EPS_PX,
|
|
@@ -133,6 +135,25 @@ export interface TabsOptions {
|
|
|
133
135
|
* then merely confirms. Default false (evidence-driven reveal: a plain
|
|
134
136
|
* shell keeps clean, label-only tabs). presetAgentTabbed enables this. */
|
|
135
137
|
presumeReports?: boolean;
|
|
138
|
+
/** Swap the page's icon links to a status variant while a background session
|
|
139
|
+
* holds an unacknowledged cue, so the browser tab icon carries the same colour
|
|
140
|
+
* its chip's dot does. Default false, because it needs asset files the library
|
|
141
|
+
* cannot ship: the dot's colour comes from the app's own `--status-*` theme, so
|
|
142
|
+
* the variants are per-app artifacts.
|
|
143
|
+
*
|
|
144
|
+
* Enabling it is a promise that those files exist. For every
|
|
145
|
+
* `link[rel=icon]` whose filename starts with `favicon`, three variants must
|
|
146
|
+
* be served alongside it with `-input`, `-done` and `-alert` inserted after
|
|
147
|
+
* that token (`/favicon.svg` needs `/favicon-input.svg`; `/favicon-32x32.png`
|
|
148
|
+
* needs `/favicon-input-32x32.png`). Generate them with
|
|
149
|
+
* `scripts/gen-attention-icons.py`, which writes exactly those names, and
|
|
150
|
+
* assert their presence in the APP's own tests — the library cannot check a
|
|
151
|
+
* file it does not ship, and a missing variant is a blank tab icon.
|
|
152
|
+
*
|
|
153
|
+
* Not every platform honours it: Safari caches the first icon it fetched and
|
|
154
|
+
* ignores later changes, and an installed app has no tab icon at all. The
|
|
155
|
+
* title count is not gated on this, so those cases lose nothing. */
|
|
156
|
+
attentionIcons?: boolean;
|
|
136
157
|
}
|
|
137
158
|
|
|
138
159
|
// looksLikeHardwareKey reports whether a keydown could only have come from a
|
|
@@ -486,8 +507,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
486
507
|
// by each qualifying background-tab event (applyStatus) and cleared when
|
|
487
508
|
// the list opens (expandSwitcher) or the raising tab is visited or closed
|
|
488
509
|
// (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden. The
|
|
489
|
-
// cue-worthy set is CueStatus (input / done / crashed — the states
|
|
490
|
-
// want the user), declared once in model.ts beside isCueStatus.
|
|
510
|
+
// cue-worthy set is CueStatus (input / done / crashed / failed — the states
|
|
511
|
+
// that want the user), declared once in model.ts beside isCueStatus.
|
|
491
512
|
let switchNotify: "" | CueStatus = "";
|
|
492
513
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
493
514
|
// swipe or any switch) acknowledges it without opening the list.
|
|
@@ -522,6 +543,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
522
543
|
cueSeen.delete(oldest);
|
|
523
544
|
}
|
|
524
545
|
writeCueSeen(cueSeen);
|
|
546
|
+
// An acknowledgement can change the unseen count without touching the tab
|
|
547
|
+
// list, so syncChrome's sweep is not enough on its own: opening the tray
|
|
548
|
+
// and arriving on a tab both land here and neither adds or removes a chip.
|
|
549
|
+
paintAttention();
|
|
525
550
|
}
|
|
526
551
|
// forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
|
|
527
552
|
// is a fresh cue: called when its status moves off the acknowledged value
|
|
@@ -529,6 +554,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
529
554
|
function forgetCueSeen(id: string): void {
|
|
530
555
|
if (cueSeen.delete(id)) {
|
|
531
556
|
writeCueSeen(cueSeen);
|
|
557
|
+
paintAttention();
|
|
532
558
|
}
|
|
533
559
|
}
|
|
534
560
|
// OSC 9 Form B notifications: the one signal the specs say to surface
|
|
@@ -538,6 +564,60 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
538
564
|
// degradation — lives in notify.ts; this feature only feeds it events and
|
|
539
565
|
// gestures.
|
|
540
566
|
const notifier = createNotifier(browserNotifierEnv());
|
|
567
|
+
// The out-of-page surfaces for the SAME unseen-cue set the switch dot
|
|
568
|
+
// shows: the document-title count, the installed app's icon badge, and the
|
|
569
|
+
// tab icon. Constructed here so every capability decision is made once,
|
|
570
|
+
// and fed only through paintAttention below.
|
|
571
|
+
const attention = createAttention(
|
|
572
|
+
browserAttentionEnv((text) => {
|
|
573
|
+
ctx.titlePrefix(text);
|
|
574
|
+
}, opts.attentionIcons === true),
|
|
575
|
+
);
|
|
576
|
+
// pageVisible is the "can the user see this page at all" test, and
|
|
577
|
+
// visibilityState is the only reliable one: document.hasFocus() is false for
|
|
578
|
+
// a visible-but-unfocused window, where the terminal IS on screen. Same
|
|
579
|
+
// signal notify.ts reads, for the same decision.
|
|
580
|
+
function pageVisible(): boolean {
|
|
581
|
+
return document.visibilityState !== "hidden";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// onPageVisible is the DEFERRED half of the active-tab acknowledgement in
|
|
585
|
+
// applyStatus: a cue that latched while the page was hidden was deliberately
|
|
586
|
+
// not acknowledged then, so it could raise the out-of-page surfaces, and this
|
|
587
|
+
// acknowledges it the moment the user can actually see the terminal.
|
|
588
|
+
//
|
|
589
|
+
// Scoped to the ACTIVE tab only. A wholesale clear on becoming visible is the
|
|
590
|
+
// obvious shortcut and it is wrong: the tab the user returns to is not
|
|
591
|
+
// necessarily the one that was waiting, and blanking a background tab's cue
|
|
592
|
+
// it never saw is how a viewer loses the thing it came back for.
|
|
593
|
+
function onPageVisible(): void {
|
|
594
|
+
if (!pageVisible() || activeId === null) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const active = tabList.find((t) => t.id === activeId);
|
|
598
|
+
if (active) {
|
|
599
|
+
markCueSeen(active.id, statusOf(active));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
document.addEventListener("visibilitychange", onPageVisible);
|
|
603
|
+
|
|
604
|
+
// paintAttention re-derives the whole attention state from the tab list and
|
|
605
|
+
// this viewer's acknowledgements, and hands it to the surfaces.
|
|
606
|
+
//
|
|
607
|
+
// A FOLD rather than incremental bookkeeping, for the reason applyServerOrder
|
|
608
|
+
// is one: there is no state of its own to get out of step, so no path can
|
|
609
|
+
// leave it stale, and it is cheap enough to run on every tick (a loop over a
|
|
610
|
+
// handful of tabs, then sinks that no-op when nothing changed). The dot is
|
|
611
|
+
// the store — statusOf reads each tab's status back off it — so this reads
|
|
612
|
+
// exactly what the user is looking at.
|
|
613
|
+
function paintAttention(): void {
|
|
614
|
+
attention.apply(
|
|
615
|
+
summarize(
|
|
616
|
+
tabList.map((t) => ({ id: t.id, status: statusOf(t) })),
|
|
617
|
+
cueSeen,
|
|
618
|
+
),
|
|
619
|
+
);
|
|
620
|
+
}
|
|
541
621
|
function paintSwitchDot(): void {
|
|
542
622
|
// Reuse the per-tab status-dot colours (single source, css/05-tabs.css
|
|
543
623
|
// .wt-status-dot[data-status="input"|"done"|"crashed"]) instead of
|
|
@@ -552,6 +632,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
552
632
|
swSwitchDot.dataset["status"] = switchNotify;
|
|
553
633
|
swSwitchDot.title = statusPhrase(switchNotify);
|
|
554
634
|
}
|
|
635
|
+
// The out-of-page surfaces answer to the same set, so they repaint
|
|
636
|
+
// wherever this does: a raise, and every clear through clearSwitchNotify.
|
|
637
|
+
paintAttention();
|
|
555
638
|
}
|
|
556
639
|
function clearSwitchNotify(): void {
|
|
557
640
|
switchNotify = "";
|
|
@@ -1154,6 +1237,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1154
1237
|
}
|
|
1155
1238
|
maybeSwipeHint();
|
|
1156
1239
|
applyServerOrder();
|
|
1240
|
+
// Hung off syncChrome for the reason applyServerOrder is: it is the one
|
|
1241
|
+
// function every list mutation already ends with (reorder, create, adopt,
|
|
1242
|
+
// close, reconcile), so no path can forget the out-of-page surfaces. The
|
|
1243
|
+
// fold is idempotent and the sinks no-op when unchanged, so running it
|
|
1244
|
+
// this often costs a comparison.
|
|
1245
|
+
paintAttention();
|
|
1157
1246
|
}
|
|
1158
1247
|
|
|
1159
1248
|
// The percentage is deliberately NOT written into the browser document
|
|
@@ -1165,6 +1254,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1165
1254
|
// the bookmark name, and it invites reading one session's progress as the
|
|
1166
1255
|
// window's. The per-chip prefix carries the same information without the
|
|
1167
1256
|
// conflict, because each chip shows its own session. Do not add it back.
|
|
1257
|
+
//
|
|
1258
|
+
// What DOES go there is the unseen-cue COUNT (see paintAttention), and it
|
|
1259
|
+
// is not a softening of this rule: a count names no session, so it needs no
|
|
1260
|
+
// arbitrary choice, and it changes only when a cue is raised or acknowledged
|
|
1261
|
+
// rather than on every progress tick. A per-session value in that prefix
|
|
1262
|
+
// would be this same mistake in a new place.
|
|
1168
1263
|
|
|
1169
1264
|
// applyServerOrder re-sorts the strip into the order the SERVER holds, and
|
|
1170
1265
|
// is the read half of tab-order sync: a drag on another device arrives as a
|
|
@@ -3162,11 +3257,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3162
3257
|
// acknowledgement so the next latch is a fresh cue.
|
|
3163
3258
|
if (!isCueStatus(rec.status)) {
|
|
3164
3259
|
forgetCueSeen(rec.id);
|
|
3165
|
-
} else if (rec.id === activeId) {
|
|
3260
|
+
} else if (rec.id === activeId && pageVisible()) {
|
|
3166
3261
|
// The user is looking at this terminal as it latches, so there is
|
|
3167
3262
|
// nothing to notify — and nothing to re-raise once they move away.
|
|
3263
|
+
//
|
|
3264
|
+
// BOTH halves are required, for the reason notify.ts states for its own
|
|
3265
|
+
// suppression rule: keyed on "is this the active tab" alone, this
|
|
3266
|
+
// swallowed the cue of the very session the user left running. That is
|
|
3267
|
+
// the single-session case — one tab is necessarily the active one — and
|
|
3268
|
+
// it is precisely the case the out-of-page surfaces exist for, so a
|
|
3269
|
+
// latch arriving on a HIDDEN page now raises them and is acknowledged
|
|
3270
|
+
// when the user comes back (see onPageVisible).
|
|
3168
3271
|
markCueSeen(rec.id, rec.status);
|
|
3169
|
-
} else if (
|
|
3272
|
+
} else if (isUnseenCue(rec.status, rec.id, cueSeen)) {
|
|
3170
3273
|
switchNotify = rec.status;
|
|
3171
3274
|
switchNotifyId = rec.id;
|
|
3172
3275
|
paintSwitchDot();
|
|
@@ -3977,6 +4080,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3977
4080
|
tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
|
|
3978
4081
|
},
|
|
3979
4082
|
teardown() {
|
|
4083
|
+
// Restore the out-of-page surfaces first, while the sinks still exist.
|
|
4084
|
+
// The kernel clears the title prefix on its own teardown, but the app
|
|
4085
|
+
// badge and the icon links are OUR state and nothing else will clear
|
|
4086
|
+
// them: the badge is OS-level and outlives the page entirely, and a
|
|
4087
|
+
// destroyed terminal in an embedded panel leaves the document's icon
|
|
4088
|
+
// pointed at a status variant for a tab that no longer exists.
|
|
4089
|
+
// Change-gated, so this is a no-op when nothing was lit.
|
|
4090
|
+
attention.apply(NO_ATTENTION);
|
|
3980
4091
|
tornDown = true;
|
|
3981
4092
|
resolveImpl = null;
|
|
3982
4093
|
offStatus?.();
|
|
@@ -3985,6 +4096,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3985
4096
|
offArmed?.();
|
|
3986
4097
|
offMenuKey();
|
|
3987
4098
|
document.removeEventListener("click", onDocClickMenu);
|
|
4099
|
+
document.removeEventListener("visibilitychange", onPageVisible);
|
|
3988
4100
|
document.removeEventListener("contextmenu", onDocContextMenu);
|
|
3989
4101
|
document.removeEventListener("pointerup", onDocTapDismiss, true);
|
|
3990
4102
|
document.removeEventListener("dragover", onDocTabDrop);
|
|
@@ -192,20 +192,79 @@ export const ACTIVE_TAB_KEY = "wt-active-session";
|
|
|
192
192
|
// One-time "swipe to switch" hint, remembered across loads.
|
|
193
193
|
export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
|
|
194
194
|
|
|
195
|
-
/** The session statuses that raise the mobile switcher's
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
|
|
195
|
+
/** The session statuses that raise an attention cue: the mobile switcher's
|
|
196
|
+
* aggregate dot, and the three out-of-page surfaces derived from the same set
|
|
197
|
+
* (see attention.ts). The rule is "only states that want the user — it's like a
|
|
198
|
+
* notification": a background terminal blocked on the user ("input"), one whose
|
|
199
|
+
* turn finished ("done"), one whose process died badly ("crashed"), and one that
|
|
200
|
+
* reported an error ("failed").
|
|
201
|
+
*
|
|
202
|
+
* `working` and `warning` are excluded because they are ONGOING and
|
|
203
|
+
* informational: an animated dot pinned to the switch button would nag with
|
|
204
|
+
* nothing to act on. `failed` was excluded on the same reasoning and that was
|
|
205
|
+
* wrong — OSC 9;4 state 2 is not ongoing, it is a result the program parked and
|
|
206
|
+
* does not revisit until its next state change, which is exactly the shape of a
|
|
207
|
+
* thing to tell the user about. It is also the half of "red" a viewer cares most
|
|
208
|
+
* about: `crashed` was already here, so an errored turn raised nothing while a
|
|
209
|
+
* dead process raised everything.
|
|
210
|
+
*
|
|
211
|
+
* Declared once here so the raise test, the acknowledgement store and every
|
|
212
|
+
* attention sink cannot disagree about which statuses are cue-worthy. */
|
|
213
|
+
export type CueStatus = "input" | "done" | "crashed" | "failed";
|
|
214
|
+
|
|
215
|
+
/** Severity order over CueStatus, most severe first, AND the complete set: this
|
|
216
|
+
* array is what isCueStatus tests against, so the type and the runtime list
|
|
217
|
+
* cannot drift the way two hand-maintained four-way checks could.
|
|
218
|
+
*
|
|
219
|
+
* The order is read by the one surface that can show a single state (the browser
|
|
220
|
+
* tab icon), which paints the most severe unseen cue. That is a total order and
|
|
221
|
+
* so a defensible rule, unlike picking a session.
|
|
222
|
+
*
|
|
223
|
+
* A dead process outranks a reported error (the session is gone, not merely
|
|
224
|
+
* unhappy), an error outranks a request for input, and a finished turn is the
|
|
225
|
+
* mildest thing worth a cue at all. */
|
|
226
|
+
export const CUE_SEVERITY: readonly CueStatus[] = [STATUS_CRASHED, STATUS_FAILED, "input", "done"];
|
|
205
227
|
|
|
206
228
|
/** isCueStatus narrows a raw server status to a cue-worthy one. */
|
|
207
229
|
export function isCueStatus(status: string): status is CueStatus {
|
|
208
|
-
return
|
|
230
|
+
return (CUE_SEVERITY as readonly string[]).includes(status);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** worseCue returns whichever of two cues is more severe; "" means no cue. */
|
|
234
|
+
export function worseCue(a: CueStatus | "", b: CueStatus | ""): CueStatus | "" {
|
|
235
|
+
if (a === "") {
|
|
236
|
+
return b;
|
|
237
|
+
}
|
|
238
|
+
if (b === "") {
|
|
239
|
+
return a;
|
|
240
|
+
}
|
|
241
|
+
return CUE_SEVERITY.indexOf(a) <= CUE_SEVERITY.indexOf(b) ? a : b;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The icon variant a cue paints, which is NOT one per status: `crashed` and
|
|
245
|
+
* `failed` both render --status-failed, so they share one asset. An app ships
|
|
246
|
+
* one icon per name returned here (see scripts/gen-attention-icons.py). */
|
|
247
|
+
export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
|
|
248
|
+
if (status === "input" || status === "done") {
|
|
249
|
+
return status;
|
|
250
|
+
}
|
|
251
|
+
return "alert";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** isUnseenCue reports whether a session's CURRENT status is a cue this viewer
|
|
255
|
+
* has not acknowledged. The single predicate behind both the raise and the
|
|
256
|
+
* attention count, so a dot can never light for a session the count omits.
|
|
257
|
+
*
|
|
258
|
+
* Note what it does NOT consider: whether the session is the active tab. A
|
|
259
|
+
* latch arriving on the active tab is acknowledged at arrival (applyStatus), so
|
|
260
|
+
* it is already absent from the unseen set by the time anything folds over it,
|
|
261
|
+
* and no caller needs a special case. */
|
|
262
|
+
export function isUnseenCue(
|
|
263
|
+
status: string,
|
|
264
|
+
id: string,
|
|
265
|
+
seen: ReadonlyMap<string, CueStatus>,
|
|
266
|
+
): status is CueStatus {
|
|
267
|
+
return isCueStatus(status) && seen.get(id) !== status;
|
|
209
268
|
}
|
|
210
269
|
|
|
211
270
|
/** localStorage key for the cues this viewer has already SEEN: session id -> the
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -91,6 +91,34 @@ const CORE_TEMPLATE = `
|
|
|
91
91
|
<div class="composition-view" aria-hidden="true"></div>
|
|
92
92
|
</div>`;
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The single character this key types, or null when it types none. Used to tell
|
|
96
|
+
* a character the engine's encoder deferred to the `input` event apart from a
|
|
97
|
+
* key whose `key` is a NAME rather than text.
|
|
98
|
+
*
|
|
99
|
+
* `codePointAt` consumes a surrogate pair whole, so a key whose first scalar is
|
|
100
|
+
* the whole string is exactly one character; "Shift", "Dead", "F5" and "Process"
|
|
101
|
+
* are not. Code points rather than grapheme clusters on purpose: a character key
|
|
102
|
+
* carries one code point, and a segmenter would also accept a multi-scalar
|
|
103
|
+
* cluster. UI Events does allow a key "associated with multiple characters"
|
|
104
|
+
* (macros, some dead-key sequences); those return null here and are simply not
|
|
105
|
+
* recovered, which is a stated limitation rather than a claim about key names.
|
|
106
|
+
*/
|
|
107
|
+
function typedChar(ev: KeyboardEvent): string | null {
|
|
108
|
+
const first = ev.key.codePointAt(0);
|
|
109
|
+
return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Normalizes text on its way to the PTY: iOS sends U+00A0 where a space was
|
|
114
|
+
* typed. Shared by every send path so one physical key cannot produce two byte
|
|
115
|
+
* sequences depending on which listener caught it (the `input` event on the
|
|
116
|
+
* focused textarea, or the document-level type-to-focus fallback).
|
|
117
|
+
*/
|
|
118
|
+
function normalizeTypedText(text: string): string {
|
|
119
|
+
return text.replace(/\u00A0/g, " ");
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
function pick(root: ParentNode, selector: string): HTMLElement {
|
|
95
123
|
const el = root.querySelector<HTMLElement>(selector);
|
|
96
124
|
if (!el) {
|
|
@@ -258,6 +286,31 @@ function buildTerminal(
|
|
|
258
286
|
narrowObserver.observe(root);
|
|
259
287
|
}
|
|
260
288
|
|
|
289
|
+
// --- Document title (kernel-owned, composed from two inputs) ---
|
|
290
|
+
//
|
|
291
|
+
// The page has ONE title and two things want to write it: a program's OSC 0/2
|
|
292
|
+
// window title, and a feature's attention prefix (tabs' unseen-cue count). So
|
|
293
|
+
// the kernel holds both and is the only assigner. Two direct writers was the
|
|
294
|
+
// alternative and it cannot work: whichever ran last won, so a shell or an
|
|
295
|
+
// editor emitting OSC 2 silently erased the prefix, and there is no ordering
|
|
296
|
+
// that fixes it because both inputs change on their own schedule.
|
|
297
|
+
//
|
|
298
|
+
// The base starts as whatever the served document declared, so a consumer that
|
|
299
|
+
// never receives an OSC title (web-terminal-kiro sets kiro-cli's
|
|
300
|
+
// chat.terminalTitle=false) keeps its own <title> as the base rather than
|
|
301
|
+
// needing to declare it twice.
|
|
302
|
+
let baseTitle = document.title;
|
|
303
|
+
let titlePrefixText = "";
|
|
304
|
+
function paintTitle(): void {
|
|
305
|
+
const next = titlePrefixText + baseTitle;
|
|
306
|
+
// Idempotent: the title doubles as the browser-tab label and the bookmark
|
|
307
|
+
// name, and this runs on every status sweep, so an unchanged assignment must
|
|
308
|
+
// not churn it.
|
|
309
|
+
if (document.title !== next) {
|
|
310
|
+
document.title = next;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
261
314
|
// Toast surface (kernel-owned shared primitive, section 22.3).
|
|
262
315
|
const toastEl = document.createElement("div");
|
|
263
316
|
toastEl.className = "wt-toast";
|
|
@@ -607,7 +660,8 @@ function buildTerminal(
|
|
|
607
660
|
// when it redraws its prompt after idling, so hold the last-good browser
|
|
608
661
|
// title instead of flickering blank.
|
|
609
662
|
if (msg.title.trim() !== "") {
|
|
610
|
-
|
|
663
|
+
baseTitle = msg.title;
|
|
664
|
+
paintTitle();
|
|
611
665
|
}
|
|
612
666
|
bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
|
|
613
667
|
} else if (msg.type === "modes") {
|
|
@@ -686,14 +740,14 @@ function buildTerminal(
|
|
|
686
740
|
paste(ev.data);
|
|
687
741
|
} else {
|
|
688
742
|
// Normalize iOS's NBSP-for-space quirk, then send through the funnel.
|
|
689
|
-
sendText(ev.data
|
|
743
|
+
sendText(normalizeTypedText(ev.data));
|
|
690
744
|
}
|
|
691
745
|
} else {
|
|
692
746
|
const v = input.value;
|
|
693
747
|
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
694
|
-
sendText(v.slice(INPUT_PLACEHOLDER.length)
|
|
748
|
+
sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
|
|
695
749
|
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
696
|
-
sendText(v
|
|
750
|
+
sendText(normalizeTypedText(v));
|
|
697
751
|
}
|
|
698
752
|
}
|
|
699
753
|
resetToPlaceholder(input);
|
|
@@ -717,6 +771,42 @@ function buildTerminal(
|
|
|
717
771
|
{ signal },
|
|
718
772
|
);
|
|
719
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Applies the part of a `mapKeyboardEvent` result that BOTH keydown paths (the
|
|
776
|
+
* focused textarea below, and the document-level type-to-focus fallback in the
|
|
777
|
+
* focus-strategy section) handle identically. Returns false only for "ignore",
|
|
778
|
+
* the one arm the two callers resolve differently: the textarea lets its own
|
|
779
|
+
* `input` event deliver the character, while the fallback has no `input` event
|
|
780
|
+
* coming and must send the character itself.
|
|
781
|
+
*
|
|
782
|
+
* Switching over the whole `KeyboardResult` union in one place is what keeps
|
|
783
|
+
* the two paths from drifting — handling only "send" in the fallback silently
|
|
784
|
+
* dropped Shift+PageUp, which is exactly what a reader who just selected some
|
|
785
|
+
* scrollback presses next.
|
|
786
|
+
*/
|
|
787
|
+
function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): boolean {
|
|
788
|
+
switch (result.kind) {
|
|
789
|
+
case "send":
|
|
790
|
+
ev.preventDefault();
|
|
791
|
+
sendText(result.bytes);
|
|
792
|
+
return true;
|
|
793
|
+
case "scroll-up": {
|
|
794
|
+
ev.preventDefault();
|
|
795
|
+
const h = termWrap.clientHeight;
|
|
796
|
+
termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
case "scroll-down": {
|
|
800
|
+
ev.preventDefault();
|
|
801
|
+
const h = termWrap.clientHeight;
|
|
802
|
+
termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
case "ignore":
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
720
810
|
input.addEventListener(
|
|
721
811
|
"keydown",
|
|
722
812
|
(ev: KeyboardEvent) => {
|
|
@@ -729,27 +819,10 @@ function buildTerminal(
|
|
|
729
819
|
return;
|
|
730
820
|
}
|
|
731
821
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
sendText(result.bytes);
|
|
737
|
-
return;
|
|
738
|
-
case "scroll-up": {
|
|
739
|
-
ev.preventDefault();
|
|
740
|
-
const h = termWrap.clientHeight;
|
|
741
|
-
termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
case "scroll-down": {
|
|
745
|
-
ev.preventDefault();
|
|
746
|
-
const h = termWrap.clientHeight;
|
|
747
|
-
termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
case "ignore":
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
822
|
+
// A false return ("ignore") needs nothing here: the `input` listener above
|
|
823
|
+
// delivers the character, which is also how IME and dead-key composition
|
|
824
|
+
// output reaches the terminal.
|
|
825
|
+
applyMappedKey(ev, mapKeyboardEvent(ev, modes));
|
|
753
826
|
},
|
|
754
827
|
{ signal },
|
|
755
828
|
);
|
|
@@ -909,6 +982,175 @@ function buildTerminal(
|
|
|
909
982
|
{ signal },
|
|
910
983
|
);
|
|
911
984
|
|
|
985
|
+
// --- Type-to-focus: recovering a keystroke after a mouse selection ---
|
|
986
|
+
// A mouse gesture over `.term-output` leaves the browser no focusable target,
|
|
987
|
+
// so the key target becomes the document body (UI Events: "If no suitable
|
|
988
|
+
// element is in focus, the event target will be the HTML body element") and
|
|
989
|
+
// the hidden textarea stops receiving keys. The click handler above cannot fix
|
|
990
|
+
// that, because focusing the textarea COLLAPSES the selection the user just
|
|
991
|
+
// made, so it deliberately declines while one exists. Net effect without this
|
|
992
|
+
// listener: select a single cell with the mouse and typing silently does
|
|
993
|
+
// nothing, and the two shortcuts that ride the keydown chain go with it
|
|
994
|
+
// (clipboard's Ctrl+Shift+C, contextMenu's Escape).
|
|
995
|
+
//
|
|
996
|
+
// Native terminals have no such competition: a selection is app state and the
|
|
997
|
+
// keyboard belongs to the window, so VTE grabs widget focus on the very press
|
|
998
|
+
// that starts a selection. xterm.js sidesteps it by preventDefaulting mousedown
|
|
999
|
+
// and painting its own selection, which is not open to us — native touch
|
|
1000
|
+
// selection is this UI's mobile selection UX (css/02-terminal.css). So the
|
|
1001
|
+
// browser's model stands and typing takes the keyboard back instead, which is
|
|
1002
|
+
// also what Windows Terminal does ("most key input clears the selection and
|
|
1003
|
+
// passes the key event directly to the underlying shell").
|
|
1004
|
+
const doc = root.ownerDocument;
|
|
1005
|
+
function ownsSelection(): boolean {
|
|
1006
|
+
// EVERY range, and both ends of each: a range dragged from the terminal out
|
|
1007
|
+
// into the host page's own content never arms this in either drag direction,
|
|
1008
|
+
// and neither does a Firefox Ctrl+drag multi-range selection with one range
|
|
1009
|
+
// outside (the mousedown policy above deliberately preserves those, and
|
|
1010
|
+
// removeAllRanges below would take the host's range with the terminal's).
|
|
1011
|
+
// Range ends are in document order whichever way the user dragged, so
|
|
1012
|
+
// direction costs nothing here. Selected TEXT rather than isCollapsed, so a
|
|
1013
|
+
// bare caret does not arm, matching the click handler's own test. Scoped to
|
|
1014
|
+
// `.term-output`, so a selection in the chrome does not arm. Known
|
|
1015
|
+
// limitation: a terminal inside a host's shadow root never arms, because
|
|
1016
|
+
// getSelection() does not reach into shadow trees.
|
|
1017
|
+
const sel = doc.getSelection();
|
|
1018
|
+
if (sel === null || sel.toString().length === 0) {
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
for (let i = 0; i < sel.rangeCount; i++) {
|
|
1022
|
+
const range = sel.getRangeAt(i);
|
|
1023
|
+
if (!outputEl.contains(range.startContainer) || !outputEl.contains(range.endContainer)) {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return true;
|
|
1028
|
+
}
|
|
1029
|
+
doc.addEventListener(
|
|
1030
|
+
"keydown",
|
|
1031
|
+
(ev: KeyboardEvent) => {
|
|
1032
|
+
// Two groups, in this order. The ARMING gates immediately below decide
|
|
1033
|
+
// whether this listener is responsible for the keystroke at all; the
|
|
1034
|
+
// TERMINAL-KEY gates after the feature chain decide whether the terminal
|
|
1035
|
+
// wants this particular key. Both are preconditions rather than a
|
|
1036
|
+
// classification: nothing runs unless every gate passes, so no keystroke
|
|
1037
|
+
// falls between them.
|
|
1038
|
+
if (destroyed) {
|
|
1039
|
+
// Reachable in one window only: cleanupRuntime() tears features down
|
|
1040
|
+
// BEFORE it aborts the signal that removes this listener, so a feature
|
|
1041
|
+
// teardown that synchronously dispatched a key would otherwise be served
|
|
1042
|
+
// by a terminal that is already gone. (The textarea listener needs no
|
|
1043
|
+
// such gate: it only fires while the textarea holds focus, and a teardown
|
|
1044
|
+
// does not focus it.)
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
// A host that already handled this key marked it, so leave it alone. This
|
|
1048
|
+
// is the platform's own convention for composing keyboard handlers, and it
|
|
1049
|
+
// is the reason this listener needs no opt-out option: an embedder with a
|
|
1050
|
+
// document-level keymap cancels the events it owns.
|
|
1051
|
+
if (ev.defaultPrevented) {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
// Nobody owns the keyboard. This one test replaces a blocklist of
|
|
1055
|
+
// interactive tag names and is strictly more complete: focus inside an open
|
|
1056
|
+
// shadow root reports the HOST, an open <dialog> reports the dialog (or its
|
|
1057
|
+
// focused child), and a tabindex="0" ARIA widget reports itself. All
|
|
1058
|
+
// non-body, so all bail. It also subsumes the double-send guard: a focused
|
|
1059
|
+
// textarea is not the body, so a key the handler above already took can
|
|
1060
|
+
// never reach here. Residual: HTML's focus fixup rule can park focus back
|
|
1061
|
+
// on the body while a modal dialog is still open (its focused child was
|
|
1062
|
+
// removed), which the focus-landed check below is what actually covers.
|
|
1063
|
+
if (doc.activeElement !== null && doc.activeElement !== doc.body) {
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
// Composition first, matching the textarea path, and on the composition
|
|
1067
|
+
// module's clock rather than ev.isComposing: that clock stays true through
|
|
1068
|
+
// the deferred compositionend read (the documented Chromium workaround).
|
|
1069
|
+
if (composition.isComposing()) {
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (!ownsSelection()) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
// Features next, while the selection is STILL INTACT, and BEFORE the
|
|
1076
|
+
// modifier gate below. Both orderings are load-bearing: clipboard's
|
|
1077
|
+
// Ctrl+Shift+C is a MODIFIED key that reads the selection, so a modifier
|
|
1078
|
+
// gate placed first would deny the chain the one shortcut this listener
|
|
1079
|
+
// exists to restore (caught by its own test). A claimed key never focuses,
|
|
1080
|
+
// so the selection survives it and a second copy is still possible.
|
|
1081
|
+
for (const h of keydownHandlers) {
|
|
1082
|
+
if (h(ev)) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// What this key DOES is decided before taking focus, because a keystroke
|
|
1087
|
+
// that delivers nothing must leave the selection and the focus untouched.
|
|
1088
|
+
// A bare modifier press is the case that proves it: Shift on its way to
|
|
1089
|
+
// Shift+click, or to Ctrl+Shift+C, would otherwise clear the very selection
|
|
1090
|
+
// the chord is about to act on.
|
|
1091
|
+
//
|
|
1092
|
+
// The character is computed ONLY for an "ignore" result, so the encoder
|
|
1093
|
+
// always wins a key it maps. Under the kitty protocol a MODIFIED character
|
|
1094
|
+
// key encodes to a CSI-u sequence rather than to its text, and the focused
|
|
1095
|
+
// path sends that sequence; this path has to agree with it.
|
|
1096
|
+
const result = mapKeyboardEvent(ev, modes);
|
|
1097
|
+
const char = result.kind === "ignore" ? typedChar(ev) : null;
|
|
1098
|
+
if (result.kind === "ignore" && char === null) {
|
|
1099
|
+
// Nothing to deliver: a bare modifier or lock key, a dead key, an IME
|
|
1100
|
+
// start, a multi-character or unknown key name.
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
// Modified keys stay with the browser, with one exception: an AltGr
|
|
1104
|
+
// CHARACTER, because on the layouts that need AltGr that is how the
|
|
1105
|
+
// character is typed. The exception is deliberately limited to a character
|
|
1106
|
+
// rather than granted to any AltGraph event, because AltGraph is not a
|
|
1107
|
+
// reliable signal on its own: Firefox reports it for plain Option on macOS
|
|
1108
|
+
// and for ordinary Ctrl+Alt on Windows, so admitting every AltGraph event
|
|
1109
|
+
// would hand the terminal Option+ArrowLeft, which is browser back.
|
|
1110
|
+
// (xterm.js carries the mirror-image heuristic, ctrl+alt meaning AltGr, and
|
|
1111
|
+
// scopes it to Windows.)
|
|
1112
|
+
//
|
|
1113
|
+
// Nothing else modified is recovered, and the asymmetry decides that: not
|
|
1114
|
+
// recovering Ctrl+ArrowLeft costs one keypress in a state that ends on the
|
|
1115
|
+
// next character typed, while stealing Ctrl+C costs the user the selection
|
|
1116
|
+
// they just made, and Alt+ArrowLeft is browser back.
|
|
1117
|
+
const altGraphChar = char !== null && ev.getModifierState("AltGraph");
|
|
1118
|
+
if ((ev.ctrlKey || ev.metaKey || ev.altKey) && !altGraphChar) {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
// Tab stays with the browser: this only ever runs on a body-focused page,
|
|
1122
|
+
// where Tab is how a keyboard user moves on (web-terminal-kiro's
|
|
1123
|
+
// focusable-inventory test pins that expectation).
|
|
1124
|
+
if (ev.key === "Tab") {
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
// Take the keyboard. Collapsing explicitly rather than leaning on focus()
|
|
1128
|
+
// to do it keeps the behaviour identical wherever focus() would not.
|
|
1129
|
+
doc.getSelection()?.removeAllRanges();
|
|
1130
|
+
focusTerminal();
|
|
1131
|
+
// Fail closed. Focus can fail to land (an inert subtree under an open modal
|
|
1132
|
+
// dialog, a released root), and bytes whose effect the user cannot see do
|
|
1133
|
+
// not belong in a shell.
|
|
1134
|
+
if (doc.activeElement !== input) {
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (char !== null) {
|
|
1138
|
+
// The encoder defers a plain character to the `input` event, which the
|
|
1139
|
+
// focused path delivers. No input event is coming for a key that targeted
|
|
1140
|
+
// the body, so send it here. preventDefault makes that exactly once BY
|
|
1141
|
+
// SPEC ("If this event is canceled, the associated event types MUST NOT be
|
|
1142
|
+
// dispatched"), rather than by depending on whether an engine re-resolves
|
|
1143
|
+
// an uncanceled keydown's insertion target after a listener moved focus
|
|
1144
|
+
// mid-dispatch. Chromium does; no specification requires it.
|
|
1145
|
+
ev.preventDefault();
|
|
1146
|
+
sendText(normalizeTypedText(char));
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
applyMappedKey(ev, result);
|
|
1150
|
+
},
|
|
1151
|
+
{ signal },
|
|
1152
|
+
);
|
|
1153
|
+
|
|
912
1154
|
// --- Viewport ---
|
|
913
1155
|
viewport.init({
|
|
914
1156
|
termWrap,
|
|
@@ -1216,6 +1458,10 @@ function buildTerminal(
|
|
|
1216
1458
|
// place rather than at each caller.
|
|
1217
1459
|
loadingStatus.reason(message);
|
|
1218
1460
|
},
|
|
1461
|
+
titlePrefix: (text) => {
|
|
1462
|
+
titlePrefixText = text;
|
|
1463
|
+
paintTitle();
|
|
1464
|
+
},
|
|
1219
1465
|
tablist: () => tablistController,
|
|
1220
1466
|
newLineStore: (sessionId) => {
|
|
1221
1467
|
// Registered on EVERY path, because this factory is the only way a store
|
|
@@ -1307,6 +1553,12 @@ function buildTerminal(
|
|
|
1307
1553
|
scrollbackKeeper?.flush();
|
|
1308
1554
|
scrollbackKeeper?.stop();
|
|
1309
1555
|
teardownFeatures();
|
|
1556
|
+
// A destroyed terminal must not leave its attention prefix on the page: the
|
|
1557
|
+
// tab it counted is gone, and the title outlives this runtime (an embedded
|
|
1558
|
+
// panel closing, a reload path). The base is left alone -- it belongs to the
|
|
1559
|
+
// document, not to us.
|
|
1560
|
+
titlePrefixText = "";
|
|
1561
|
+
paintTitle();
|
|
1310
1562
|
kernelAbort.abort();
|
|
1311
1563
|
narrowObserver?.disconnect();
|
|
1312
1564
|
narrowObserver = null;
|
package/src/kernel/types.ts
CHANGED
|
@@ -274,6 +274,21 @@ export interface TerminalContext {
|
|
|
274
274
|
* re-read the same sentence every few seconds. Also a no-op once the overlay
|
|
275
275
|
* has been dismissed, so a late retry cannot resurrect it. */
|
|
276
276
|
loadingReason(message: string): void;
|
|
277
|
+
/** Set the prefix the kernel composes onto the document title, or "" to clear
|
|
278
|
+
* it. The kernel is the only writer of `document.title`: it holds the base
|
|
279
|
+
* (the served `<title>`, replaced by a program's OSC 0/2 window title) and
|
|
280
|
+
* this prefix, and re-composes on either change. A feature that assigned
|
|
281
|
+
* `document.title` directly would be erased by the next OSC 2 from any shell
|
|
282
|
+
* or editor running in a session, with no ordering that avoids it.
|
|
283
|
+
*
|
|
284
|
+
* Idempotent and safe on every status tick: an unchanged result is not
|
|
285
|
+
* re-assigned, because the title doubles as the browser-tab label and the
|
|
286
|
+
* bookmark name. Cleared automatically when the terminal is destroyed.
|
|
287
|
+
*
|
|
288
|
+
* Keep it an AGGREGATE. A page has one title while this UI multiplexes many
|
|
289
|
+
* sessions, so any per-session value has to pick a session arbitrarily (see
|
|
290
|
+
* the standing decision against writing progress here in features/tabs). */
|
|
291
|
+
titlePrefix(text: string): void;
|
|
277
292
|
/** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
|
|
278
293
|
tablist(): TablistController;
|
|
279
294
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// presetAgentTabbed: the tabbed composition tuned for an agent shell.
|
|
2
2
|
|
|
3
3
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
4
|
-
import { buildTabbed } from "./tabbed.js";
|
|
4
|
+
import { type TabbedPresetOptions, buildTabbed } from "./tabbed.js";
|
|
5
5
|
|
|
6
6
|
/** Tabbed UI for an agent shell (web-terminal-kiro). Same features as
|
|
7
7
|
* presetTabbed, tuned for sessions that ARE agents:
|
|
@@ -15,6 +15,6 @@ import { buildTabbed } from "./tabbed.js";
|
|
|
15
15
|
* status classifier that maps OSC 9 notifications to done/needs-input, and the
|
|
16
16
|
* input-derived session name (terminal.WithInputTitle), which the agent host
|
|
17
17
|
* enables because its program's own window title is not worth showing. */
|
|
18
|
-
export function presetAgentTabbed(): TerminalFeature<unknown>[] {
|
|
19
|
-
return buildTabbed(true);
|
|
18
|
+
export function presetAgentTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
|
|
19
|
+
return buildTabbed(true, opts);
|
|
20
20
|
}
|
package/src/presets/tabbed.ts
CHANGED
|
@@ -27,7 +27,22 @@ import { presetSingle } from "./single.js";
|
|
|
27
27
|
* foreground-process/cwd inference — and both presets render what it reports. A
|
|
28
28
|
* browser that re-derived any of that could only disagree with the server and
|
|
29
29
|
* with every other client attached to the same session. */
|
|
30
|
-
|
|
30
|
+
/** Options shared by the two tabbed presets, passed through to the tabs feature.
|
|
31
|
+
* A preset that takes arguments is called as `features: () =>
|
|
32
|
+
* presetTabbed({...})`, which keeps the call inside createTerminal's failure
|
|
33
|
+
* boundary (see CreateTerminalOptions.features). */
|
|
34
|
+
export interface TabbedPresetOptions {
|
|
35
|
+
/** Swap the page's icon links to a status variant while a background session
|
|
36
|
+
* wants the user. Off by default, and enabling it is a promise that the
|
|
37
|
+
* variant assets are served — see TabsOptions.attentionIcons for the naming
|
|
38
|
+
* contract and scripts/gen-attention-icons.py, which writes them. */
|
|
39
|
+
attentionIcons?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildTabbed(
|
|
43
|
+
agentShell: boolean,
|
|
44
|
+
opts: TabbedPresetOptions = {},
|
|
45
|
+
): TerminalFeature<unknown>[] {
|
|
31
46
|
const kb = mobileToolbar({ externalToggle: true });
|
|
32
47
|
const monitor = activityMonitor();
|
|
33
48
|
return [
|
|
@@ -38,6 +53,7 @@ export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
|
|
|
38
53
|
keyboardToggle: kb,
|
|
39
54
|
activityMonitor: monitor,
|
|
40
55
|
presumeReports: agentShell,
|
|
56
|
+
attentionIcons: opts.attentionIcons === true,
|
|
41
57
|
}),
|
|
42
58
|
animations(),
|
|
43
59
|
];
|
|
@@ -49,6 +65,6 @@ export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
|
|
|
49
65
|
* activity dot stays hidden until its session reports activity via OSC 9;4
|
|
50
66
|
* progress (kiro-cli, Claude Code, …), so a plain bash/sh keeps clean,
|
|
51
67
|
* label-only tabs. */
|
|
52
|
-
export function presetTabbed(): TerminalFeature<unknown>[] {
|
|
53
|
-
return buildTabbed(false);
|
|
68
|
+
export function presetTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
|
|
69
|
+
return buildTabbed(false, opts);
|
|
54
70
|
}
|
package/src/presets.ts
CHANGED
|
@@ -19,4 +19,5 @@
|
|
|
19
19
|
export { presetSingle } from "./presets/single.js";
|
|
20
20
|
export { presetTouch } from "./presets/touch.js";
|
|
21
21
|
export { presetTabbed } from "./presets/tabbed.js";
|
|
22
|
+
export type { TabbedPresetOptions } from "./presets/tabbed.js";
|
|
22
23
|
export { presetAgentTabbed } from "./presets/agent-tabbed.js";
|