@cplieger/web-terminal-ui 5.0.3 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -172,22 +172,20 @@
172
172
  min-width: 0;
173
173
  }
174
174
 
175
+ /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
176
+ the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
177
+ that lifts the ink to the row centre. See 30-tabs.css for the derivation, for
178
+ why line-height must stay `normal` here, and for why this is margin arithmetic
179
+ over the bundled font rather than a text-box trim behind an @supports gate that
180
+ only one engine ever takes. */
175
181
  :where(.wt-root) .wt-switcher-label {
176
182
  flex: 1 1 auto;
177
183
  overflow: hidden;
178
184
  text-overflow: ellipsis;
179
185
  white-space: nowrap;
180
186
  text-align: left;
181
- }
182
-
183
- /* Ink-centering trim + its mandatory descender allowance, same as .wt-tab-label
184
- (see 30-tabs.css for why `ex`, why the padding is not optional, and why the
185
- pair is @supports-gated rather than left to fall back). */
186
- @supports (text-box: trim-both ex alphabetic) {
187
- :where(.wt-root) .wt-switcher-label {
188
- text-box: trim-both ex alphabetic;
189
- padding-block: 0.5em;
190
- }
187
+ padding-block: 0.125em;
188
+ margin-block: 0 calc(2 * var(--label-ink-shift));
191
189
  }
192
190
 
193
191
  /* Keyboard button: hidden until a keyboardToggle is wired; filled while the key
@@ -301,9 +299,12 @@
301
299
 
302
300
  /* Latest-wins background-tab notification dot on the switch button — the moved
303
301
  attention cue: the input colour when a background terminal needs input, the
304
- done colour when a background turn finished. Reuses the exact .wt-status-dot
305
- rules (30-tabs.css, --status-* tokens) so it matches the per-tab dots. Hidden
306
- until the tabs feature sets data-status; cleared when the list opens
302
+ done colour when a background turn finished, the failed red when a background
303
+ process crashed. Those three only — the rule the user set is "only states that
304
+ want the user, like a notification", so the ongoing progress states (working /
305
+ warning / failed) never raise it. Reuses the exact .wt-status-dot rules
306
+ (30-tabs.css, --status-* tokens) so it matches the per-tab dots. Hidden until
307
+ the tabs feature sets data-status; cleared when the list opens
307
308
  (expandSwitcher). */
308
309
  :where(.wt-root) .wt-switcher-switch-dot {
309
310
  position: absolute;
@@ -312,8 +313,9 @@
312
313
  }
313
314
 
314
315
  /* The dot carries the .wt-status-dot class, so its size, shape, and colour come
315
- straight from the shared .wt-status-dot[data-status="input"|"done"] rules
316
- (30-tabs.css) — the SAME dot colours every tab already uses, defined once.
316
+ straight from the shared .wt-status-dot[data-status="input"|"done"|"crashed"]
317
+ rules (30-tabs.css) — the SAME dot colours every tab already uses, defined
318
+ once. None of the three animates, so nothing here can pin a wave layer.
317
319
  .wt-status-dot is display:none by default; force it visible only while a
318
320
  notification is set (data-status present). */
319
321
  :where(.wt-root) .wt-switcher-switch-dot[data-status] {
@@ -407,6 +409,10 @@
407
409
  display: flex;
408
410
  align-items: center;
409
411
  gap: var(--sp-2);
412
+ /* Positioning context for this row's progress bar (.wt-progress-bar, defined
413
+ once with the shared dot vocabulary in 30-tabs.css), which rides the row's
414
+ own bottom edge. */
415
+ position: relative;
410
416
  min-height: var(--touch-target);
411
417
  /* Right padding reserves room for the close (x) overlaid inside the box. */
412
418
  padding: 0 var(--touch-target) 0 var(--sp-2);
@@ -435,21 +441,19 @@
435
441
  background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
436
442
  }
437
443
 
444
+ /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
445
+ the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
446
+ that lifts the ink to the row centre. See 30-tabs.css for the derivation, for
447
+ why line-height must stay `normal` here, and for why this is margin arithmetic
448
+ over the bundled font rather than a text-box trim behind an @supports gate that
449
+ only one engine ever takes. */
438
450
  :where(.wt-root) .wt-switcher-row-label {
439
451
  flex: 1 1 auto;
440
452
  overflow: hidden;
441
453
  text-overflow: ellipsis;
442
454
  white-space: nowrap;
443
- }
444
-
445
- /* Ink-centering trim + its mandatory descender allowance, same as .wt-tab-label
446
- (see 30-tabs.css for why `ex`, why the padding is not optional, and why the
447
- pair is @supports-gated rather than left to fall back). */
448
- @supports (text-box: trim-both ex alphabetic) {
449
- :where(.wt-root) .wt-switcher-row-label {
450
- text-box: trim-both ex alphabetic;
451
- padding-block: 0.5em;
452
- }
455
+ padding-block: 0.125em;
456
+ margin-block: 0 calc(2 * var(--label-ink-shift));
453
457
  }
454
458
 
455
459
  /* The close sits flush at the row's right edge (no gap), vertically centered. */
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.3",
3
+ "version": "5.2.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"
@@ -55,7 +56,7 @@
55
56
  "eslint": "10.8.0",
56
57
  "fast-check": "4.9.0",
57
58
  "happy-dom": "20.11.1",
58
- "html-validate": "11.5.6",
59
+ "html-validate": "11.6.0",
59
60
  "knip": "6.29.0",
60
61
  "prettier": "3.9.6",
61
62
  "stylelint": "17.14.1",
@@ -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,13 +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 { CueStatus, SessionInfo, Tab } from "./model.js";
17
+ import type { CueStatus, SessionInfo, StatusRecord, Tab } from "./model.js";
18
18
  import {
19
19
  ACTIVE_TAB_KEY,
20
20
  CUE_SEEN_KEY,
21
21
  MAX_PERSISTED_CUE_SEEN,
22
22
  MAX_PINNED_NAME,
23
- STATUS_EXITED,
23
+ PROGRESS_ABSENT,
24
24
  SWIPE_HINT_KEY,
25
25
  SessionAPIError,
26
26
  TAB_ORDER_KEY,
@@ -29,17 +29,24 @@ import {
29
29
  createTombstones,
30
30
  hasPinnedName,
31
31
  isCueStatus,
32
+ isEndedStatus,
33
+ normalizeProgress,
32
34
  orderedInsertIndex,
33
35
  parseCueSeen,
34
36
  parseTabOrder,
37
+ renderedProgress,
35
38
  sanitizePinnedName,
36
39
  serializeCueSeen,
37
40
  serializeTabOrder,
41
+ statusPhrase,
42
+ tabAccessibleName,
38
43
  } from "./model.js";
44
+ import { browserNotifierEnv, createNotifier } from "./notify.js";
39
45
  import {
40
46
  TAB_HTML,
41
47
  kbButtonHTML,
42
48
  newButtonHTML,
49
+ paintProgress,
43
50
  paintStatusDot,
44
51
  pick,
45
52
  switchButtonHTML,
@@ -472,6 +479,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
472
479
  const swInner = pick(switcher, ".wt-switcher-current-inner");
473
480
  const swDot = pick(switcher, ".wt-switcher-dot");
474
481
  const swLabel = pick(switcher, ".wt-switcher-label");
482
+ // The active row's determinate progress bar. Scoped to the current chip:
483
+ // every expanded list row carries a .wt-progress-bar of its own, so an
484
+ // unscoped pick would be ambiguous the moment the list is populated.
485
+ const swProgress = pick(switcher, ".wt-switcher-current .wt-progress-bar");
475
486
  const swClose = pick(switcher, ".wt-switcher-current-close");
476
487
  // The active-tab elements that translate together during a horizontal
477
488
  // swipe: the content (dot + label) and the close (x). Moving both keeps the
@@ -491,8 +502,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
491
502
  // Latest-wins notification state for the switch button's dot: overwritten
492
503
  // by each qualifying background-tab event (applyStatus) and cleared when
493
504
  // the list opens (expandSwitcher) or the raising tab is visited or closed
494
- // (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden.
495
- let switchNotify: "" | "input" | "done" = "";
505
+ // (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden. The
506
+ // cue-worthy set is CueStatus (input / done / crashed — the states that
507
+ // want the user), declared once in model.ts beside isCueStatus.
508
+ let switchNotify: "" | CueStatus = "";
496
509
  // The session that raised the pending cue, so arriving on that tab (a
497
510
  // swipe or any switch) acknowledges it without opening the list.
498
511
  let switchNotifyId: string | null = null;
@@ -535,15 +548,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
535
548
  writeCueSeen(cueSeen);
536
549
  }
537
550
  }
551
+ // OSC 9 Form B notifications: the one signal the specs say to surface
552
+ // OUTSIDE the page ("post a notification"), so a finished turn can reach a
553
+ // user who is looking at another app. All the policy — the suppression
554
+ // rule, the gesture-gated permission request, the silent tab-only
555
+ // degradation — lives in notify.ts; this feature only feeds it events and
556
+ // gestures.
557
+ const notifier = createNotifier(browserNotifierEnv());
538
558
  function paintSwitchDot(): void {
539
559
  // Reuse the per-tab status-dot colours (single source, css/05-tabs.css
540
- // .wt-status-dot[data-status="input"|"done"]) instead of re-declaring
541
- // them: the dot has the .wt-status-dot class, so data-status colours it
542
- // exactly like the tabs' own dots.
560
+ // .wt-status-dot[data-status="input"|"done"|"crashed"]) instead of
561
+ // re-declaring them: the dot has the .wt-status-dot class, so data-status
562
+ // colours it exactly like the tabs' own dots. Its tooltip comes from the
563
+ // same wording map as theirs, so the aggregate cue names the state it is
564
+ // showing rather than being a coloured dot with no explanation.
543
565
  if (switchNotify === "") {
544
566
  delete swSwitchDot.dataset["status"];
567
+ swSwitchDot.removeAttribute("title");
545
568
  } else {
546
569
  swSwitchDot.dataset["status"] = switchNotify;
570
+ swSwitchDot.title = statusPhrase(switchNotify);
547
571
  }
548
572
  }
549
573
  function clearSwitchNotify(): void {
@@ -707,6 +731,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
707
731
  // same window title, or two tabs whose last submitted line was identical),
708
732
  // the second and later get a " (k)" suffix in creation order, so the strip
709
733
  // never shows two identical labels.
734
+ //
735
+ // The OSC 9;4 percentage prefix ("78% · one") is applied HERE, at render
736
+ // time, and stored nowhere: `display` stays the plain de-duplicated label,
737
+ // so de-duplication compares real names, the rename field opens on the real
738
+ // name, `list()` reports the real name, and clearing the progress needs no
739
+ // cleanup — the next paint simply stops adding it.
710
740
  function relabelAll(): void {
711
741
  // Count only real (non-fallback) labels, so multiple untitled tabs all
712
742
  // read "New tab" without a numeric suffix.
@@ -730,10 +760,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
730
760
  }
731
761
  t.display = display;
732
762
  t.label.textContent = display;
733
- t.aria.setLabel(display);
763
+ // The accessible name carries the state and the percentage as well as
764
+ // the label: the dots are aria-hidden decoration and the progress bar
765
+ // is a 2px line with no text, so without this a screen-reader user
766
+ // cannot tell a working tab from a crashed one, or hear how far along
767
+ // a determinate one is.
768
+ t.aria.setLabel(tabAccessibleName(display, statusOf(t), shownProgress(t)));
734
769
  }
735
770
  }
736
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 unless the current status is
781
+ * one the progress channel owns. Those are the only two ways a percentage
782
+ * stops showing — the program's own OSC 9;4;0 (an explicit -1) and a status
783
+ * from another channel; see renderedProgress for why there is no third. */
784
+ function shownProgress(t: Tab): number {
785
+ return renderedProgress(statusOf(t), t.progress);
786
+ }
787
+
737
788
  const termInput = (): HTMLElement | null =>
738
789
  ctx.surface().querySelector<HTMLElement>(".term-input");
739
790
  function focusInput(): void {
@@ -826,7 +877,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
826
877
  const idx = tabList.findIndex((t) => t.id === activeId);
827
878
  const active = idx >= 0 ? tabList[idx] : undefined;
828
879
  swLabel.textContent = active ? active.display : "";
829
- paintStatusDot(swDot, active?.dot.dataset["status"] ?? "idle", active?.reports ?? false);
880
+ paintStatusDot(swDot, active ? statusOf(active) : "idle", active?.reports ?? false);
881
+ paintProgress(swProgress, active ? shownProgress(active) : PROGRESS_ABSENT);
830
882
  // The aggregate background-notification cue rides the dedicated switch
831
883
  // button's dot (paintSwitchDot), not the active surface (it did not fit
832
884
  // there).
@@ -847,13 +899,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
847
899
  });
848
900
  return row;
849
901
  }
850
- // 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).
851
904
  function updateRow(row: HTMLElement, t: Tab): void {
852
- paintStatusDot(
853
- pick(row, ".wt-switcher-row-dot"),
854
- t.dot.dataset["status"] ?? "idle",
855
- t.reports,
856
- );
905
+ paintStatusDot(pick(row, ".wt-switcher-row-dot"), statusOf(t), t.reports);
906
+ paintProgress(pick(row, ".wt-progress-bar"), shownProgress(t));
857
907
  pick(row, ".wt-switcher-row-label").textContent = t.display;
858
908
  }
859
909
  // renderSwitcherList reconciles the expanded list to a row per OTHER tab
@@ -1070,6 +1120,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1070
1120
  persistOrder();
1071
1121
  }
1072
1122
 
1123
+ // The percentage is deliberately NOT written into the browser document
1124
+ // title. ConEmu's spec names the taskbar/title as a display site, and a
1125
+ // page's document title is the nearest analogue — but a page has exactly
1126
+ // ONE title while this UI multiplexes many sessions, so any rule for
1127
+ // choosing whose percentage it shows is arbitrary. Even restricted to the
1128
+ // active tab it churns a surface that doubles as the browser-tab label and
1129
+ // the bookmark name, and it invites reading one session's progress as the
1130
+ // window's. The per-chip prefix carries the same information without the
1131
+ // conflict, because each chip shows its own session. Do not add it back.
1132
+
1073
1133
  // persistOrder stores the strip's current arrangement so it survives a
1074
1134
  // reload. It hangs off syncChrome — the one function every list mutation
1075
1135
  // already ends with (reorder, create, adopt, close, reconcile) — rather than
@@ -1197,12 +1257,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1197
1257
  }
1198
1258
  }
1199
1259
 
1200
- function addTabChrome(info: SessionInfo): Tab {
1260
+ function addTabChrome(info: StatusRecord): Tab {
1201
1261
  const el = fromHTML(TAB_HTML);
1202
1262
  const label = el.querySelector<HTMLElement>(".wt-tab-label");
1203
1263
  const dot = el.querySelector<HTMLElement>(".wt-tab-dot");
1264
+ const progressEl = el.querySelector<HTMLElement>(".wt-progress-bar");
1204
1265
  const close = el.querySelector<HTMLButtonElement>(".wt-tab-close");
1205
- if (!label || !dot || !close) {
1266
+ if (!label || !dot || !progressEl || !close) {
1206
1267
  throw new Error("web-terminal-ui: tab chrome missing parts");
1207
1268
  }
1208
1269
  paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
@@ -1232,16 +1293,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1232
1293
  el,
1233
1294
  label,
1234
1295
  dot,
1296
+ progressEl,
1297
+ // A percentage exists only on the status STREAM, so a tab adopted from
1298
+ // the REST list starts with none; the first status event fills it in.
1299
+ progress: normalizeProgress(info.progressValue),
1235
1300
  aria,
1236
1301
  scrollTop: 0,
1237
1302
  following: true,
1238
1303
  reports: reportsOf(info.reportsActivity),
1239
1304
  };
1305
+ paintProgress(progressEl, renderedProgress(info.status, tab.progress));
1240
1306
  // Set an initial label immediately (relabelAll refines it with de-dup
1241
1307
  // once the tab is in tabList and syncChrome runs).
1242
1308
  tab.display = baseLabel(tab).text;
1243
1309
  label.textContent = tab.display;
1244
- aria.setLabel(tab.display);
1310
+ aria.setLabel(
1311
+ tabAccessibleName(tab.display, info.status, renderedProgress(info.status, tab.progress)),
1312
+ );
1245
1313
  el.addEventListener("click", (e) => {
1246
1314
  if ((e.target as HTMLElement).closest(".wt-tab-close")) {
1247
1315
  return; // handled by the close button
@@ -1716,14 +1784,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1716
1784
  // stream / poll adopt the existing sessions below; without this they would
1717
1785
  // render inert (blank, never connecting) until the user taps a tab. This
1718
1786
  // is the sibling of the '+'-retry recovery the bootstrap already handles.
1719
- // A live tab outranks an exited one (its dot status is fed by the same
1787
+ // A live tab outranks an ENDED one (its dot status is fed by the same
1720
1788
  // SSE/poll that adopted it); a corpse is only auto-activated when nothing
1721
- // else exists.
1789
+ // else exists. "Ended" covers both ways a process goes (exited and
1790
+ // crashed) — a crashed session is exactly as unable to produce output as a
1791
+ // cleanly exited one, so activating it would wedge the page the same way.
1722
1792
  function ensureActive(): void {
1723
1793
  if (activeId !== null) {
1724
1794
  return;
1725
1795
  }
1726
- const first = tabList.find((t) => t.dot.dataset["status"] !== STATUS_EXITED) ?? tabList[0];
1796
+ const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
1727
1797
  if (first) {
1728
1798
  switchTo(first.id);
1729
1799
  }
@@ -1831,6 +1901,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1831
1901
  // sit in storage forever.
1832
1902
  acknowledgeSwitchNotify(id);
1833
1903
  forgetCueSeen(id);
1904
+ notifier.forget(id); // no more notifications can arrive for a gone session
1834
1905
  tab.aria.remove();
1835
1906
  // Remove immediately (no exit animation): a lingering element made the
1836
1907
  // "+" teleport after a delay, and made a last-tab replacement appear in
@@ -2212,12 +2283,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2212
2283
  (e) => {
2213
2284
  menuTouch = e.pointerType === "touch";
2214
2285
  noteChromePress();
2286
+ notifier.gesture();
2215
2287
  },
2216
2288
  { passive: true },
2217
2289
  );
2218
2290
  // The switcher's half of that snapshot (its rows and the x are buttons, so
2219
2291
  // pressing one focuses it and blurs the terminal exactly as a chip does).
2220
- switcher.addEventListener("pointerdown", noteChromePress, { passive: true });
2292
+ // It is also the mobile half of the notification-permission gesture.
2293
+ switcher.addEventListener(
2294
+ "pointerdown",
2295
+ () => {
2296
+ noteChromePress();
2297
+ notifier.gesture();
2298
+ },
2299
+ { passive: true },
2300
+ );
2221
2301
  function hideTabMenu(): void {
2222
2302
  tabMenu.classList.remove("visible");
2223
2303
  tabMenu.replaceChildren();
@@ -2329,13 +2409,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2329
2409
  // one, and its title fields are interchangeable strings that as positional
2330
2410
  // parameters would be trivially swappable. reports is the server's sticky reportsActivity flag, passed
2331
2411
  // separately because it is normalised (reportsOf) before it gets here.
2332
- function applyStatus(rec: SessionInfo, reports: boolean): void {
2412
+ function applyStatus(rec: StatusRecord, reports: boolean): void {
2333
2413
  const t = tabList.find((tab) => tab.id === rec.id);
2334
2414
  if (!t) {
2335
2415
  return;
2336
2416
  }
2337
2417
  t.reports = reports;
2338
2418
  paintStatusDot(t.dot, rec.status, reports);
2419
+ // The OSC 9;4 percentage. Only an explicitly PRESENT value updates the
2420
+ // tab: the polling fallback lists SessionInfo, which carries no
2421
+ // percentage at all, and reading that absence as "cleared" would blank a
2422
+ // live bar on every poll tick. The spec's own clear (OSC 9;4;0, or the
2423
+ // abbreviated form) arrives as an explicit -1; the only other clear is
2424
+ // the process ending, applied at render time (renderedProgress). No
2425
+ // timer, and nothing special about 100%.
2426
+ if (rec.progressValue !== undefined) {
2427
+ t.progress = normalizeProgress(rec.progressValue);
2428
+ }
2429
+ paintProgress(t.progressEl, shownProgress(t));
2430
+ // A session that reports activity is a session whose program speaks OSC 9,
2431
+ // so it is also one that may post a notification: arm the permission
2432
+ // request for the next user gesture (see notify.ts).
2433
+ if (reports) {
2434
+ notifier.arm();
2435
+ }
2339
2436
  // Latest-wins background-tab notification for the switch button's dot: a
2340
2437
  // background tab (not the active one) reaching "input" (needs you) or
2341
2438
  // "done" (turn finished) raises the cue in that colour; each qualifying
@@ -2468,6 +2565,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2468
2565
  applyStatus(s, reportsOf(s.reportsActivity));
2469
2566
  ensureActive();
2470
2567
  syncChrome();
2568
+ // A notification is an EVENT the engine delivers once, on the sweep
2569
+ // that first observes it, so it is handled here rather than in
2570
+ // applyStatus (which also runs on re-delivered STATE). Delivered after
2571
+ // syncChrome so the notification's title is the label the user would
2572
+ // see. The status record is the notification's carrier whether or not
2573
+ // the server has a classifier installed.
2574
+ notifier.deliver(s, {
2575
+ sessionIsActive: s.id === activeId,
2576
+ label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
2577
+ // What clicking the notification does. The platform's own click
2578
+ // default focuses the page; this supplies the other half, landing the
2579
+ // user on the session that raised the notification rather than on
2580
+ // whichever tab they last left active. switchTo already no-ops on an
2581
+ // id that is gone, which is the case that matters here: the prompt may
2582
+ // have been answered, or the tab closed, between post and click.
2583
+ activate: () => {
2584
+ switchTo(s.id);
2585
+ },
2586
+ });
2471
2587
  });
2472
2588
  offStreamOpen = monitor.onStreamOpen?.(() => {
2473
2589
  void reconcileOnce();
@@ -3001,13 +3117,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3001
3117
  } catch {
3002
3118
  sessions = [];
3003
3119
  }
3004
- // Spawn a fresh session unless a LIVE one is listed. An exited session
3005
- // is viewable history, not a working terminal — booting a page whose
3006
- // every session has died (the agent exited: a sign-in dead end, a
3007
- // crash) onto a corpse was the stuck-loading wedge. The exited ones are
3008
- // still adopted below (switch to them to read their final screen; close
3009
- // them by hand).
3010
- if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
3120
+ // Spawn a fresh session unless a LIVE one is listed. An ended session
3121
+ // (exited cleanly OR crashed) is viewable history, not a working
3122
+ // terminal — booting a page whose every session has died (the agent
3123
+ // exited: a sign-in dead end, a crash) onto a corpse was the
3124
+ // stuck-loading wedge. The dead ones are still adopted below (switch to
3125
+ // them to read their final screen; close them by hand).
3126
+ if (!sessions.some((s) => !isEndedStatus(s.status))) {
3011
3127
  try {
3012
3128
  sessions = [...sessions, await createSessionHonouringRetry(api, ctx, () => tornDown)];
3013
3129
  } catch (err) {
@@ -3047,15 +3163,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3047
3163
  // Activate the previously-active session if it still exists, else the
3048
3164
  // first (oldest). Session ids are stable server-side, so a page reload
3049
3165
  // reconnects to the tab the user left on instead of always the oldest.
3050
- // Live sessions outrank exited ones: the saved id is honored only while
3051
- // its session is still live (a reload used to restore straight onto the
3052
- // corpse of a died-while-away session and wedge there), and the default
3053
- // is the oldest LIVE tab. Only when nothing is live (the fresh-spawn
3054
- // above failed too) does an exited tab start — a frozen final screen
3055
- // with the "Session ended" banner beats a blank page.
3056
- const liveIds = new Set(
3057
- sessions.filter((s) => s.status !== STATUS_EXITED).map((s) => s.id),
3058
- );
3166
+ // Live sessions outrank ended ones (exited or crashed): the saved id is
3167
+ // honored only while its session is still live (a reload used to restore
3168
+ // straight onto the corpse of a died-while-away session and wedge
3169
+ // there), and the default is the oldest LIVE tab. Only when nothing is
3170
+ // live (the fresh-spawn above failed too) does a dead tab start — a
3171
+ // frozen final screen with the "Session ended" banner beats a blank
3172
+ // page.
3173
+ const liveIds = new Set(sessions.filter((s) => !isEndedStatus(s.status)).map((s) => s.id));
3059
3174
  const oldestLive = tabList.find((t) => liveIds.has(t.id));
3060
3175
  let startTab = oldestLive ?? tabList[0];
3061
3176
  try {