@cplieger/web-terminal-ui 7.1.0 → 7.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.
package/README.md CHANGED
@@ -153,9 +153,25 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
153
153
  nowhere: no terminal emulator puts a percentage next to a tab label, and a chip
154
154
  that shrinks toward a 100px floor has no width to spare. It is likewise never
155
155
  written to the browser document title, since one page title cannot represent
156
- several sessions. An OSC 9 notification is posted as a browser
157
- notification when the user is not already looking at that terminal (permission
158
- is requested on a user gesture; a denial degrades silently to the tab dots).
156
+ several sessions.
157
+
158
+ Beside that dot, every chip carries a second mark for a host-reported
159
+ background activity that outlives a turn (a workflow run, in the
160
+ `web-terminal-kiro` case). It is a rounded-square ring rather than a disc, so
161
+ it does not read as another status dot, and band width carries its three
162
+ states: a 2px band with a breathing glow while a background task is running, a
163
+ 1px band with a halo while one is paused and resumable, and a 2px band with
164
+ that same halo while one is blocked on the user. The state and the count are
165
+ announced in the tab's accessible name and repeated as the mark's hover
166
+ tooltip. The mark is independent of the dot's reveal gate, so a tab can show
167
+ one and not the other, and it collapses to no width when there is nothing to
168
+ report: a chip with no background activity measures exactly as it did before
169
+ the mark existed. It draws from `--status-working` and `--status-input`, so
170
+ theming the activity-dot palette themes it too and no extra token is needed.
171
+
172
+ An OSC 9 notification is posted as a browser notification when the user is not
173
+ already looking at that terminal (permission is requested on a user gesture; a
174
+ denial degrades silently to the tab dots).
159
175
  Clicking one switches to the session that raised it: the Notifications API's
160
176
  own click default already focuses the page, and the handler supplies the
161
177
  in-page half. These are non-persistent notifications, so per the API the
package/css/30-tabs.css CHANGED
@@ -964,6 +964,120 @@
964
964
  border-color: var(--status-failed);
965
965
  }
966
966
 
967
+ /* ===== Secondary activity mark (a host's background-activity channel) =====
968
+ A SECOND per-tab mark, orthogonal to the activity dot beside it: the dot
969
+ reports the session's turn, this reports a background task that outlives one.
970
+ Three states (working / waiting / input) and it WITHDRAWS when there is none.
971
+
972
+ It is a rounded-SQUARE RING, never a filled disc and never a pictogram, and
973
+ that silhouette is the WCAG 1.4.1 channel: the round dot sits 8px away at all
974
+ three chip sites, and the two share a hue vocabulary (--status-input is input's
975
+ dot colour too), so shape is what separates them at 9px. Between the mark's OWN
976
+ three states the channel is BAND WIDTH, in a deliberate 2:1 ratio, because
977
+ waiting and input share one ink.
978
+
979
+ A literal 3px radius, not --radius-sm: the smallest token rung is 4px, which on
980
+ a 9px box reads as a circle and defeats the silhouette.
981
+
982
+ It costs ZERO layout while no state is set. The footprint cancel is a negative
983
+ inline-start margin of exactly the box plus the chip's own flex gap, so the
984
+ label's box does not move when the element is added to a chip that has nothing
985
+ to report; setting data-activity zeroes the margin and the label shifts right by
986
+ that same amount. The gap term is per-site (31-switcher.css sets --sp-2 for the
987
+ two mobile chips), so it must stay a var() reference — a literal breaks whichever
988
+ site it does not match. box-sizing: border-box is global under .wt-root, so a
989
+ band change never moves layout either. */
990
+ :where(.wt-root) .wt-activity-mark {
991
+ --wt-mark-size: 9px;
992
+ --wt-mark-gap: var(--sp-1);
993
+ /* waiting and input both speak through this ink and separate by band alone;
994
+ working overrides it. */
995
+ --wt-mark-ink: var(--status-input);
996
+
997
+ width: var(--wt-mark-size);
998
+ height: var(--wt-mark-size);
999
+ flex: none;
1000
+ position: relative;
1001
+ border-radius: 3px;
1002
+ /* Zero-width at rest, so the ring is drawn entirely by a state's band. The
1003
+ colour rides --wt-mark-ink here rather than in each state, so a state sets
1004
+ one variable and one width. */
1005
+ border: 0 solid var(--wt-mark-ink);
1006
+ background: transparent;
1007
+ opacity: 0;
1008
+ /* While collapsed the box overlaps the status dot, and a title-less span over
1009
+ the dot would eat the dot's own hover tooltip. Restored with the state. */
1010
+ pointer-events: none;
1011
+ margin-inline-start: calc(-1 * (var(--wt-mark-size) + var(--wt-mark-gap)));
1012
+ /* Two lists, because the two directions are not symmetric. This one is the EXIT:
1013
+ the mark fades out first and the space closes behind it, so nothing is still on
1014
+ screen while the row reclaims its width. The ENTRY is on the [data-activity]
1015
+ rule below, and it is what the tuck exists for.
1016
+
1017
+ The space is carried by a LAYOUT property on purpose: no transform moves the
1018
+ label, and the label being pushed IS the reveal. Bounded to one flex line and
1019
+ one mark plus one gap, once per state change. */
1020
+ transition:
1021
+ opacity var(--dur-exit) var(--ease-exit),
1022
+ margin-inline-start var(--dur-standard) var(--ease-standard) var(--dur-exit);
1023
+ }
1024
+
1025
+ /* The ENTRY, the reverse of the tuck's list: the space opens, and the mark fades in
1026
+ after it on a delay equal to that same duration, so a reader sees the label make
1027
+ room and then the mark arrive in the room it made. */
1028
+ :where(.wt-root) .wt-activity-mark[data-activity] {
1029
+ margin-inline-start: 0;
1030
+ opacity: 1;
1031
+ pointer-events: auto;
1032
+ transition:
1033
+ margin-inline-start var(--dur-standard) var(--ease-standard),
1034
+ opacity var(--dur-enter) var(--ease-enter) var(--dur-standard);
1035
+ }
1036
+
1037
+ /* Working: the heavier bare band, plus a breathing glow. Its own compositing
1038
+ layer for the reason the status dot's rule records — a nested overlay layer
1039
+ snaps to device pixels independently of a fractional parent position, which at
1040
+ this size reads as an off-centre pulse. */
1041
+ :where(.wt-root) .wt-activity-mark[data-activity="working"] {
1042
+ --wt-mark-ink: var(--status-working);
1043
+
1044
+ border-width: 2px;
1045
+ transform: translateZ(0);
1046
+ }
1047
+
1048
+ /* The breath: a pre-painted radial animating OPACITY only, never colours, so the
1049
+ beat costs compositing rather than a repaint per frame for as long as a
1050
+ background task runs (the battery reason the dot's glow overlay records). */
1051
+ :where(.wt-root) .wt-activity-mark[data-activity="working"]::before {
1052
+ content: "";
1053
+ position: absolute;
1054
+ inset: -1px;
1055
+ border-radius: 4px;
1056
+ background: radial-gradient(
1057
+ closest-side,
1058
+ color-mix(in oklch, var(--wt-mark-ink) 78%, #fff) 0 70%,
1059
+ transparent 92%
1060
+ );
1061
+ opacity: 0;
1062
+ animation: wt-activity-breath 1.2s ease-in-out infinite;
1063
+ will-change: opacity;
1064
+ }
1065
+
1066
+ /* Waiting: the thin band plus a static halo. Stopped and resumable, nobody being
1067
+ asked — so no motion, and the lightest band of the three. */
1068
+ :where(.wt-root) .wt-activity-mark[data-activity="waiting"] {
1069
+ border-width: 1px;
1070
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--wt-mark-ink) 30%, transparent);
1071
+ }
1072
+
1073
+ /* Input: the same ink and the same halo as waiting, at TWICE its band. The pair
1074
+ is the only place two states share one ink, which is why the ratio is 2:1
1075
+ rather than a hairline apart. */
1076
+ :where(.wt-root) .wt-activity-mark[data-activity="input"] {
1077
+ border-width: 2px;
1078
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--wt-mark-ink) 30%, transparent);
1079
+ }
1080
+
967
1081
  /* The disc's glow beat: base -> brighter -> base, then a rest until the next
968
1082
  cycle — expressed as the opacity envelope of the pre-painted bright overlay
969
1083
  (the ::before rule above), so the beat costs compositing, not repaints.
@@ -1019,6 +1133,21 @@
1019
1133
  }
1020
1134
  }
1021
1135
 
1136
+ /* The activity mark's breath, same envelope as the dot's glow: rest, one bright
1137
+ swell, rest. Opacity only — a colour keyframe here would reintroduce the
1138
+ per-frame repaint this shape exists to avoid. */
1139
+ @keyframes wt-activity-breath {
1140
+ 0%,
1141
+ 70%,
1142
+ 100% {
1143
+ opacity: 0;
1144
+ }
1145
+
1146
+ 35% {
1147
+ opacity: 1;
1148
+ }
1149
+ }
1150
+
1022
1151
  /* Reduced-motion: drop the animated states' motion (glow + ripple), keep color.
1023
1152
  All three degrade the same way, since they are one object with three hues. */
1024
1153
  @media (prefers-reduced-motion: reduce) {
@@ -1045,6 +1174,28 @@
1045
1174
  :where(.wt-root) .wt-status-dot[data-status="failed"]::after {
1046
1175
  content: none;
1047
1176
  }
1177
+
1178
+ /* Both of the mark's transition lists carry a DELAY on their second property,
1179
+ and 01-scope.css zeroes animation and transition DURATIONS inside .wt-root
1180
+ while saying nothing about delay — so an un-zeroed delay parks the fade with
1181
+ the space already open, or the close with the mark already gone. The state
1182
+ selector is listed because its own `transition` shorthand outranks the base
1183
+ rule's delay and would otherwise keep the entry's. */
1184
+ :where(.wt-root) .wt-activity-mark,
1185
+ :where(.wt-root) .wt-activity-mark[data-activity] {
1186
+ transition-delay: 0s;
1187
+ }
1188
+
1189
+ /* Working loses its breath, so it takes the HEAVIEST band instead: motion is
1190
+ REPLACED by a channel rather than removed, and the three states then separate
1191
+ by band alone (3px bare / 1px halo / 2px halo). */
1192
+ :where(.wt-root) .wt-activity-mark[data-activity="working"] {
1193
+ border-width: 3px;
1194
+ }
1195
+
1196
+ :where(.wt-root) .wt-activity-mark[data-activity="working"]::before {
1197
+ content: none;
1198
+ }
1048
1199
  }
1049
1200
 
1050
1201
  /* ===== Catching-up cue (tabs feature, sections 12/13) =====
@@ -194,6 +194,17 @@
194
194
  min-width: 0;
195
195
  }
196
196
 
197
+ /* The secondary activity mark's footprint cancel is "the box plus the chip's own
198
+ flex gap" (30-tabs.css), and these two chips lay their parts out on --sp-2
199
+ where the desktop chip uses --sp-1 — so the collapsed mark would leave 4px of
200
+ layout behind at both mobile sites. This is the one per-site value; the rendered
201
+ geometry test in features/tabs/chip-geometry.test.ts measures all three sites,
202
+ which is what stops it drifting. */
203
+ :where(.wt-root) .wt-switcher-current-inner .wt-activity-mark,
204
+ :where(.wt-root) .wt-switcher-row-select .wt-activity-mark {
205
+ --wt-mark-gap: var(--sp-2);
206
+ }
207
+
197
208
  /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
198
209
  the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
199
210
  that lifts the CAP band (cap top to baseline, never cap-to-descender) to the row
@@ -45,9 +45,17 @@
45
45
 
46
46
  /* The chip's content dissolves as it becomes the drop slot, and comes back when the
47
47
  drag ends (30-tabs.css fades every child to nothing). One rule on the children,
48
- not four per-part rules: the label, the status dot, the progress bar and the close
49
- all leave together, because they leave for one reason. */
50
- :where(.wt-root.wt-animate) .wt-tab > * {
48
+ not a per-part rule each: the label, the status dot, the progress bar and the
49
+ close all leave together, because they leave for one reason.
50
+
51
+ The activity mark is the fifth child and is deliberately NOT one of them: it
52
+ animates on its own timing (30-tabs.css), a delayed fade that follows the
53
+ footprint snap. Both declarations are `transition` at equal specificity and this
54
+ file loads later, so dropping the :not() silently resets the mark's reveal at the
55
+ desktop chip while the two mobile chips, which are not .wt-tab children, keep it
56
+ — one element revealing two ways. chip-geometry.test.ts measures the resolved
57
+ values under .wt-animate at all three sites. */
58
+ :where(.wt-root.wt-animate) .wt-tab > *:not(.wt-activity-mark) {
51
59
  transition: opacity var(--dur-micro) var(--ease-standard);
52
60
  }
53
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.1.0",
3
+ "version": "7.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": "MPL-2.0",
@@ -50,7 +50,7 @@
50
50
  "@stryker-mutator/core": "10.0.0",
51
51
  "@stryker-mutator/typescript-checker": "10.0.0",
52
52
  "@stryker-mutator/vitest-runner": "10.0.0",
53
- "@types/node": "26.5.0",
53
+ "@types/node": "26.5.1",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
55
  "@vitest/browser": "5.0.0",
56
56
  "@vitest/browser-playwright": "5.0.0",
@@ -29,10 +29,13 @@ import {
29
29
  compareTabOrder,
30
30
  createSessionAPI,
31
31
  createTombstones,
32
+ foldedCueStatus,
32
33
  hasPinnedName,
33
34
  isCueStatus,
34
35
  isEndedStatus,
35
36
  isUnseenCue,
37
+ normalizeActivity,
38
+ normalizeActivityCount,
36
39
  normalizeProgress,
37
40
  orderedInsertIndex,
38
41
  parseCueSeen,
@@ -54,6 +57,7 @@ import {
54
57
  TAB_HTML,
55
58
  kbButtonHTML,
56
59
  newButtonHTML,
60
+ paintActivityMark,
57
61
  paintProgress,
58
62
  paintStatusDot,
59
63
  pick,
@@ -498,6 +502,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
498
502
  // every expanded list row carries a .wt-progress-bar of its own, so an
499
503
  // unscoped pick would be ambiguous the moment the list is populated.
500
504
  const swProgress = pick(switcher, ".wt-switcher-current .wt-progress-bar");
505
+ // The active row's secondary activity mark, scoped for the same reason.
506
+ const swActivity = pick(switcher, ".wt-switcher-current .wt-activity-mark");
501
507
  const swClose = pick(switcher, ".wt-switcher-current-close");
502
508
  // The active-tab elements that translate together during a horizontal
503
509
  // swipe: the content (dot + label) and the close (x). Moving both keeps the
@@ -652,12 +658,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
652
658
  // is one: there is no state of its own to get out of step, so no path can
653
659
  // leave it stale, and it is cheap enough to run on every tick (a loop over a
654
660
  // handful of tabs, then sinks that no-op when nothing changed). The dot is
655
- // the store — statusOf reads each tab's status back off it — so this reads
656
- // exactly what the user is looking at.
661
+ // the store — cueStatusOf reads each tab's status back off it — so this and
662
+ // the raise in applyStatus fold the same value.
657
663
  function paintAttention(): void {
658
664
  attention.apply(
659
665
  summarize(
660
- tabList.map((t) => ({ id: t.id, status: statusOf(t) })),
666
+ tabList.map((t) => ({ id: t.id, status: cueStatusOf(t) })),
661
667
  cueSeen,
662
668
  ),
663
669
  );
@@ -928,7 +934,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
928
934
  // is a 2px line with no text, so without this a screen-reader user
929
935
  // cannot tell a working tab from a crashed one, or hear how far along
930
936
  // a determinate one is.
931
- t.aria.setLabel(tabAccessibleName(display, statusOf(t), shownProgress(t)));
937
+ t.aria.setLabel(
938
+ tabAccessibleName({
939
+ label: display,
940
+ status: statusOf(t),
941
+ progress: shownProgress(t),
942
+ activity: t.activity,
943
+ activityCount: t.activityCount,
944
+ }),
945
+ );
932
946
  }
933
947
  }
934
948
 
@@ -939,6 +953,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
939
953
  return t.dot.dataset["status"] ?? "idle";
940
954
  }
941
955
 
956
+ /** cueStatusOf folds a tab for the cue surfaces (foldedCueStatus). Off the
957
+ * DOT rather than the wire status, so a server's empty status arrives here
958
+ * as "idle" and cannot be mistaken for the blanked-cue sentinel. */
959
+ function cueStatusOf(t: Tab): string {
960
+ return foldedCueStatus(statusOf(t), t.activity);
961
+ }
962
+
942
963
  /** shownProgress is a tab's percentage as it may currently be DISPLAYED:
943
964
  * the last value the server reported, dropped unless the current status is
944
965
  * one the progress channel owns. Those are the only two ways a percentage
@@ -1041,6 +1062,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1041
1062
  swLabel.textContent = active ? active.display : "";
1042
1063
  paintStatusDot(swDot, active ? statusOf(active) : "idle", active?.reports ?? false);
1043
1064
  paintProgress(swProgress, active ? shownProgress(active) : PROGRESS_ABSENT);
1065
+ paintActivityMark(swActivity, active?.activity ?? "", active?.activityCount ?? 0);
1044
1066
  // The aggregate background-notification cue rides the dedicated switch
1045
1067
  // button's dot (paintSwitchDot), not the active surface (it did not fit
1046
1068
  // there).
@@ -1066,6 +1088,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1066
1088
  function updateRow(row: HTMLElement, t: Tab): void {
1067
1089
  paintStatusDot(pick(row, ".wt-switcher-row-dot"), statusOf(t), t.reports);
1068
1090
  paintProgress(pick(row, ".wt-progress-bar"), shownProgress(t));
1091
+ paintActivityMark(pick(row, ".wt-activity-mark"), t.activity, t.activityCount);
1069
1092
  pick(row, ".wt-switcher-row-label").textContent = t.display;
1070
1093
  }
1071
1094
  // renderSwitcherList reconciles the expanded list to a row per OTHER tab
@@ -1444,9 +1467,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1444
1467
  const el = fromHTML(TAB_HTML);
1445
1468
  const label = el.querySelector<HTMLElement>(".wt-tab-label");
1446
1469
  const dot = el.querySelector<HTMLElement>(".wt-tab-dot");
1470
+ const activityEl = el.querySelector<HTMLElement>(".wt-activity-mark");
1447
1471
  const progressEl = el.querySelector<HTMLElement>(".wt-progress-bar");
1448
1472
  const close = el.querySelector<HTMLButtonElement>(".wt-tab-close");
1449
- if (!label || !dot || !progressEl || !close) {
1473
+ if (!label || !dot || !activityEl || !progressEl || !close) {
1450
1474
  throw new Error("web-terminal-ui: tab chrome missing parts");
1451
1475
  }
1452
1476
  paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
@@ -1482,21 +1506,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1482
1506
  el,
1483
1507
  label,
1484
1508
  dot,
1509
+ activityEl,
1485
1510
  progressEl,
1486
1511
  // A percentage exists only on the status STREAM, so a tab adopted from
1487
1512
  // the REST list starts with none; the first status event fills it in.
1488
1513
  progress: normalizeProgress(info.progressValue),
1514
+ activity: normalizeActivity(info.activity),
1515
+ activityCount: normalizeActivityCount(info.activityCount),
1489
1516
  aria,
1490
1517
  view: null,
1491
1518
  reports: reportsOf(info.reportsActivity),
1492
1519
  };
1493
1520
  paintProgress(progressEl, renderedProgress(info.status, tab.progress));
1521
+ paintActivityMark(activityEl, tab.activity, tab.activityCount);
1494
1522
  // Set an initial label immediately (relabelAll refines it with de-dup
1495
1523
  // once the tab is in tabList and syncChrome runs).
1496
1524
  tab.display = baseLabel(tab).text;
1497
1525
  label.textContent = tab.display;
1498
1526
  aria.setLabel(
1499
- tabAccessibleName(tab.display, info.status, renderedProgress(info.status, tab.progress)),
1527
+ tabAccessibleName({
1528
+ label: tab.display,
1529
+ status: info.status,
1530
+ progress: renderedProgress(info.status, tab.progress),
1531
+ activity: tab.activity,
1532
+ activityCount: tab.activityCount,
1533
+ }),
1500
1534
  );
1501
1535
  el.addEventListener("click", (e) => {
1502
1536
  if ((e.target as HTMLElement).closest(".wt-tab-close")) {
@@ -3307,6 +3341,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3307
3341
  t.progress = normalizeProgress(rec.progressValue);
3308
3342
  }
3309
3343
  paintProgress(t.progressEl, shownProgress(t));
3344
+ // The host's secondary activity, read with the same PRESENT-only guard and
3345
+ // for the same reason: the polling fallback lists SessionInfo, and a
3346
+ // pre-release server carries neither field at all, so reading absence as
3347
+ // "no background task" would blank a live mark on every tick. An empty
3348
+ // string IS the withdrawal, and it arrives explicitly.
3349
+ if (rec.activity !== undefined) {
3350
+ t.activity = normalizeActivity(rec.activity);
3351
+ }
3352
+ if (rec.activityCount !== undefined) {
3353
+ t.activityCount = normalizeActivityCount(rec.activityCount);
3354
+ }
3355
+ paintActivityMark(t.activityEl, t.activity, t.activityCount);
3310
3356
  // A session that reports activity is a session whose program speaks OSC 9,
3311
3357
  // so it is also one that may post a notification: arm the permission
3312
3358
  // request for the next user gesture (see notify.ts).
@@ -3327,9 +3373,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3327
3373
  // dismissal stick across those is cueSeen — a latch this viewer already
3328
3374
  // acknowledged raises nothing, while a status that moved on drops the
3329
3375
  // acknowledgement so the next latch is a fresh cue.
3330
- if (!isCueStatus(rec.status)) {
3331
- forgetCueSeen(rec.id);
3332
- } else if (rec.id === activeId && pageVisible()) {
3376
+ //
3377
+ // Two inputs, one per question, and the ORDER keeps them apart. The
3378
+ // acknowledgement asks whether this reader has SEEN the tab's real state:
3379
+ // RAW status, first, and only for a cue — a non-cue one has nothing to
3380
+ // acknowledge and has to reach the forget below. The raise and the forget
3381
+ // ask whether anything should be ANNOUNCED: folded. Off the fold the
3382
+ // acknowledgement misses a watched turn until its task ends; off the raw
3383
+ // status the forget makes the "" sentinel pointless.
3384
+ const raw = statusOf(t);
3385
+ const cue = cueStatusOf(t);
3386
+ if (rec.id === activeId && pageVisible() && isCueStatus(raw)) {
3333
3387
  // The user is looking at this terminal as it latches, so there is
3334
3388
  // nothing to notify — and nothing to re-raise once they move away.
3335
3389
  //
@@ -3340,9 +3394,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3340
3394
  // it is precisely the case the out-of-page surfaces exist for, so a
3341
3395
  // latch arriving on a HIDDEN page now raises them and is acknowledged
3342
3396
  // when the user comes back (see onPageVisible).
3343
- markCueSeen(rec.id, rec.status);
3344
- } else if (isUnseenCue(rec.status, rec.id, cueSeen)) {
3345
- switchNotify = rec.status;
3397
+ markCueSeen(rec.id, raw);
3398
+ } else if (cue === "") {
3399
+ // A blanked cue is NO INFORMATION, not a state, so nothing is raised and
3400
+ // the acknowledgement map is left alone: forgetting here would re-raise
3401
+ // the cue from scratch the moment the background task ended.
3402
+ } else if (!isCueStatus(cue)) {
3403
+ forgetCueSeen(rec.id);
3404
+ } else if (isUnseenCue(cue, rec.id, cueSeen)) {
3405
+ switchNotify = cue;
3346
3406
  switchNotifyId = rec.id;
3347
3407
  paintSwitchDot();
3348
3408
  }
@@ -20,8 +20,19 @@ export type { SessionInfo };
20
20
  * (SessionStatus.progressValue). Both status sources flow through one code path
21
21
  * this way, and the optionality is meaningful: the polling fallback lists
22
22
  * SessionInfo with no percentage at all, which means "no information" and must
23
- * not be read as "the percentage was cleared". */
24
- export type StatusRecord = SessionInfo & { readonly progressValue?: number };
23
+ * not be read as "the percentage was cleared".
24
+ *
25
+ * `activity`/`activityCount` are declared here as a FORWARD declaration: the
26
+ * published engine's SessionInfo does not carry them yet. Drop both from this
27
+ * intersection once it does, so the wire shape has one home again — if the
28
+ * intersection then narrows anything, the engine's declaration is the wrong
29
+ * one. Both are `string`/`number` rather than unions for the same reason
30
+ * `status` is: the stream is parsed, not validated. */
31
+ export type StatusRecord = SessionInfo & {
32
+ readonly progressValue?: number;
33
+ readonly activity?: string;
34
+ readonly activityCount?: number;
35
+ };
25
36
 
26
37
  // The status vocabulary, module-private on purpose: every consumer asks one of
27
38
  // the predicates below (isEndedStatus / statusRevealsDot / isCueStatus /
@@ -161,6 +172,63 @@ export function renderedProgress(status: string, progress: number): number {
161
172
  return statusOwnsProgress(status) ? progress : PROGRESS_ABSENT;
162
173
  }
163
174
 
175
+ // The secondary-activity vocabulary: a host-reported background activity that
176
+ // OUTLIVES the turn, orthogonal to the status above. Module-private for the same
177
+ // reason the status vocabulary is — every consumer asks a predicate, so a state's
178
+ // MEANING has one home.
179
+ //
180
+ // A background task is running.
181
+ const ACTIVITY_WORKING = "working";
182
+ // Stopped and resumable; nobody is being asked for anything.
183
+ const ACTIVITY_WAITING = "waiting";
184
+ // A background task is blocked on the user.
185
+ const ACTIVITY_INPUT = "input";
186
+
187
+ /** normalizeActivity cleans the wire value into the closed set, or "" for no
188
+ * mark. Anything outside it — an unknown state from a newer server, a
189
+ * non-string, absent — is "": an unrecognised state must fail toward NO mark
190
+ * rather than toward a lit one whose meaning this build cannot name. */
191
+ export function normalizeActivity(value: unknown): string {
192
+ if (value === ACTIVITY_WORKING || value === ACTIVITY_WAITING || value === ACTIVITY_INPUT) {
193
+ return value;
194
+ }
195
+ return "";
196
+ }
197
+
198
+ /** normalizeActivityCount cleans the count off the wire: a non-integer, a
199
+ * negative or an absent value is 0. The engine documents count >= 1 whenever
200
+ * the state is non-empty, so 0 beside a live state is a server that does not
201
+ * count, which activityPhrase reads as one. */
202
+ export function normalizeActivityCount(value: unknown): number {
203
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
204
+ return 0;
205
+ }
206
+ return value;
207
+ }
208
+
209
+ /** activityPhrase is the human wording for the mark, used for BOTH its hover
210
+ * tooltip and the suffix in a tab's accessible name — one definition, so what a
211
+ * sighted user reads on hover and what a screen reader announces cannot
212
+ * disagree (the statusPhrase rule). "" for no mark.
213
+ *
214
+ * It cannot say "workflow": the secondary channel is generic, so the wording has
215
+ * to be true for whatever a host puts on it. */
216
+ export function activityPhrase(state: string, count: number): string {
217
+ const normalized = normalizeActivity(state);
218
+ if (normalized === "") {
219
+ return "";
220
+ }
221
+ const n = Math.max(1, normalizeActivityCount(count));
222
+ const subject = `${String(n)} background task${n === 1 ? "" : "s"}`;
223
+ if (normalized === ACTIVITY_WORKING) {
224
+ return `${subject} running`;
225
+ }
226
+ if (normalized === ACTIVITY_WAITING) {
227
+ return `${subject} paused`;
228
+ }
229
+ return `${subject} waiting for you`;
230
+ }
231
+
164
232
  /** tabAccessibleName is a tab's announced name: its rendered label plus the
165
233
  * session's state, and the percentage when one is showing.
166
234
  *
@@ -173,16 +241,23 @@ export function renderedProgress(status: string, progress: number): number {
173
241
  *
174
242
  * Pass PROGRESS_ABSENT for progress to announce the state alone. Callers hand in
175
243
  * renderedProgress's output, so a percentage the tab is not showing is not
176
- * announced either. */
177
- export function tabAccessibleName(
178
- renderedLabel: string,
179
- status: string,
180
- progress: number = PROGRESS_ABSENT,
181
- ): string {
182
- const state = statusPhrase(status);
183
- return progress < 0
184
- ? `${renderedLabel} — ${state}`
185
- : `${renderedLabel} — ${state}, ${String(progress)}%`;
244
+ * announced either.
245
+ *
246
+ * A RECORD rather than positional arguments: `label` and `status` are adjacent
247
+ * same-typed strings, so a transposition compiles. */
248
+ export function tabAccessibleName(v: {
249
+ readonly label: string;
250
+ readonly status: string;
251
+ readonly progress?: number;
252
+ readonly activity?: string;
253
+ readonly activityCount?: number;
254
+ }): string {
255
+ const state = statusPhrase(v.status);
256
+ const progress = v.progress ?? PROGRESS_ABSENT;
257
+ const head =
258
+ progress < 0 ? `${v.label} — ${state}` : `${v.label} — ${state}, ${String(progress)}%`;
259
+ const activity = activityPhrase(v.activity ?? "", v.activityCount ?? 0);
260
+ return activity === "" ? head : `${head} (${activity})`;
186
261
  }
187
262
 
188
263
  // localStorage key for the last active session id, so a page reload reopens the
@@ -251,6 +326,37 @@ export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
251
326
  return "alert";
252
327
  }
253
328
 
329
+ /** The cue statuses that mean the session's own turn is OVER WITHOUT INCIDENT, and
330
+ * therefore the only ones a live background task may blank.
331
+ *
332
+ * Narrow on purpose, and `done` is the whole set. Every other cue status is
333
+ * something the viewer has to be told AT ONCE, so none of them may wait on a task:
334
+ * `crashed` and `exited` mean the PROCESS is gone (and a task belonging to a dead
335
+ * session is dead too), `input` IS what the viewer is being pointed at whatever
336
+ * else the session is doing, and `failed` is an error the program declared, which
337
+ * does not become less urgent because something else is still running. On this
338
+ * engine's path `failed` is OSC 9;4 state 2 rather than a turn verdict, so it is
339
+ * deliberately NOT treated the way vibekit's settle probe treats its own `failed`
340
+ * (see web-terminal-engine.md). */
341
+ const SETTLED_CUES: ReadonlySet<string> = new Set<CueStatus>(["done"]);
342
+
343
+ /** foldedCueStatus is the status the CUE surfaces see for a session: its own
344
+ * status, unless a settled turn's background task is still running, in which
345
+ * case "". Nothing about the status DOT changes — the strip keeps rendering the
346
+ * turn's real state; only the cue's view of it is blanked.
347
+ *
348
+ * "" and NOT "idle", and the difference is load-bearing: "" means NO
349
+ * INFORMATION, so a caller must leave the wt-cue-seen map alone, where "idle" is
350
+ * a real non-cue state that FORGETS the acknowledgement. Blanking with "idle"
351
+ * would drop the dismissal and re-raise the cue from scratch the moment the task
352
+ * ended, even for a session the viewer had already visited. */
353
+ export function foldedCueStatus(status: string, activity: string): string {
354
+ if (!SETTLED_CUES.has(status) || normalizeActivity(activity) === "") {
355
+ return status;
356
+ }
357
+ return "";
358
+ }
359
+
254
360
  /** isUnseenCue reports whether a session's CURRENT status is a cue this viewer
255
361
  * has not acknowledged. The single predicate behind both the raise and the
256
362
  * attention count, so a dot can never light for a session the count omits.
@@ -460,6 +566,16 @@ export interface Tab {
460
566
  * question (renderedProgress), and the prefix it produces is never stored on
461
567
  * any title field. */
462
568
  progress: number;
569
+ /** The chip's secondary activity mark (the rounded-square ring beside the
570
+ * status dot). Present on every chip, and costing no layout until a state
571
+ * arrives. */
572
+ activityEl: HTMLElement;
573
+ /** The host's secondary activity state for this session, normalised to the
574
+ * closed set ("" for none). Independent of `status` and of `reports`: a
575
+ * background task can be running while the turn that launched it is finished. */
576
+ activity: string;
577
+ /** How many sources produced that state, 0 when the server does not count. */
578
+ activityCount: number;
463
579
  aria: TabHandle;
464
580
  /** This tab's saved reading position: the absolute LINE at the viewport top
465
581
  * plus its on-screen offset and the follow state, as captureViewMemory()
@@ -10,7 +10,7 @@
10
10
  // The status vocabulary itself (which statuses reveal a dot, and how each one is
11
11
  // worded for a human) lives in the DOM-free model, so the painters here and the
12
12
  // accessible names index.ts builds read one definition.
13
- import { statusPhrase, statusRevealsDot } from "./model.js";
13
+ import { activityPhrase, normalizeActivity, statusPhrase, statusRevealsDot } from "./model.js";
14
14
 
15
15
  // --- Reorder preview -------------------------------------------------------
16
16
  // Every chip in this strip is the same width (see .wt-tab in 30-tabs.css: a
@@ -121,6 +121,13 @@ const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8
121
121
  // class (.wt-progress-bar) styles it everywhere and every chip site gets it for
122
122
  // free. It starts `hidden` — a session with no percentage must render NO bar
123
123
  // (paintProgress).
124
+ //
125
+ // The secondary activity mark (.wt-activity-mark) rides here for the same reason
126
+ // and takes no per-site class either. It carries NO `hidden` attribute, unlike
127
+ // the bar: `hidden` is display:none, and an element out of layout cannot animate
128
+ // into it. Absence is the absence of `data-activity`, which the CSS answers by
129
+ // cancelling the mark's own footprint (see 30-tabs.css), so a chip with no
130
+ // secondary activity measures exactly as it did before this element existed.
124
131
  export function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
125
132
  dotLabel: string;
126
133
  close: string;
@@ -128,6 +135,7 @@ export function chipContent(v: { dot: string; label: string; close: string; clos
128
135
  return {
129
136
  dotLabel:
130
137
  `<span class="${v.dot} wt-status-dot" aria-hidden="true"></span>` +
138
+ `<span class="wt-activity-mark" aria-hidden="true"></span>` +
131
139
  `<span class="${v.label}"></span>` +
132
140
  `<span class="wt-progress-bar" aria-hidden="true" hidden></span>`,
133
141
  close: `<button type="button" class="${v.close}" aria-label="Close terminal"${v.closeAttr ?? ""}>${CLOSE_SVG}</button>`,
@@ -195,6 +203,27 @@ export function paintStatusDot(el: HTMLElement, status: string, reports: boolean
195
203
  el.title = statusPhrase(value);
196
204
  }
197
205
 
206
+ /** paintActivityMark applies the secondary mark's two bits: data-activity drives
207
+ * its appearance (working / waiting / input via CSS) and `title` gives it a
208
+ * hover tooltip, worded by activityPhrase so hover text and announced text
209
+ * cannot drift. No state REMOVES the attribute, which is what collapses the
210
+ * mark's footprint to zero.
211
+ *
212
+ * The reveal is the mark's OWN state, deliberately not the status dot's
213
+ * reportsActivity gate: a background run is a fact about the session that is
214
+ * independent of whether its program ever spoke OSC 9, which is the whole point
215
+ * of a second mark. */
216
+ export function paintActivityMark(el: HTMLElement, state: string, count: number): void {
217
+ const phrase = activityPhrase(state, count);
218
+ if (phrase === "") {
219
+ delete el.dataset["activity"];
220
+ el.removeAttribute("title");
221
+ return;
222
+ }
223
+ el.dataset["activity"] = normalizeActivity(state);
224
+ el.title = phrase;
225
+ }
226
+
198
227
  /** paintProgress renders one chip's determinate progress bar from a percentage.
199
228
  *
200
229
  * An absent percentage (PROGRESS_ABSENT / any negative) renders NO bar: the
@@ -115,7 +115,11 @@ interface StoredEntry {
115
115
  * neither could be observed: 15 of the file's 19 surviving mutants were those
116
116
  * two guards and their bodies.
117
117
  */
118
- function readStoredEntry(entry: NonNullable<unknown>): StoredEntry | null {
118
+ // Spelled `{}` rather than `NonNullable<unknown>`: the two are the same type, and
119
+ // only the second is a type OPERATION, which `no-generated-empty-object-type`
120
+ // (typescript-eslint 8.70+) reports even where resolving to `{}` is the point.
121
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- "any non-nullish value" is the intended type; the paragraph above says why there is no object test here
122
+ function readStoredEntry(entry: {}): StoredEntry | null {
119
123
  const rec = entry as Record<string, unknown>;
120
124
  const savedAt = rec["savedAt"];
121
125
  // Finite, not merely a number: the age comparison below is a subtraction, and