@cplieger/web-terminal-ui 5.0.3 → 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.3",
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"
@@ -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,25 @@ import {
29
29
  createTombstones,
30
30
  hasPinnedName,
31
31
  isCueStatus,
32
+ isEndedStatus,
33
+ normalizeProgress,
32
34
  orderedInsertIndex,
33
35
  parseCueSeen,
34
36
  parseTabOrder,
37
+ progressLabel,
38
+ renderedProgress,
35
39
  sanitizePinnedName,
36
40
  serializeCueSeen,
37
41
  serializeTabOrder,
42
+ statusPhrase,
43
+ tabAccessibleName,
38
44
  } from "./model.js";
45
+ import { browserNotifierEnv, createNotifier } from "./notify.js";
39
46
  import {
40
47
  TAB_HTML,
41
48
  kbButtonHTML,
42
49
  newButtonHTML,
50
+ paintProgress,
43
51
  paintStatusDot,
44
52
  pick,
45
53
  switchButtonHTML,
@@ -472,6 +480,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
472
480
  const swInner = pick(switcher, ".wt-switcher-current-inner");
473
481
  const swDot = pick(switcher, ".wt-switcher-dot");
474
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");
475
487
  const swClose = pick(switcher, ".wt-switcher-current-close");
476
488
  // The active-tab elements that translate together during a horizontal
477
489
  // swipe: the content (dot + label) and the close (x). Moving both keeps the
@@ -491,8 +503,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
491
503
  // Latest-wins notification state for the switch button's dot: overwritten
492
504
  // by each qualifying background-tab event (applyStatus) and cleared when
493
505
  // 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" = "";
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 = "";
496
510
  // The session that raised the pending cue, so arriving on that tab (a
497
511
  // swipe or any switch) acknowledges it without opening the list.
498
512
  let switchNotifyId: string | null = null;
@@ -535,15 +549,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
535
549
  writeCueSeen(cueSeen);
536
550
  }
537
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());
538
559
  function paintSwitchDot(): void {
539
560
  // 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.
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.
543
566
  if (switchNotify === "") {
544
567
  delete swSwitchDot.dataset["status"];
568
+ swSwitchDot.removeAttribute("title");
545
569
  } else {
546
570
  swSwitchDot.dataset["status"] = switchNotify;
571
+ swSwitchDot.title = statusPhrase(switchNotify);
547
572
  }
548
573
  }
549
574
  function clearSwitchNotify(): void {
@@ -707,6 +732,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
707
732
  // same window title, or two tabs whose last submitted line was identical),
708
733
  // the second and later get a " (k)" suffix in creation order, so the strip
709
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.
710
741
  function relabelAll(): void {
711
742
  // Count only real (non-fallback) labels, so multiple untitled tabs all
712
743
  // read "New tab" without a numeric suffix.
@@ -729,11 +760,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
729
760
  }
730
761
  }
731
762
  t.display = display;
732
- t.label.textContent = display;
733
- 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)));
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 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
+
737
788
  const termInput = (): HTMLElement | null =>
738
789
  ctx.surface().querySelector<HTMLElement>(".term-input");
739
790
  function focusInput(): void {
@@ -825,8 +876,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
825
876
  function syncMobile(): void {
826
877
  const idx = tabList.findIndex((t) => t.id === activeId);
827
878
  const active = idx >= 0 ? tabList[idx] : undefined;
828
- swLabel.textContent = active ? active.display : "";
829
- 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);
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,14 +899,15 @@ 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,
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),
856
910
  );
857
- pick(row, ".wt-switcher-row-label").textContent = t.display;
858
911
  }
859
912
  // renderSwitcherList reconciles the expanded list to a row per OTHER tab
860
913
  // (the active tab lives in the bar row), REUSING existing row elements
@@ -1070,6 +1123,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1070
1123
  persistOrder();
1071
1124
  }
1072
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
+
1073
1136
  // persistOrder stores the strip's current arrangement so it survives a
1074
1137
  // reload. It hangs off syncChrome — the one function every list mutation
1075
1138
  // already ends with (reorder, create, adopt, close, reconcile) — rather than
@@ -1197,12 +1260,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1197
1260
  }
1198
1261
  }
1199
1262
 
1200
- function addTabChrome(info: SessionInfo): Tab {
1263
+ function addTabChrome(info: StatusRecord): Tab {
1201
1264
  const el = fromHTML(TAB_HTML);
1202
1265
  const label = el.querySelector<HTMLElement>(".wt-tab-label");
1203
1266
  const dot = el.querySelector<HTMLElement>(".wt-tab-dot");
1267
+ const progressEl = el.querySelector<HTMLElement>(".wt-progress-bar");
1204
1268
  const close = el.querySelector<HTMLButtonElement>(".wt-tab-close");
1205
- if (!label || !dot || !close) {
1269
+ if (!label || !dot || !progressEl || !close) {
1206
1270
  throw new Error("web-terminal-ui: tab chrome missing parts");
1207
1271
  }
1208
1272
  paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
@@ -1232,16 +1296,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1232
1296
  el,
1233
1297
  label,
1234
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),
1235
1303
  aria,
1236
1304
  scrollTop: 0,
1237
1305
  following: true,
1238
1306
  reports: reportsOf(info.reportsActivity),
1239
1307
  };
1308
+ paintProgress(progressEl, renderedProgress(info.status, tab.progress));
1240
1309
  // Set an initial label immediately (relabelAll refines it with de-dup
1241
1310
  // once the tab is in tabList and syncChrome runs).
1242
1311
  tab.display = baseLabel(tab).text;
1243
- label.textContent = tab.display;
1244
- aria.setLabel(tab.display);
1312
+ label.textContent = progressLabel(tab.display, renderedProgress(info.status, tab.progress));
1313
+ aria.setLabel(tabAccessibleName(label.textContent, info.status));
1245
1314
  el.addEventListener("click", (e) => {
1246
1315
  if ((e.target as HTMLElement).closest(".wt-tab-close")) {
1247
1316
  return; // handled by the close button
@@ -1716,14 +1785,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1716
1785
  // stream / poll adopt the existing sessions below; without this they would
1717
1786
  // render inert (blank, never connecting) until the user taps a tab. This
1718
1787
  // 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
1788
+ // A live tab outranks an ENDED one (its dot status is fed by the same
1720
1789
  // SSE/poll that adopted it); a corpse is only auto-activated when nothing
1721
- // 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.
1722
1793
  function ensureActive(): void {
1723
1794
  if (activeId !== null) {
1724
1795
  return;
1725
1796
  }
1726
- const first = tabList.find((t) => t.dot.dataset["status"] !== STATUS_EXITED) ?? tabList[0];
1797
+ const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
1727
1798
  if (first) {
1728
1799
  switchTo(first.id);
1729
1800
  }
@@ -1831,6 +1902,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1831
1902
  // sit in storage forever.
1832
1903
  acknowledgeSwitchNotify(id);
1833
1904
  forgetCueSeen(id);
1905
+ notifier.forget(id); // no more notifications can arrive for a gone session
1834
1906
  tab.aria.remove();
1835
1907
  // Remove immediately (no exit animation): a lingering element made the
1836
1908
  // "+" teleport after a delay, and made a last-tab replacement appear in
@@ -2212,12 +2284,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2212
2284
  (e) => {
2213
2285
  menuTouch = e.pointerType === "touch";
2214
2286
  noteChromePress();
2287
+ notifier.gesture();
2215
2288
  },
2216
2289
  { passive: true },
2217
2290
  );
2218
2291
  // The switcher's half of that snapshot (its rows and the x are buttons, so
2219
2292
  // pressing one focuses it and blurs the terminal exactly as a chip does).
2220
- 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
+ );
2221
2302
  function hideTabMenu(): void {
2222
2303
  tabMenu.classList.remove("visible");
2223
2304
  tabMenu.replaceChildren();
@@ -2329,13 +2410,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2329
2410
  // one, and its title fields are interchangeable strings that as positional
2330
2411
  // parameters would be trivially swappable. reports is the server's sticky reportsActivity flag, passed
2331
2412
  // separately because it is normalised (reportsOf) before it gets here.
2332
- function applyStatus(rec: SessionInfo, reports: boolean): void {
2413
+ function applyStatus(rec: StatusRecord, reports: boolean): void {
2333
2414
  const t = tabList.find((tab) => tab.id === rec.id);
2334
2415
  if (!t) {
2335
2416
  return;
2336
2417
  }
2337
2418
  t.reports = reports;
2338
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
+ }
2339
2437
  // Latest-wins background-tab notification for the switch button's dot: a
2340
2438
  // background tab (not the active one) reaching "input" (needs you) or
2341
2439
  // "done" (turn finished) raises the cue in that colour; each qualifying
@@ -2468,6 +2566,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2468
2566
  applyStatus(s, reportsOf(s.reportsActivity));
2469
2567
  ensureActive();
2470
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
+ });
2471
2579
  });
2472
2580
  offStreamOpen = monitor.onStreamOpen?.(() => {
2473
2581
  void reconcileOnce();
@@ -3001,13 +3109,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3001
3109
  } catch {
3002
3110
  sessions = [];
3003
3111
  }
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)) {
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))) {
3011
3119
  try {
3012
3120
  sessions = [...sessions, await createSessionHonouringRetry(api, ctx, () => tornDown)];
3013
3121
  } catch (err) {
@@ -3047,15 +3155,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3047
3155
  // Activate the previously-active session if it still exists, else the
3048
3156
  // first (oldest). Session ids are stable server-side, so a page reload
3049
3157
  // 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
- );
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));
3059
3166
  const oldestLive = tabList.find((t) => liveIds.has(t.id));
3060
3167
  let startTab = oldestLive ?? tabList[0];
3061
3168
  try {
@@ -14,11 +14,157 @@ import type { TabHandle } from "../../kernel/types.js";
14
14
  // contract has one home. Type-only import: erases at compile, no runtime dep.
15
15
  export type { SessionInfo };
16
16
 
17
+ /** A status record as the tabs feature consumes it: the session's REST wire
18
+ * shape plus the percentage that exists only on the status STREAM
19
+ * (SessionStatus.progressValue). Both status sources flow through one code path
20
+ * this way, and the optionality is meaningful: the polling fallback lists
21
+ * SessionInfo with no percentage at all, which means "no information" and must
22
+ * not be read as "the percentage was cleared". */
23
+ export type StatusRecord = SessionInfo & { readonly progressValue?: number };
24
+
25
+ // The status vocabulary, module-private on purpose: every consumer asks one of
26
+ // the predicates below (isEndedStatus / statusRevealsDot / isCueStatus /
27
+ // statusPhrase) instead of comparing strings itself, so a status's
28
+ // MEANING has exactly one home. Adding a status means adding it to the
29
+ // predicates that should include it, not to a comparison at each call site.
30
+ //
17
31
  // The server-side status of a session whose process has exited (mirrors the
18
32
  // engine's terminal.StatusExited). Such a session is viewable history — its
19
33
  // final screen replays and the kernel shows "Session ended" — but it can never
20
34
  // produce output again, so session selection prefers live sessions everywhere.
21
- export const STATUS_EXITED = "exited";
35
+ const STATUS_EXITED = "exited";
36
+
37
+ /** The other end-of-process status (engine terminal.StatusCrashed): a non-zero
38
+ * exit status, or a terminating signal the program was not asked for. A
39
+ * server-initiated end (a closed tab, the idle reaper, a server shutdown) is
40
+ * reported as "exited", so a routine restart never paints as a failure. Like
41
+ * "exited" it is terminal: nothing clears it, and it outranks every progress
42
+ * state. */
43
+ const STATUS_CRASHED = "crashed";
44
+
45
+ /** OSC 9;4 progress state 2, the error state (iTerm2 semantics). A STATE, not an
46
+ * event: it persists until the program reports another progress state, or the
47
+ * process dies (which outranks it). */
48
+ const STATUS_FAILED = "failed";
49
+
50
+ /** OSC 9;4 progress state 4, the warning state (iTerm2 semantics; ConEmu calls
51
+ * the same state paused). Same persistence rules as STATUS_FAILED. */
52
+ const STATUS_WARNING = "warning";
53
+
54
+ /** isEndedStatus reports whether a status means the session's process is GONE,
55
+ * whichever way it went. Session selection asks this — not `=== "exited"` —
56
+ * because a crashed session is exactly as dead as an exited one: reloading onto
57
+ * either is the stuck-loading wedge the bootstrap's live-session preference
58
+ * exists to avoid. */
59
+ export function isEndedStatus(status: string): boolean {
60
+ return status === STATUS_EXITED || status === STATUS_CRASHED;
61
+ }
62
+
63
+ /** The statuses whose dot must be visible even if the server never set the
64
+ * session's sticky reportsActivity flag. `warning` and `failed` are OSC 9;4
65
+ * states, so the flag is set for them in practice and this only floors it;
66
+ * `crashed` is the case that needs it — a plain shell that dies badly has
67
+ * reported no activity in its life, and its red dot is the one thing the user
68
+ * most needs to see. An ordinary `exited` deliberately stays gated: a clean end
69
+ * is not news. */
70
+ export function statusRevealsDot(status: string): boolean {
71
+ return status === STATUS_WARNING || status === STATUS_FAILED || status === STATUS_CRASHED;
72
+ }
73
+
74
+ /** statusPhrase is the human wording for a status, used for BOTH the dot's hover
75
+ * tooltip and the suffix in a tab's accessible name — one definition, so what a
76
+ * sighted user reads on hover and what a screen reader announces cannot
77
+ * disagree. An unknown status (a newer server: the wire is parsed, not
78
+ * validated) falls back to the raw value rather than being hidden. */
79
+ export function statusPhrase(status: string): string {
80
+ switch (status) {
81
+ case "working":
82
+ return "working";
83
+ case STATUS_WARNING:
84
+ return "warning reported";
85
+ case STATUS_FAILED:
86
+ return "error reported";
87
+ case "input":
88
+ return "waiting for you";
89
+ case "done":
90
+ return "turn finished";
91
+ case STATUS_EXITED:
92
+ return "session ended";
93
+ case STATUS_CRASHED:
94
+ return "process crashed";
95
+ case "idle":
96
+ case "":
97
+ return "idle";
98
+ default:
99
+ return status;
100
+ }
101
+ }
102
+
103
+ /** PROGRESS_ABSENT is "no percentage" (the engine's own marker). Absence is -1,
104
+ * never 0: a session that reported nothing is not a session at 0%, and the two
105
+ * must render differently (no bar at all vs an empty bar). */
106
+ export const PROGRESS_ABSENT = -1;
107
+
108
+ /** normalizeProgress cleans a percentage off the wire into PROGRESS_ABSENT or
109
+ * 0-100. The engine already clamps, so this is the untrusted-JSON guard: a
110
+ * string, a NaN, an Infinity or a negative all mean "absent" rather than
111
+ * throwing or rendering a nonsense bar, and an out-of-range high value clamps
112
+ * rather than overflowing the chip. */
113
+ export function normalizeProgress(value: unknown): number {
114
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
115
+ return PROGRESS_ABSENT;
116
+ }
117
+ return Math.min(100, Math.round(value));
118
+ }
119
+
120
+ /** renderedProgress is the percentage a status may actually SHOW. The tab keeps
121
+ * the last value the server reported; this is the one place that decides whether
122
+ * it is still meaningful, so the clearing rule has a single home.
123
+ *
124
+ * There are exactly TWO clears, and both are honest — neither invents a state
125
+ * change the program never made:
126
+ *
127
+ * 1. The program's own clear: OSC 9;4;0, or the abbreviated OSC 9;4, which the
128
+ * engine reports as percentage -1. That is the spec path, and the value
129
+ * arriving as PROGRESS_ABSENT is all it takes.
130
+ * 2. The process is GONE (exited or crashed) — checked here. A dead process's
131
+ * progress is meaningless, and nothing will ever clear it, so the bar and
132
+ * the title prefix drop with the process.
133
+ *
134
+ * There is deliberately NO third clear. 100% is not special: state 1 at 100 is a
135
+ * STATE that persists, and the progress channel carries no "done" signal at all
136
+ * (our `done` comes from the notification channel, a separate mechanism), so a
137
+ * program that pins 100 and goes quiet keeps its bar — the same "persists until
138
+ * the program says otherwise" property the indeterminate state already has.
139
+ * There is also no TIMEOUT: a timer would assert a change the program never
140
+ * reported. And a `done`/`input` latch does not clear it either, because a latch
141
+ * says something about the notification channel, not about the progress the
142
+ * program last reported. (In practice this is not a stale-bar risk for the one
143
+ * agent that drives it: kiro-cli never sends state 1, and sends state 0 when it
144
+ * goes idle.) */
145
+ export function renderedProgress(status: string, progress: number): number {
146
+ return isEndedStatus(status) ? PROGRESS_ABSENT : progress;
147
+ }
148
+
149
+ /** progressLabel prefixes a rendered label with its percentage ("78% · one").
150
+ *
151
+ * Applied at RENDER time and never stored: `Tab.title`, `Tab.pinnedTitle` and
152
+ * `Tab.display` stay the plain name, so a manual rename round-trips unprefixed,
153
+ * an automatic title is untouched, de-duplication still compares real labels,
154
+ * and clearing the progress needs no cleanup — the next paint simply stops
155
+ * adding the prefix. */
156
+ export function progressLabel(display: string, progress: number): string {
157
+ return progress < 0 ? display : `${String(progress)}% · ${display}`;
158
+ }
159
+
160
+ /** tabAccessibleName is a tab's announced name: its rendered label plus the
161
+ * session's state. The dots are aria-hidden="true" (they are decoration with no
162
+ * text), so before this the status was invisible to a screen reader — a fact a
163
+ * red crashed state makes worse. This deliberately changes the announced text
164
+ * for the existing states too. */
165
+ export function tabAccessibleName(renderedLabel: string, status: string): string {
166
+ return `${renderedLabel} — ${statusPhrase(status)}`;
167
+ }
22
168
 
23
169
  // localStorage key for the last active session id, so a page reload reopens the
24
170
  // tab the user left on rather than always defaulting to the oldest one.
@@ -87,15 +233,20 @@ export function serializeTabOrder(ids: readonly string[]): string {
87
233
  return JSON.stringify(ids.slice(0, MAX_PERSISTED_TAB_ORDER));
88
234
  }
89
235
 
90
- /** The two session statuses that raise the mobile switcher's aggregate attention
91
- * cue: a background terminal blocked on the user ("input") or one whose turn
92
- * finished ("done"). Declared once here so the raise test and the
93
- * acknowledgement store cannot disagree about which statuses are cue-worthy. */
94
- export type CueStatus = "input" | "done";
236
+ /** The session statuses that raise the mobile switcher's aggregate attention
237
+ * cue. The rule is "only states that want the user — it's like a notification":
238
+ * a background terminal blocked on the user ("input"), one whose turn finished
239
+ * ("done"), and one whose process died badly ("crashed"). The three animated
240
+ * progress states are deliberately excluded — working / warning / failed are
241
+ * ongoing and informational, and an animated dot pinned to the switch button
242
+ * would nag with nothing to act on. Declared once here so the raise test and
243
+ * the acknowledgement store cannot disagree about which statuses are
244
+ * cue-worthy. */
245
+ export type CueStatus = "input" | "done" | "crashed";
95
246
 
96
247
  /** isCueStatus narrows a raw server status to a cue-worthy one. */
97
248
  export function isCueStatus(status: string): status is CueStatus {
98
- return status === "input" || status === "done";
249
+ return status === "input" || status === "done" || status === STATUS_CRASHED;
99
250
  }
100
251
 
101
252
  /** localStorage key for the cues this viewer has already SEEN: session id -> the
@@ -221,6 +372,14 @@ export interface Tab {
221
372
  el: HTMLElement;
222
373
  label: HTMLElement;
223
374
  dot: HTMLElement;
375
+ /** The chip's determinate progress bar (the 2px line on its bottom edge).
376
+ * Present on every chip, hidden until a percentage exists. */
377
+ progressEl: HTMLElement;
378
+ /** The last OSC 9;4 percentage the server reported for this session, or
379
+ * PROGRESS_ABSENT. Held raw: whether it is currently SHOWN is a render-time
380
+ * question (renderedProgress), and the prefix it produces is never stored on
381
+ * any title field. */
382
+ progress: number;
224
383
  aria: TabHandle;
225
384
  scrollTop: number;
226
385
  following: boolean;