@cplieger/web-terminal-ui 5.0.2 → 5.1.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/page.css CHANGED
@@ -207,11 +207,15 @@ body {
207
207
  }
208
208
 
209
209
  /* ===== Fonts =====
210
- The terminal web font, served by the full-page host at /vendor/fonts (both
210
+ The bundled web font, served by the full-page host at /vendor/fonts (both
211
211
  web-terminal-server and web-terminal-kiro ship these files there; see their
212
- Dockerfiles). An embedder does not serve this file, so it never inherits
213
- these URLs — it either ships its own @font-face or lets --font-mono fall back
214
- to the platform monospace stack. */
212
+ Dockerfiles). It backs BOTH --font-mono (the terminal display) and --font-ui
213
+ (the chrome), the latter deliberately: chip-label ink centring is a per-font
214
+ constant, so the chrome cannot afford a system-font stack that resolves to a
215
+ different typeface per engine (see --label-ink-shift in 00-tokens.css). An
216
+ embedder does not serve these files and never inherits these URLs, so it
217
+ either ships its own @font-face or lets both tokens fall back to the platform
218
+ monospace stack. */
215
219
  @font-face {
216
220
  font-family: "MonaspiceNe NFM";
217
221
  src: url("/vendor/fonts/MonaspiceNeNerdFontMono-Regular.otf") format("opentype");
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.0.2",
3
+ "version": "5.1.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": "GPL-3.0-or-later",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./startup-copy": "./src/kernel/startup-copy.ts",
10
+ "./style-contract": "./src/kernel/style-contract.ts",
10
11
  "./presets": "./src/presets.ts",
11
12
  "./presets/single": "./src/presets/single.ts",
12
13
  "./presets/touch": "./src/presets/touch.ts",
@@ -39,7 +40,7 @@
39
40
  "verify": "bash scripts/verify.sh"
40
41
  },
41
42
  "peerDependencies": {
42
- "@cplieger/web-terminal-engine": "^3.2.0"
43
+ "@cplieger/web-terminal-engine": "^3.3.0"
43
44
  },
44
45
  "overrides": {
45
46
  "qs": "^6.15.2"
@@ -52,7 +53,7 @@
52
53
  "@types/node": "25.9.5",
53
54
  "@typescript/native": "npm:typescript@7.0.2",
54
55
  "@vitest/coverage-v8": "4.1.10",
55
- "eslint": "10.7.0",
56
+ "eslint": "10.8.0",
56
57
  "fast-check": "4.9.0",
57
58
  "happy-dom": "20.11.1",
58
59
  "html-validate": "11.5.6",
@@ -16,8 +16,10 @@ import type { TerminalFeature, Unsubscribe } from "../kernel/types.js";
16
16
  const DEFAULT_EVENTS_PATH = "/api/sessions/events";
17
17
 
18
18
  export interface ActivityMonitorApi {
19
- /** Subscribe to every status update (working/idle/input/exited, plus removed).
20
- * Fired for the initial snapshot and each change. */
19
+ /** Subscribe to every status update (idle/working/warning/failed/input/done/
20
+ * exited/crashed, plus removed), including the OSC 9;4 percentage and any OSC
21
+ * 9 notification the event carries. Fired for the initial snapshot and each
22
+ * change. */
21
23
  onStatus(cb: (s: SessionStatus) => void): Unsubscribe;
22
24
  /** The last known status for a session, or undefined. */
23
25
  current(id: string): SessionStatus | undefined;
@@ -14,11 +14,13 @@ import type { ActivityMonitorApi } from "../activity-monitor.js";
14
14
  import type { MobileToolbarApi } from "../mobile-toolbar.js";
15
15
  import { fromHTML } from "../dom.js";
16
16
  import { createClickSwallow, placeMenuAt } from "../menu-position.js";
17
- import type { SessionInfo, Tab } from "./model.js";
17
+ import type { CueStatus, SessionInfo, StatusRecord, Tab } from "./model.js";
18
18
  import {
19
19
  ACTIVE_TAB_KEY,
20
+ CUE_SEEN_KEY,
21
+ MAX_PERSISTED_CUE_SEEN,
20
22
  MAX_PINNED_NAME,
21
- STATUS_EXITED,
23
+ PROGRESS_ABSENT,
22
24
  SWIPE_HINT_KEY,
23
25
  SessionAPIError,
24
26
  TAB_ORDER_KEY,
@@ -26,15 +28,26 @@ import {
26
28
  createSessionAPI,
27
29
  createTombstones,
28
30
  hasPinnedName,
31
+ isCueStatus,
32
+ isEndedStatus,
33
+ normalizeProgress,
29
34
  orderedInsertIndex,
35
+ parseCueSeen,
30
36
  parseTabOrder,
37
+ progressLabel,
38
+ renderedProgress,
31
39
  sanitizePinnedName,
40
+ serializeCueSeen,
32
41
  serializeTabOrder,
42
+ statusPhrase,
43
+ tabAccessibleName,
33
44
  } from "./model.js";
45
+ import { browserNotifierEnv, createNotifier } from "./notify.js";
34
46
  import {
35
47
  TAB_HTML,
36
48
  kbButtonHTML,
37
49
  newButtonHTML,
50
+ paintProgress,
38
51
  paintStatusDot,
39
52
  pick,
40
53
  switchButtonHTML,
@@ -186,6 +199,29 @@ function writeSavedOrder(ids: readonly string[]): void {
186
199
  }
187
200
  }
188
201
 
202
+ /** readCueSeen loads the acknowledged background-tab cues, or an empty map when
203
+ * there is none to trust. Storage itself can throw (Safari private mode, a
204
+ * disabled third-party context, an embedder's iframe) and an unreadable map is
205
+ * never fatal: the dot simply lights again, exactly as it behaved before
206
+ * acknowledgements were remembered. */
207
+ function readCueSeen(): Map<string, CueStatus> {
208
+ try {
209
+ return parseCueSeen(localStorage.getItem(CUE_SEEN_KEY));
210
+ } catch {
211
+ return new Map<string, CueStatus>();
212
+ }
213
+ }
214
+
215
+ /** writeCueSeen persists acknowledgements, best-effort: a full quota must not
216
+ * break the dismissal the user just performed on screen. */
217
+ function writeCueSeen(seen: ReadonlyMap<string, CueStatus>): void {
218
+ try {
219
+ localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
220
+ } catch {
221
+ /* storage unavailable or full — the dismissal still holds for this page */
222
+ }
223
+ }
224
+
189
225
  async function createSessionHonouringRetry(
190
226
  api: { create: () => Promise<SessionInfo> },
191
227
  ctx: TerminalContext,
@@ -444,6 +480,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
444
480
  const swInner = pick(switcher, ".wt-switcher-current-inner");
445
481
  const swDot = pick(switcher, ".wt-switcher-dot");
446
482
  const swLabel = pick(switcher, ".wt-switcher-label");
483
+ // The active row's determinate progress bar. Scoped to the current chip:
484
+ // every expanded list row carries a .wt-progress-bar of its own, so an
485
+ // unscoped pick would be ambiguous the moment the list is populated.
486
+ const swProgress = pick(switcher, ".wt-switcher-current .wt-progress-bar");
447
487
  const swClose = pick(switcher, ".wt-switcher-current-close");
448
488
  // The active-tab elements that translate together during a horizontal
449
489
  // swipe: the content (dot + label) and the close (x). Moving both keeps the
@@ -463,20 +503,72 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
463
503
  // Latest-wins notification state for the switch button's dot: overwritten
464
504
  // by each qualifying background-tab event (applyStatus) and cleared when
465
505
  // the list opens (expandSwitcher) or the raising tab is visited or closed
466
- // (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden.
467
- let switchNotify: "" | "input" | "done" = "";
506
+ // (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden. The
507
+ // cue-worthy set is CueStatus (input / done / crashed — the states that
508
+ // want the user), declared once in model.ts beside isCueStatus.
509
+ let switchNotify: "" | CueStatus = "";
468
510
  // The session that raised the pending cue, so arriving on that tab (a
469
511
  // swipe or any switch) acknowledges it without opening the list.
470
512
  let switchNotifyId: string | null = null;
513
+ // Cues this viewer has already SEEN, per session (see CUE_SEEN_KEY).
514
+ // Loaded here so a reload starts from what the user already dismissed:
515
+ // input/done stay LATCHED server-side, and the status stream re-pushes the
516
+ // latch in the snapshot it sends on every open, so without this a
517
+ // dismissed dot came back on the next load — and on every SSE reconnect,
518
+ // which on a phone is just returning to a backgrounded page.
519
+ const cueSeen = readCueSeen();
520
+ // markCueSeen records that this viewer has seen `id` holding `status`:
521
+ // either because that tab is the one on screen, or because the tray listing
522
+ // every tab's dot was opened. A non-cue status is not an acknowledgeable
523
+ // event, so it is ignored rather than stored.
524
+ function markCueSeen(id: string, status: string): void {
525
+ if (!isCueStatus(status) || cueSeen.get(id) === status) {
526
+ return;
527
+ }
528
+ cueSeen.set(id, status);
529
+ // Evict the oldest entries so the live map obeys the same cap the parser
530
+ // does. dropTab prunes a session closed while this page was open, but one
531
+ // that vanished while the page was CLOSED leaves an entry nothing else
532
+ // collects; unbounded, that would eventually push the map past the cap and
533
+ // make the parser discard whatever it read last — dropping fresh
534
+ // acknowledgements to keep dead ones.
535
+ while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
536
+ const oldest = cueSeen.keys().next().value;
537
+ if (oldest === undefined) {
538
+ break;
539
+ }
540
+ cueSeen.delete(oldest);
541
+ }
542
+ writeCueSeen(cueSeen);
543
+ }
544
+ // forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
545
+ // is a fresh cue: called when its status moves off the acknowledged value
546
+ // (a new working phase, an exit) and when the session goes away.
547
+ function forgetCueSeen(id: string): void {
548
+ if (cueSeen.delete(id)) {
549
+ writeCueSeen(cueSeen);
550
+ }
551
+ }
552
+ // OSC 9 Form B notifications: the one signal the specs say to surface
553
+ // OUTSIDE the page ("post a notification"), so a finished turn can reach a
554
+ // user who is looking at another app. All the policy — the suppression
555
+ // rule, the gesture-gated permission request, the silent tab-only
556
+ // degradation — lives in notify.ts; this feature only feeds it events and
557
+ // gestures.
558
+ const notifier = createNotifier(browserNotifierEnv());
471
559
  function paintSwitchDot(): void {
472
560
  // Reuse the per-tab status-dot colours (single source, css/05-tabs.css
473
- // .wt-status-dot[data-status="input"|"done"]) instead of re-declaring
474
- // them: the dot has the .wt-status-dot class, so data-status colours it
475
- // exactly like the tabs' own dots.
561
+ // .wt-status-dot[data-status="input"|"done"|"crashed"]) instead of
562
+ // re-declaring them: the dot has the .wt-status-dot class, so data-status
563
+ // colours it exactly like the tabs' own dots. Its tooltip comes from the
564
+ // same wording map as theirs, so the aggregate cue names the state it is
565
+ // showing rather than being a coloured dot with no explanation.
476
566
  if (switchNotify === "") {
477
567
  delete swSwitchDot.dataset["status"];
568
+ swSwitchDot.removeAttribute("title");
478
569
  } else {
479
570
  swSwitchDot.dataset["status"] = switchNotify;
571
+ swSwitchDot.title = statusPhrase(switchNotify);
480
572
  }
481
573
  }
482
574
  function clearSwitchNotify(): void {
@@ -640,6 +732,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
640
732
  // same window title, or two tabs whose last submitted line was identical),
641
733
  // the second and later get a " (k)" suffix in creation order, so the strip
642
734
  // never shows two identical labels.
735
+ //
736
+ // The OSC 9;4 percentage prefix ("78% · one") is applied HERE, at render
737
+ // time, and stored nowhere: `display` stays the plain de-duplicated label,
738
+ // so de-duplication compares real names, the rename field opens on the real
739
+ // name, `list()` reports the real name, and clearing the progress needs no
740
+ // cleanup — the next paint simply stops adding it.
643
741
  function relabelAll(): void {
644
742
  // Count only real (non-fallback) labels, so multiple untitled tabs all
645
743
  // read "New tab" without a numeric suffix.
@@ -662,11 +760,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
662
760
  }
663
761
  }
664
762
  t.display = display;
665
- t.label.textContent = display;
666
- t.aria.setLabel(display);
763
+ const rendered = progressLabel(display, shownProgress(t));
764
+ t.label.textContent = rendered;
765
+ // The accessible name carries the state as well as the label: the dots
766
+ // are aria-hidden decoration, so without this a screen-reader user
767
+ // cannot tell a working tab from a crashed one.
768
+ t.aria.setLabel(tabAccessibleName(rendered, statusOf(t)));
667
769
  }
668
770
  }
669
771
 
772
+ /** statusOf reads a tab's current status back off its dot, which is where
773
+ * applyStatus records it (the dot is the one element that always has it,
774
+ * on every chip site). */
775
+ function statusOf(t: Tab): string {
776
+ return t.dot.dataset["status"] ?? "idle";
777
+ }
778
+
779
+ /** shownProgress is a tab's percentage as it may currently be DISPLAYED:
780
+ * the last value the server reported, dropped once the process is gone.
781
+ * Those are the only two ways a percentage stops showing — the program's
782
+ * own OSC 9;4;0 (an explicit -1) and the process ending; see
783
+ * renderedProgress for why there is no third. */
784
+ function shownProgress(t: Tab): number {
785
+ return renderedProgress(statusOf(t), t.progress);
786
+ }
787
+
670
788
  const termInput = (): HTMLElement | null =>
671
789
  ctx.surface().querySelector<HTMLElement>(".term-input");
672
790
  function focusInput(): void {
@@ -758,8 +876,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
758
876
  function syncMobile(): void {
759
877
  const idx = tabList.findIndex((t) => t.id === activeId);
760
878
  const active = idx >= 0 ? tabList[idx] : undefined;
761
- swLabel.textContent = active ? active.display : "";
762
- paintStatusDot(swDot, active?.dot.dataset["status"] ?? "idle", active?.reports ?? false);
879
+ swLabel.textContent = active ? progressLabel(active.display, shownProgress(active)) : "";
880
+ paintStatusDot(swDot, active ? statusOf(active) : "idle", active?.reports ?? false);
881
+ paintProgress(swProgress, active ? shownProgress(active) : PROGRESS_ABSENT);
763
882
  // The aggregate background-notification cue rides the dedicated switch
764
883
  // button's dot (paintSwitchDot), not the active surface (it did not fit
765
884
  // there).
@@ -780,14 +899,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
780
899
  });
781
900
  return row;
782
901
  }
783
- // updateRow refreshes a reused row's live bits (status dot + label).
902
+ // updateRow refreshes a reused row's live bits (status dot + label +
903
+ // progress bar).
784
904
  function updateRow(row: HTMLElement, t: Tab): void {
785
- paintStatusDot(
786
- pick(row, ".wt-switcher-row-dot"),
787
- t.dot.dataset["status"] ?? "idle",
788
- t.reports,
905
+ paintStatusDot(pick(row, ".wt-switcher-row-dot"), statusOf(t), t.reports);
906
+ paintProgress(pick(row, ".wt-progress-bar"), shownProgress(t));
907
+ pick(row, ".wt-switcher-row-label").textContent = progressLabel(
908
+ t.display,
909
+ shownProgress(t),
789
910
  );
790
- pick(row, ".wt-switcher-row-label").textContent = t.display;
791
911
  }
792
912
  // renderSwitcherList reconciles the expanded list to a row per OTHER tab
793
913
  // (the active tab lives in the bar row), REUSING existing row elements
@@ -1003,6 +1123,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1003
1123
  persistOrder();
1004
1124
  }
1005
1125
 
1126
+ // The percentage is deliberately NOT written into the browser document
1127
+ // title. ConEmu's spec names the taskbar/title as a display site, and a
1128
+ // page's document title is the nearest analogue — but a page has exactly
1129
+ // ONE title while this UI multiplexes many sessions, so any rule for
1130
+ // choosing whose percentage it shows is arbitrary. Even restricted to the
1131
+ // active tab it churns a surface that doubles as the browser-tab label and
1132
+ // the bookmark name, and it invites reading one session's progress as the
1133
+ // window's. The per-chip prefix carries the same information without the
1134
+ // conflict, because each chip shows its own session. Do not add it back.
1135
+
1006
1136
  // persistOrder stores the strip's current arrangement so it survives a
1007
1137
  // reload. It hangs off syncChrome — the one function every list mutation
1008
1138
  // already ends with (reorder, create, adopt, close, reconcile) — rather than
@@ -1100,6 +1230,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1100
1230
  }
1101
1231
  // Opening the list acknowledges any pending background-tab notification:
1102
1232
  // the user is now looking at the tabs, so clear the switch button's dot.
1233
+ // The tray lists EVERY tab with its own status dot, so every latched tab
1234
+ // is acknowledged here, not just the cue's latest subject — several tabs
1235
+ // can be latched at once while the dot only ever showed the newest, and an
1236
+ // unacknowledged sibling would re-raise it on the next load.
1237
+ for (const t of tabList) {
1238
+ markCueSeen(t.id, t.dot.dataset["status"] ?? "");
1239
+ }
1103
1240
  clearSwitchNotify();
1104
1241
  renderSwitcherList();
1105
1242
  setExpandedState(true);
@@ -1123,12 +1260,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1123
1260
  }
1124
1261
  }
1125
1262
 
1126
- function addTabChrome(info: SessionInfo): Tab {
1263
+ function addTabChrome(info: StatusRecord): Tab {
1127
1264
  const el = fromHTML(TAB_HTML);
1128
1265
  const label = el.querySelector<HTMLElement>(".wt-tab-label");
1129
1266
  const dot = el.querySelector<HTMLElement>(".wt-tab-dot");
1267
+ const progressEl = el.querySelector<HTMLElement>(".wt-progress-bar");
1130
1268
  const close = el.querySelector<HTMLButtonElement>(".wt-tab-close");
1131
- if (!label || !dot || !close) {
1269
+ if (!label || !dot || !progressEl || !close) {
1132
1270
  throw new Error("web-terminal-ui: tab chrome missing parts");
1133
1271
  }
1134
1272
  paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
@@ -1158,16 +1296,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1158
1296
  el,
1159
1297
  label,
1160
1298
  dot,
1299
+ progressEl,
1300
+ // A percentage exists only on the status STREAM, so a tab adopted from
1301
+ // the REST list starts with none; the first status event fills it in.
1302
+ progress: normalizeProgress(info.progressValue),
1161
1303
  aria,
1162
1304
  scrollTop: 0,
1163
1305
  following: true,
1164
1306
  reports: reportsOf(info.reportsActivity),
1165
1307
  };
1308
+ paintProgress(progressEl, renderedProgress(info.status, tab.progress));
1166
1309
  // Set an initial label immediately (relabelAll refines it with de-dup
1167
1310
  // once the tab is in tabList and syncChrome runs).
1168
1311
  tab.display = baseLabel(tab).text;
1169
- label.textContent = tab.display;
1170
- aria.setLabel(tab.display);
1312
+ label.textContent = progressLabel(tab.display, renderedProgress(info.status, tab.progress));
1313
+ aria.setLabel(tabAccessibleName(label.textContent, info.status));
1171
1314
  el.addEventListener("click", (e) => {
1172
1315
  if ((e.target as HTMLElement).closest(".wt-tab-close")) {
1173
1316
  return; // handled by the close button
@@ -1371,8 +1514,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1371
1514
  activeId = next.id;
1372
1515
  // Arriving on the tab that raised the switch-button cue resolves it
1373
1516
  // (a swipe through the tabs must dismiss the dot, not only opening the
1374
- // list).
1517
+ // list). Its current latch is acknowledged whether or not it was the
1518
+ // cue's subject: the terminal is now on screen, so a reload must not
1519
+ // notify about a state the user just looked at.
1375
1520
  acknowledgeSwitchNotify(next.id);
1521
+ markCueSeen(next.id, next.dot.dataset["status"] ?? "");
1376
1522
  try {
1377
1523
  localStorage.setItem(ACTIVE_TAB_KEY, next.id);
1378
1524
  } catch {
@@ -1639,14 +1785,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1639
1785
  // stream / poll adopt the existing sessions below; without this they would
1640
1786
  // render inert (blank, never connecting) until the user taps a tab. This
1641
1787
  // is the sibling of the '+'-retry recovery the bootstrap already handles.
1642
- // A live tab outranks an exited one (its dot status is fed by the same
1788
+ // A live tab outranks an ENDED one (its dot status is fed by the same
1643
1789
  // SSE/poll that adopted it); a corpse is only auto-activated when nothing
1644
- // else exists.
1790
+ // else exists. "Ended" covers both ways a process goes (exited and
1791
+ // crashed) — a crashed session is exactly as unable to produce output as a
1792
+ // cleanly exited one, so activating it would wedge the page the same way.
1645
1793
  function ensureActive(): void {
1646
1794
  if (activeId !== null) {
1647
1795
  return;
1648
1796
  }
1649
- const first = tabList.find((t) => t.dot.dataset["status"] !== STATUS_EXITED) ?? tabList[0];
1797
+ const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
1650
1798
  if (first) {
1651
1799
  switchTo(first.id);
1652
1800
  }
@@ -1749,8 +1897,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1749
1897
  // the server reaping it cannot re-adopt the just-closed tab.
1750
1898
  tombstones.add(id);
1751
1899
  // A pending switch-button cue whose subject just closed is moot: clear
1752
- // it rather than leaving a dot no tab visit can ever resolve.
1900
+ // it rather than leaving a dot no tab visit can ever resolve. Its stored
1901
+ // acknowledgement goes too — the session is gone, so the entry would only
1902
+ // sit in storage forever.
1753
1903
  acknowledgeSwitchNotify(id);
1904
+ forgetCueSeen(id);
1905
+ notifier.forget(id); // no more notifications can arrive for a gone session
1754
1906
  tab.aria.remove();
1755
1907
  // Remove immediately (no exit animation): a lingering element made the
1756
1908
  // "+" teleport after a delay, and made a last-tab replacement appear in
@@ -2132,12 +2284,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2132
2284
  (e) => {
2133
2285
  menuTouch = e.pointerType === "touch";
2134
2286
  noteChromePress();
2287
+ notifier.gesture();
2135
2288
  },
2136
2289
  { passive: true },
2137
2290
  );
2138
2291
  // The switcher's half of that snapshot (its rows and the x are buttons, so
2139
2292
  // pressing one focuses it and blurs the terminal exactly as a chip does).
2140
- switcher.addEventListener("pointerdown", noteChromePress, { passive: true });
2293
+ // It is also the mobile half of the notification-permission gesture.
2294
+ switcher.addEventListener(
2295
+ "pointerdown",
2296
+ () => {
2297
+ noteChromePress();
2298
+ notifier.gesture();
2299
+ },
2300
+ { passive: true },
2301
+ );
2141
2302
  function hideTabMenu(): void {
2142
2303
  tabMenu.classList.remove("visible");
2143
2304
  tabMenu.replaceChildren();
@@ -2249,13 +2410,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2249
2410
  // one, and its title fields are interchangeable strings that as positional
2250
2411
  // parameters would be trivially swappable. reports is the server's sticky reportsActivity flag, passed
2251
2412
  // separately because it is normalised (reportsOf) before it gets here.
2252
- function applyStatus(rec: SessionInfo, reports: boolean): void {
2413
+ function applyStatus(rec: StatusRecord, reports: boolean): void {
2253
2414
  const t = tabList.find((tab) => tab.id === rec.id);
2254
2415
  if (!t) {
2255
2416
  return;
2256
2417
  }
2257
2418
  t.reports = reports;
2258
2419
  paintStatusDot(t.dot, rec.status, reports);
2420
+ // The OSC 9;4 percentage. Only an explicitly PRESENT value updates the
2421
+ // tab: the polling fallback lists SessionInfo, which carries no
2422
+ // percentage at all, and reading that absence as "cleared" would blank a
2423
+ // live bar on every poll tick. The spec's own clear (OSC 9;4;0, or the
2424
+ // abbreviated form) arrives as an explicit -1; the only other clear is
2425
+ // the process ending, applied at render time (renderedProgress). No
2426
+ // timer, and nothing special about 100%.
2427
+ if (rec.progressValue !== undefined) {
2428
+ t.progress = normalizeProgress(rec.progressValue);
2429
+ }
2430
+ paintProgress(t.progressEl, shownProgress(t));
2431
+ // A session that reports activity is a session whose program speaks OSC 9,
2432
+ // so it is also one that may post a notification: arm the permission
2433
+ // request for the next user gesture (see notify.ts).
2434
+ if (reports) {
2435
+ notifier.arm();
2436
+ }
2259
2437
  // Latest-wins background-tab notification for the switch button's dot: a
2260
2438
  // background tab (not the active one) reaching "input" (needs you) or
2261
2439
  // "done" (turn finished) raises the cue in that colour; each qualifying
@@ -2263,7 +2441,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2263
2441
  // list opens. The active surface keeps its own needs-input cue (see
2264
2442
  // syncMobile); this is the moved + upgraded, glanceable version on the
2265
2443
  // dedicated button.
2266
- if (rec.id !== activeId && (rec.status === "input" || rec.status === "done")) {
2444
+ //
2445
+ // Both statuses are LATCHED server-side, so this runs on re-delivered
2446
+ // state as well as on genuine transitions: every SSE (re)open pushes a
2447
+ // snapshot and the poll fallback re-lists every few seconds. What makes a
2448
+ // dismissal stick across those is cueSeen — a latch this viewer already
2449
+ // acknowledged raises nothing, while a status that moved on drops the
2450
+ // acknowledgement so the next latch is a fresh cue.
2451
+ if (!isCueStatus(rec.status)) {
2452
+ forgetCueSeen(rec.id);
2453
+ } else if (rec.id === activeId) {
2454
+ // The user is looking at this terminal as it latches, so there is
2455
+ // nothing to notify — and nothing to re-raise once they move away.
2456
+ markCueSeen(rec.id, rec.status);
2457
+ } else if (cueSeen.get(rec.id) !== rec.status) {
2267
2458
  switchNotify = rec.status;
2268
2459
  switchNotifyId = rec.id;
2269
2460
  paintSwitchDot();
@@ -2375,6 +2566,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2375
2566
  applyStatus(s, reportsOf(s.reportsActivity));
2376
2567
  ensureActive();
2377
2568
  syncChrome();
2569
+ // A notification is an EVENT the engine delivers once, on the sweep
2570
+ // that first observes it, so it is handled here rather than in
2571
+ // applyStatus (which also runs on re-delivered STATE). Delivered after
2572
+ // syncChrome so the notification's title is the label the user would
2573
+ // see. The status record is the notification's carrier whether or not
2574
+ // the server has a classifier installed.
2575
+ notifier.deliver(s, {
2576
+ sessionIsActive: s.id === activeId,
2577
+ label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
2578
+ });
2378
2579
  });
2379
2580
  offStreamOpen = monitor.onStreamOpen?.(() => {
2380
2581
  void reconcileOnce();
@@ -2908,13 +3109,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2908
3109
  } catch {
2909
3110
  sessions = [];
2910
3111
  }
2911
- // Spawn a fresh session unless a LIVE one is listed. An exited session
2912
- // is viewable history, not a working terminal — booting a page whose
2913
- // every session has died (the agent exited: a sign-in dead end, a
2914
- // crash) onto a corpse was the stuck-loading wedge. The exited ones are
2915
- // still adopted below (switch to them to read their final screen; close
2916
- // them by hand).
2917
- if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
3112
+ // Spawn a fresh session unless a LIVE one is listed. An ended session
3113
+ // (exited cleanly OR crashed) is viewable history, not a working
3114
+ // terminal — booting a page whose every session has died (the agent
3115
+ // exited: a sign-in dead end, a crash) onto a corpse was the
3116
+ // stuck-loading wedge. The dead ones are still adopted below (switch to
3117
+ // them to read their final screen; close them by hand).
3118
+ if (!sessions.some((s) => !isEndedStatus(s.status))) {
2918
3119
  try {
2919
3120
  sessions = [...sessions, await createSessionHonouringRetry(api, ctx, () => tornDown)];
2920
3121
  } catch (err) {
@@ -2954,15 +3155,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2954
3155
  // Activate the previously-active session if it still exists, else the
2955
3156
  // first (oldest). Session ids are stable server-side, so a page reload
2956
3157
  // reconnects to the tab the user left on instead of always the oldest.
2957
- // Live sessions outrank exited ones: the saved id is honored only while
2958
- // its session is still live (a reload used to restore straight onto the
2959
- // corpse of a died-while-away session and wedge there), and the default
2960
- // is the oldest LIVE tab. Only when nothing is live (the fresh-spawn
2961
- // above failed too) does an exited tab start — a frozen final screen
2962
- // with the "Session ended" banner beats a blank page.
2963
- const liveIds = new Set(
2964
- sessions.filter((s) => s.status !== STATUS_EXITED).map((s) => s.id),
2965
- );
3158
+ // Live sessions outrank ended ones (exited or crashed): the saved id is
3159
+ // honored only while its session is still live (a reload used to restore
3160
+ // straight onto the corpse of a died-while-away session and wedge
3161
+ // there), and the default is the oldest LIVE tab. Only when nothing is
3162
+ // live (the fresh-spawn above failed too) does a dead tab start — a
3163
+ // frozen final screen with the "Session ended" banner beats a blank
3164
+ // page.
3165
+ const liveIds = new Set(sessions.filter((s) => !isEndedStatus(s.status)).map((s) => s.id));
2966
3166
  const oldestLive = tabList.find((t) => liveIds.has(t.id));
2967
3167
  let startTab = oldestLive ?? tabList[0];
2968
3168
  try {