@cplieger/web-terminal-ui 5.4.6 → 5.5.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.5.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
@@ -258,6 +258,31 @@ function buildTerminal(
258
258
  narrowObserver.observe(root);
259
259
  }
260
260
 
261
+ // --- Document title (kernel-owned, composed from two inputs) ---
262
+ //
263
+ // The page has ONE title and two things want to write it: a program's OSC 0/2
264
+ // window title, and a feature's attention prefix (tabs' unseen-cue count). So
265
+ // the kernel holds both and is the only assigner. Two direct writers was the
266
+ // alternative and it cannot work: whichever ran last won, so a shell or an
267
+ // editor emitting OSC 2 silently erased the prefix, and there is no ordering
268
+ // that fixes it because both inputs change on their own schedule.
269
+ //
270
+ // The base starts as whatever the served document declared, so a consumer that
271
+ // never receives an OSC title (web-terminal-kiro sets kiro-cli's
272
+ // chat.terminalTitle=false) keeps its own <title> as the base rather than
273
+ // needing to declare it twice.
274
+ let baseTitle = document.title;
275
+ let titlePrefixText = "";
276
+ function paintTitle(): void {
277
+ const next = titlePrefixText + baseTitle;
278
+ // Idempotent: the title doubles as the browser-tab label and the bookmark
279
+ // name, and this runs on every status sweep, so an unchanged assignment must
280
+ // not churn it.
281
+ if (document.title !== next) {
282
+ document.title = next;
283
+ }
284
+ }
285
+
261
286
  // Toast surface (kernel-owned shared primitive, section 22.3).
262
287
  const toastEl = document.createElement("div");
263
288
  toastEl.className = "wt-toast";
@@ -607,7 +632,8 @@ function buildTerminal(
607
632
  // when it redraws its prompt after idling, so hold the last-good browser
608
633
  // title instead of flickering blank.
609
634
  if (msg.title.trim() !== "") {
610
- document.title = msg.title;
635
+ baseTitle = msg.title;
636
+ paintTitle();
611
637
  }
612
638
  bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
613
639
  } else if (msg.type === "modes") {
@@ -1216,6 +1242,10 @@ function buildTerminal(
1216
1242
  // place rather than at each caller.
1217
1243
  loadingStatus.reason(message);
1218
1244
  },
1245
+ titlePrefix: (text) => {
1246
+ titlePrefixText = text;
1247
+ paintTitle();
1248
+ },
1219
1249
  tablist: () => tablistController,
1220
1250
  newLineStore: (sessionId) => {
1221
1251
  // Registered on EVERY path, because this factory is the only way a store
@@ -1307,6 +1337,12 @@ function buildTerminal(
1307
1337
  scrollbackKeeper?.flush();
1308
1338
  scrollbackKeeper?.stop();
1309
1339
  teardownFeatures();
1340
+ // A destroyed terminal must not leave its attention prefix on the page: the
1341
+ // tab it counted is gone, and the title outlives this runtime (an embedded
1342
+ // panel closing, a reload path). The base is left alone -- it belongs to the
1343
+ // document, not to us.
1344
+ titlePrefixText = "";
1345
+ paintTitle();
1310
1346
  kernelAbort.abort();
1311
1347
  narrowObserver?.disconnect();
1312
1348
  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";