@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 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 never shows any of
879
- them — input/done/crashed only — so its position: absolute placement never
880
- meets this rule's position: relative.)
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
@@ -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 three only — the rule the user set is "only states that
330
- want the user, like a notification", so the ongoing progress states (working /
331
- warning / failed) never raise it. Reuses the exact .wt-status-dot rules
332
- (30-tabs.css, --status-* tokens) so it matches the per-tab dots. Hidden until
333
- the tabs feature sets data-status; cleared when the list opens
334
- (expandSwitcher). */
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, defined
344
- once. None of the three animates, so nothing here can pin a wave layer.
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.4.6",
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 that
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 (cueSeen.get(rec.id) !== rec.status) {
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 aggregate attention
196
- * cue. The rule is "only states that want the user — it's like a notification":
197
- * a background terminal blocked on the user ("input"), one whose turn finished
198
- * ("done"), and one whose process died badly ("crashed"). The three animated
199
- * progress states are deliberately excluded — working / warning / failed are
200
- * ongoing and informational, and an animated dot pinned to the switch button
201
- * would nag with nothing to act on. Declared once here so the raise test and
202
- * the acknowledgement store cannot disagree about which statuses are
203
- * cue-worthy. */
204
- export type CueStatus = "input" | "done" | "crashed";
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 status === "input" || status === "done" || status === STATUS_CRASHED;
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
@@ -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
- document.title = msg.title;
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.replace(/\u00A0/g, " "));
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).replace(/\u00A0/g, " "));
748
+ sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
695
749
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
696
- sendText(v.replace(/\u00A0/g, " "));
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
- const result = mapKeyboardEvent(ev, modes);
733
- switch (result.kind) {
734
- case "send":
735
- ev.preventDefault();
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;
@@ -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
  }
@@ -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
- export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
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";