@cplieger/web-terminal-ui 7.1.0 → 7.2.1
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 +19 -3
- package/css/30-tabs.css +151 -0
- package/css/31-switcher.css +11 -0
- package/css/40-animations.css +11 -3
- package/package.json +4 -4
- package/src/features/tabs/index.ts +72 -12
- package/src/features/tabs/model.ts +118 -11
- package/src/features/tabs/strip.ts +30 -1
- package/src/kernel/scrollback.ts +5 -1
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.
|
|
157
|
-
|
|
158
|
-
|
|
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) =====
|
package/css/31-switcher.css
CHANGED
|
@@ -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
|
package/css/40-animations.css
CHANGED
|
@@ -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
|
|
49
|
-
all leave together, because they leave for one reason.
|
|
50
|
-
|
|
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
|
|
3
|
+
"version": "7.2.1",
|
|
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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"verify": "bash scripts/verify.sh"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@cplieger/web-terminal-engine": "^5.
|
|
43
|
+
"@cplieger/web-terminal-engine": "^5.2.0"
|
|
44
44
|
},
|
|
45
45
|
"overrides": {
|
|
46
46
|
"qs": "^6.15.2"
|
|
@@ -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.
|
|
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",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"stylelint": "17.15.0",
|
|
65
65
|
"stylelint-config-standard": "40.0.0",
|
|
66
66
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
67
|
-
"typescript-eslint": "8.
|
|
67
|
+
"typescript-eslint": "8.70.0",
|
|
68
68
|
"vitest": "5.0.0"
|
|
69
69
|
},
|
|
70
70
|
"publishConfig": {
|
|
@@ -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 —
|
|
656
|
-
//
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
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,
|
|
3344
|
-
} else if (
|
|
3345
|
-
|
|
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
|
}
|
|
@@ -21,7 +21,9 @@ export type { SessionInfo };
|
|
|
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
23
|
* not be read as "the percentage was cleared". */
|
|
24
|
-
export type StatusRecord = SessionInfo & {
|
|
24
|
+
export type StatusRecord = SessionInfo & {
|
|
25
|
+
readonly progressValue?: number;
|
|
26
|
+
};
|
|
25
27
|
|
|
26
28
|
// The status vocabulary, module-private on purpose: every consumer asks one of
|
|
27
29
|
// the predicates below (isEndedStatus / statusRevealsDot / isCueStatus /
|
|
@@ -161,6 +163,63 @@ export function renderedProgress(status: string, progress: number): number {
|
|
|
161
163
|
return statusOwnsProgress(status) ? progress : PROGRESS_ABSENT;
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
// The secondary-activity vocabulary: a host-reported background activity that
|
|
167
|
+
// OUTLIVES the turn, orthogonal to the status above. Module-private for the same
|
|
168
|
+
// reason the status vocabulary is — every consumer asks a predicate, so a state's
|
|
169
|
+
// MEANING has one home.
|
|
170
|
+
//
|
|
171
|
+
// A background task is running.
|
|
172
|
+
const ACTIVITY_WORKING = "working";
|
|
173
|
+
// Stopped and resumable; nobody is being asked for anything.
|
|
174
|
+
const ACTIVITY_WAITING = "waiting";
|
|
175
|
+
// A background task is blocked on the user.
|
|
176
|
+
const ACTIVITY_INPUT = "input";
|
|
177
|
+
|
|
178
|
+
/** normalizeActivity cleans the wire value into the closed set, or "" for no
|
|
179
|
+
* mark. Anything outside it — an unknown state from a newer server, a
|
|
180
|
+
* non-string, absent — is "": an unrecognised state must fail toward NO mark
|
|
181
|
+
* rather than toward a lit one whose meaning this build cannot name. */
|
|
182
|
+
export function normalizeActivity(value: unknown): string {
|
|
183
|
+
if (value === ACTIVITY_WORKING || value === ACTIVITY_WAITING || value === ACTIVITY_INPUT) {
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** normalizeActivityCount cleans the count off the wire: a non-integer, a
|
|
190
|
+
* negative or an absent value is 0. The engine documents count >= 1 whenever
|
|
191
|
+
* the state is non-empty, so 0 beside a live state is a server that does not
|
|
192
|
+
* count, which activityPhrase reads as one. */
|
|
193
|
+
export function normalizeActivityCount(value: unknown): number {
|
|
194
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** activityPhrase is the human wording for the mark, used for BOTH its hover
|
|
201
|
+
* tooltip and the suffix in a tab's accessible name — one definition, so what a
|
|
202
|
+
* sighted user reads on hover and what a screen reader announces cannot
|
|
203
|
+
* disagree (the statusPhrase rule). "" for no mark.
|
|
204
|
+
*
|
|
205
|
+
* It cannot say "workflow": the secondary channel is generic, so the wording has
|
|
206
|
+
* to be true for whatever a host puts on it. */
|
|
207
|
+
export function activityPhrase(state: string, count: number): string {
|
|
208
|
+
const normalized = normalizeActivity(state);
|
|
209
|
+
if (normalized === "") {
|
|
210
|
+
return "";
|
|
211
|
+
}
|
|
212
|
+
const n = Math.max(1, normalizeActivityCount(count));
|
|
213
|
+
const subject = `${String(n)} background task${n === 1 ? "" : "s"}`;
|
|
214
|
+
if (normalized === ACTIVITY_WORKING) {
|
|
215
|
+
return `${subject} running`;
|
|
216
|
+
}
|
|
217
|
+
if (normalized === ACTIVITY_WAITING) {
|
|
218
|
+
return `${subject} paused`;
|
|
219
|
+
}
|
|
220
|
+
return `${subject} waiting for you`;
|
|
221
|
+
}
|
|
222
|
+
|
|
164
223
|
/** tabAccessibleName is a tab's announced name: its rendered label plus the
|
|
165
224
|
* session's state, and the percentage when one is showing.
|
|
166
225
|
*
|
|
@@ -173,16 +232,23 @@ export function renderedProgress(status: string, progress: number): number {
|
|
|
173
232
|
*
|
|
174
233
|
* Pass PROGRESS_ABSENT for progress to announce the state alone. Callers hand in
|
|
175
234
|
* renderedProgress's output, so a percentage the tab is not showing is not
|
|
176
|
-
* announced either.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
235
|
+
* announced either.
|
|
236
|
+
*
|
|
237
|
+
* A RECORD rather than positional arguments: `label` and `status` are adjacent
|
|
238
|
+
* same-typed strings, so a transposition compiles. */
|
|
239
|
+
export function tabAccessibleName(v: {
|
|
240
|
+
readonly label: string;
|
|
241
|
+
readonly status: string;
|
|
242
|
+
readonly progress?: number;
|
|
243
|
+
readonly activity?: string;
|
|
244
|
+
readonly activityCount?: number;
|
|
245
|
+
}): string {
|
|
246
|
+
const state = statusPhrase(v.status);
|
|
247
|
+
const progress = v.progress ?? PROGRESS_ABSENT;
|
|
248
|
+
const head =
|
|
249
|
+
progress < 0 ? `${v.label} — ${state}` : `${v.label} — ${state}, ${String(progress)}%`;
|
|
250
|
+
const activity = activityPhrase(v.activity ?? "", v.activityCount ?? 0);
|
|
251
|
+
return activity === "" ? head : `${head} (${activity})`;
|
|
186
252
|
}
|
|
187
253
|
|
|
188
254
|
// localStorage key for the last active session id, so a page reload reopens the
|
|
@@ -251,6 +317,37 @@ export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
|
|
|
251
317
|
return "alert";
|
|
252
318
|
}
|
|
253
319
|
|
|
320
|
+
/** The cue statuses that mean the session's own turn is OVER WITHOUT INCIDENT, and
|
|
321
|
+
* therefore the only ones a live background task may blank.
|
|
322
|
+
*
|
|
323
|
+
* Narrow on purpose, and `done` is the whole set. Every other cue status is
|
|
324
|
+
* something the viewer has to be told AT ONCE, so none of them may wait on a task:
|
|
325
|
+
* `crashed` and `exited` mean the PROCESS is gone (and a task belonging to a dead
|
|
326
|
+
* session is dead too), `input` IS what the viewer is being pointed at whatever
|
|
327
|
+
* else the session is doing, and `failed` is an error the program declared, which
|
|
328
|
+
* does not become less urgent because something else is still running. On this
|
|
329
|
+
* engine's path `failed` is OSC 9;4 state 2 rather than a turn verdict, so it is
|
|
330
|
+
* deliberately NOT treated the way vibekit's settle probe treats its own `failed`
|
|
331
|
+
* (see web-terminal-engine.md). */
|
|
332
|
+
const SETTLED_CUES: ReadonlySet<string> = new Set<CueStatus>(["done"]);
|
|
333
|
+
|
|
334
|
+
/** foldedCueStatus is the status the CUE surfaces see for a session: its own
|
|
335
|
+
* status, unless a settled turn's background task is still running, in which
|
|
336
|
+
* case "". Nothing about the status DOT changes — the strip keeps rendering the
|
|
337
|
+
* turn's real state; only the cue's view of it is blanked.
|
|
338
|
+
*
|
|
339
|
+
* "" and NOT "idle", and the difference is load-bearing: "" means NO
|
|
340
|
+
* INFORMATION, so a caller must leave the wt-cue-seen map alone, where "idle" is
|
|
341
|
+
* a real non-cue state that FORGETS the acknowledgement. Blanking with "idle"
|
|
342
|
+
* would drop the dismissal and re-raise the cue from scratch the moment the task
|
|
343
|
+
* ended, even for a session the viewer had already visited. */
|
|
344
|
+
export function foldedCueStatus(status: string, activity: string): string {
|
|
345
|
+
if (!SETTLED_CUES.has(status) || normalizeActivity(activity) === "") {
|
|
346
|
+
return status;
|
|
347
|
+
}
|
|
348
|
+
return "";
|
|
349
|
+
}
|
|
350
|
+
|
|
254
351
|
/** isUnseenCue reports whether a session's CURRENT status is a cue this viewer
|
|
255
352
|
* has not acknowledged. The single predicate behind both the raise and the
|
|
256
353
|
* attention count, so a dot can never light for a session the count omits.
|
|
@@ -460,6 +557,16 @@ export interface Tab {
|
|
|
460
557
|
* question (renderedProgress), and the prefix it produces is never stored on
|
|
461
558
|
* any title field. */
|
|
462
559
|
progress: number;
|
|
560
|
+
/** The chip's secondary activity mark (the rounded-square ring beside the
|
|
561
|
+
* status dot). Present on every chip, and costing no layout until a state
|
|
562
|
+
* arrives. */
|
|
563
|
+
activityEl: HTMLElement;
|
|
564
|
+
/** The host's secondary activity state for this session, normalised to the
|
|
565
|
+
* closed set ("" for none). Independent of `status` and of `reports`: a
|
|
566
|
+
* background task can be running while the turn that launched it is finished. */
|
|
567
|
+
activity: string;
|
|
568
|
+
/** How many sources produced that state, 0 when the server does not count. */
|
|
569
|
+
activityCount: number;
|
|
463
570
|
aria: TabHandle;
|
|
464
571
|
/** This tab's saved reading position: the absolute LINE at the viewport top
|
|
465
572
|
* 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
|
package/src/kernel/scrollback.ts
CHANGED
|
@@ -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
|
-
|
|
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
|