@cplieger/web-terminal-ui 7.0.7 → 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 +40 -4
- package/css/02-terminal.css +17 -0
- package/css/30-tabs.css +151 -0
- package/css/31-switcher.css +11 -0
- package/css/40-animations.css +11 -3
- package/package.json +5 -5
- package/src/features/tabs/index.ts +72 -12
- package/src/features/tabs/model.ts +128 -12
- package/src/features/tabs/strip.ts +30 -1
- package/src/kernel/kernel.ts +121 -9
- 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
|
|
@@ -186,7 +202,27 @@ want one, for example Ctrl+L); `reattach()` attaches again to whatever the serve
|
|
|
186
202
|
serves now, for a session that has ended (see "When a session ends"); and
|
|
187
203
|
`destroy()` tears every feature down and releases the kernel.
|
|
188
204
|
|
|
189
|
-
###
|
|
205
|
+
### Mouse and focus
|
|
206
|
+
|
|
207
|
+
A terminal application that turns mouse tracking on receives the clicks, drags
|
|
208
|
+
and wheel gestures a user makes in the browser, as SGR reports. The pointer says
|
|
209
|
+
so while that is happening: at rest the grid carries a text cursor, and while an
|
|
210
|
+
application holds the mouse the grid and any links in it carry an arrow instead,
|
|
211
|
+
because a click is reported to that application rather than selecting text or
|
|
212
|
+
opening the link. Shift-drag still selects and shift-click still opens a link,
|
|
213
|
+
which is the convention xterm set, so nothing becomes unreachable.
|
|
214
|
+
|
|
215
|
+
Mouse input is best-effort, as it is in every terminal: a report says where the
|
|
216
|
+
pointer is at that moment and carries nothing a receiver could use to notice it
|
|
217
|
+
had gone stale, so a report made while the socket is down is dropped rather than
|
|
218
|
+
delivered late against a screen that has since been repainted. Typed input keeps
|
|
219
|
+
its delivery guarantee and is unaffected.
|
|
220
|
+
|
|
221
|
+
Focus reporting is the server's answer rather than this UI's. The kernel tells
|
|
222
|
+
the server whether its own terminal widget holds focus and the server decides
|
|
223
|
+
what the application is told, so several devices attached to one session cannot
|
|
224
|
+
contradict each other. A blur that a press causes and that the same gesture's
|
|
225
|
+
click undoes is not reported, because the terminal did not lose focus.
|
|
190
226
|
|
|
191
227
|
`persistScrollback` takes the storage, not a switch, because storage is a
|
|
192
228
|
decision this library should not make for you. Chrome's page-lifecycle guidance
|
package/css/02-terminal.css
CHANGED
|
@@ -149,6 +149,11 @@
|
|
|
149
149
|
kernel focus handling + contextMenu feature). */
|
|
150
150
|
user-select: text;
|
|
151
151
|
-webkit-touch-callout: default;
|
|
152
|
+
|
|
153
|
+
/* Stated rather than inherited: the UA default over text is already an I-beam,
|
|
154
|
+
but the tracking rule below has to have something deterministic to override
|
|
155
|
+
(and to hand back to). */
|
|
156
|
+
cursor: text;
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
/* IME composition view: floating overlay positioned at the cursor
|
|
@@ -327,6 +332,18 @@
|
|
|
327
332
|
--bg: var(--text-default);
|
|
328
333
|
}
|
|
329
334
|
|
|
335
|
+
/* An application holds the mouse (DEC 1000/1002/1003) and the kernel toggles
|
|
336
|
+
.wt-mouse-app on the scroll container. A plain click is reported to that
|
|
337
|
+
application, so neither the I-beam nor the link's pointer is true any longer:
|
|
338
|
+
both promise something the click no longer does. Deliberately no
|
|
339
|
+
`user-select: none` here, because the engine hands a shifted gesture to the
|
|
340
|
+
browser, so shift+drag selecting and shift+click opening a link are the only
|
|
341
|
+
ways left to reach either and blanketing selection off would remove them. */
|
|
342
|
+
:where(.wt-root) .term.wt-mouse-app .term-output,
|
|
343
|
+
:where(.wt-root) .term.wt-mouse-app .term-link {
|
|
344
|
+
cursor: default;
|
|
345
|
+
}
|
|
346
|
+
|
|
330
347
|
:where(.wt-root) .term-blink {
|
|
331
348
|
animation: wt-blink-anim 1s step-end infinite;
|
|
332
349
|
}
|
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.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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"verify": "bash scripts/verify.sh"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@cplieger/web-terminal-engine": "^5.0
|
|
43
|
+
"@cplieger/web-terminal-engine": "^5.1.0"
|
|
44
44
|
},
|
|
45
45
|
"overrides": {
|
|
46
46
|
"qs": "^6.15.2"
|
|
@@ -50,15 +50,15 @@
|
|
|
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.
|
|
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",
|
|
57
57
|
"@vitest/coverage-v8": "5.0.0",
|
|
58
58
|
"eslint": "10.10.0",
|
|
59
59
|
"fast-check": "4.9.0",
|
|
60
|
-
"html-validate": "11.
|
|
61
|
-
"knip": "6.
|
|
60
|
+
"html-validate": "11.15.0",
|
|
61
|
+
"knip": "6.35.1",
|
|
62
62
|
"playwright": "1.63.0",
|
|
63
63
|
"prettier": "3.9.6",
|
|
64
64
|
"stylelint": "17.15.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 —
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -13,12 +13,11 @@
|
|
|
13
13
|
// still single-instance, so createTerminal is called once per page (tabs
|
|
14
14
|
// multiplex sessions over the one kernel, section 22.5).
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// suppression is a tracked follow-up.
|
|
16
|
+
// The kernel owns the terminal widget's DOM, so it owns the widget's focus: it
|
|
17
|
+
// REPORTS focus and blur to the transport (`connection.setClientFocus`) and
|
|
18
|
+
// never writes DEC 1004 bytes itself. The server derives the answer for the
|
|
19
|
+
// session from every attached client's report plus its own hold, which is the
|
|
20
|
+
// only place the question can be answered once a second device attaches.
|
|
22
21
|
|
|
23
22
|
import {
|
|
24
23
|
render,
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
connection,
|
|
27
26
|
keyboard,
|
|
28
27
|
modes,
|
|
28
|
+
mouse,
|
|
29
29
|
LineStore,
|
|
30
30
|
} from "@cplieger/web-terminal-engine";
|
|
31
31
|
import * as composition from "../composition.js";
|
|
@@ -400,9 +400,9 @@ function buildTerminal(
|
|
|
400
400
|
// output never does (the scroll controller's follow/hold job is unchanged),
|
|
401
401
|
// so scrolling up to read while output streams is preserved. A no-op on the
|
|
402
402
|
// alt screen (no scrollback) and when already following. NOTE: every caller
|
|
403
|
-
// of sendBytes
|
|
404
|
-
//
|
|
405
|
-
//
|
|
403
|
+
// of sendBytes is genuine user input (typed keys, paste, mobile toolbar);
|
|
404
|
+
// mouse reports deliberately bypass this funnel and go out as ephemeral
|
|
405
|
+
// input, or the view would snap on every motion report.
|
|
406
406
|
scroll.scrollToBottom();
|
|
407
407
|
for (const obs of inputObservers) {
|
|
408
408
|
obs(out);
|
|
@@ -575,6 +575,100 @@ function buildTerminal(
|
|
|
575
575
|
});
|
|
576
576
|
render.updateFontMetrics();
|
|
577
577
|
|
|
578
|
+
// A mouse report is BEST-EFFORT and never enters the reliable outbox: it
|
|
579
|
+
// carries no sequence number, so one retransmitted after a resume describes a
|
|
580
|
+
// screen since repainted, and `sendEphemeral` refuses rather than queues. Not
|
|
581
|
+
// sendBytes either: that funnel snaps the view to the bottom on every accepted
|
|
582
|
+
// byte, so a motion report would jump the viewport. The listeners belong on the
|
|
583
|
+
// scroll container (a click in its padding or over the reserved scrollbar
|
|
584
|
+
// gutter never reaches a row, and the wheel has to be cancellable there); the
|
|
585
|
+
// coordinate frame is the row container, whose box IS the grid.
|
|
586
|
+
const disposeMouse = mouse.init({
|
|
587
|
+
sendReport: (data) => connection.sendEphemeral(data),
|
|
588
|
+
cellSize: render.cellSize,
|
|
589
|
+
termElement: () => termWrap,
|
|
590
|
+
gridElement: () => outputEl,
|
|
591
|
+
// The RENDERED grid, not computeSize(): the row hit test anchors on its
|
|
592
|
+
// height, and this client's own measurement disagrees with the negotiated
|
|
593
|
+
// screen whenever a second device is attached or a local resize has not been
|
|
594
|
+
// answered yet, which would shift every reported row.
|
|
595
|
+
gridSize: render.gridSize,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// A press over the display-only output takes focus off the hidden textarea and
|
|
599
|
+
// the click handler below puts it back, so that blur/focus pair is an artifact
|
|
600
|
+
// of this input model, not a focus change the application should be told about.
|
|
601
|
+
// xterm.js never emits one because its textarea keeps focus through a click on
|
|
602
|
+
// the terminal. So a blur raised inside a press is withheld and the gesture's
|
|
603
|
+
// REAL end state is reported once it resolves: nothing when focus came back,
|
|
604
|
+
// one report when it did not (a live selection, a link and a bare touch tap all
|
|
605
|
+
// decline to restore it). Measured before this: 3600 focus reports in 90s of
|
|
606
|
+
// clicking, which reaches an application as FocusLost/FocusGained per click.
|
|
607
|
+
let pressHoldsFocus = false;
|
|
608
|
+
let focusResolve: ReturnType<typeof setTimeout> | null = null;
|
|
609
|
+
|
|
610
|
+
function reportFocusNow(): void {
|
|
611
|
+
connection.setClientFocus(
|
|
612
|
+
document.activeElement !== null && termWrap.contains(document.activeElement),
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Scheduled rather than read at pointerup: the focus restore happens in the
|
|
617
|
+
// `click` handler, which the browser dispatches after pointerup within the same
|
|
618
|
+
// task, so a task boundary is the first point where the end state is settled.
|
|
619
|
+
function endPressFocusGesture(): void {
|
|
620
|
+
if (focusResolve !== null) {
|
|
621
|
+
clearTimeout(focusResolve);
|
|
622
|
+
}
|
|
623
|
+
focusResolve = setTimeout(() => {
|
|
624
|
+
focusResolve = null;
|
|
625
|
+
pressHoldsFocus = false;
|
|
626
|
+
reportFocusNow();
|
|
627
|
+
}, 0);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// The keyboard target is the hidden textarea INSIDE termWrap, so the widget's
|
|
631
|
+
// own focus events bubble to here. What goes out is the widget's STATE, not
|
|
632
|
+
// DEC 1004 bytes: the server derives the answer from this report plus every
|
|
633
|
+
// other attached client's plus its own hold.
|
|
634
|
+
termWrap.addEventListener(
|
|
635
|
+
"focusin",
|
|
636
|
+
() => {
|
|
637
|
+
connection.setClientFocus(true);
|
|
638
|
+
},
|
|
639
|
+
{ signal },
|
|
640
|
+
);
|
|
641
|
+
termWrap.addEventListener(
|
|
642
|
+
"focusout",
|
|
643
|
+
(ev) => {
|
|
644
|
+
if (ev.relatedTarget instanceof Node && termWrap.contains(ev.relatedTarget)) {
|
|
645
|
+
return; // focus moving WITHIN the terminal is not a blur
|
|
646
|
+
}
|
|
647
|
+
if (pressHoldsFocus) {
|
|
648
|
+
return; // this gesture's own doing; endPressFocusGesture reports the truth
|
|
649
|
+
}
|
|
650
|
+
connection.setClientFocus(false);
|
|
651
|
+
},
|
|
652
|
+
{ signal },
|
|
653
|
+
);
|
|
654
|
+
// Seeded once, so a terminal mounted with its textarea already focused does not
|
|
655
|
+
// report blurred until the first real focusin.
|
|
656
|
+
reportFocusNow();
|
|
657
|
+
|
|
658
|
+
// The kernel is the toggler, and the class goes on the scroll container rather
|
|
659
|
+
// than on the rows: `.term-output` is replaced wholesale by the renderer, and
|
|
660
|
+
// `.term` is the element that survives every rebuild. What the shape MEANS is
|
|
661
|
+
// at the CSS rule it drives (css/02-terminal.css).
|
|
662
|
+
function updateMousePointer(): void {
|
|
663
|
+
termWrap.classList.toggle("wt-mouse-app", modes.getMouseMode() !== 0);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Derived once here as well as from the two triggers below: the modes module is
|
|
667
|
+
// a singleton that outlives a terminal, so a re-created terminal on a page
|
|
668
|
+
// whose mirror already holds a tracking mode would otherwise show the resting
|
|
669
|
+
// pointer until the next frame.
|
|
670
|
+
updateMousePointer();
|
|
671
|
+
|
|
578
672
|
composition.init({
|
|
579
673
|
textarea: input,
|
|
580
674
|
compositionView: compositionViewEl,
|
|
@@ -698,6 +792,7 @@ function buildTerminal(
|
|
|
698
792
|
bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
|
|
699
793
|
} else if (msg.type === "modes") {
|
|
700
794
|
render.updateReverseVideo();
|
|
795
|
+
updateMousePointer();
|
|
701
796
|
bus.emit("wire:modes", msg);
|
|
702
797
|
} else if (msg.type === "clipboard") {
|
|
703
798
|
// Inbound OSC 52. With no clipboard feature subscribed this is a no-op
|
|
@@ -969,9 +1064,13 @@ function buildTerminal(
|
|
|
969
1064
|
pointerDownX = e.clientX;
|
|
970
1065
|
pointerDownY = e.clientY;
|
|
971
1066
|
pointerDownTime = e.timeStamp;
|
|
1067
|
+
pressHoldsFocus = true;
|
|
972
1068
|
},
|
|
973
1069
|
{ passive: true, signal },
|
|
974
1070
|
);
|
|
1071
|
+
for (const end of ["pointerup", "pointercancel", "lostpointercapture"] as const) {
|
|
1072
|
+
termWrap.addEventListener(end, endPressFocusGesture, { passive: true, signal });
|
|
1073
|
+
}
|
|
975
1074
|
termWrap.addEventListener(
|
|
976
1075
|
"pointerup",
|
|
977
1076
|
(e) => {
|
|
@@ -1476,6 +1575,11 @@ function buildTerminal(
|
|
|
1476
1575
|
// detach here and the onSwitch attach below.
|
|
1477
1576
|
composition.cancelComposition();
|
|
1478
1577
|
resetToPlaceholder(input);
|
|
1578
|
+
// A held mouse gesture is latched input too, and the mouse module is per
|
|
1579
|
+
// TERMINAL while sessions multiplex over it: a record kept across the switch
|
|
1580
|
+
// would let the incoming session's first open synthesize a release for a
|
|
1581
|
+
// press its application never saw.
|
|
1582
|
+
mouse.disarmGesture();
|
|
1479
1583
|
for (const { instance } of instances) {
|
|
1480
1584
|
instance.onDetach?.();
|
|
1481
1585
|
}
|
|
@@ -1483,6 +1587,9 @@ function buildTerminal(
|
|
|
1483
1587
|
// Reconnect the terminal WS to this session using its per-tab resume
|
|
1484
1588
|
// state; the renderer was already pointed at its store by the owner.
|
|
1485
1589
|
connection.setSession(session.id);
|
|
1590
|
+
// setSession restores the incoming session's mode mirror synchronously and
|
|
1591
|
+
// delivers no modes frame, so nothing else re-derives the pointer here.
|
|
1592
|
+
updateMousePointer();
|
|
1486
1593
|
// The owned first connect has happened (session id is now on the WS URL);
|
|
1487
1594
|
// wake-reconnect handlers may fire from here on.
|
|
1488
1595
|
connectionInitiated = true;
|
|
@@ -1671,12 +1778,17 @@ function buildTerminal(
|
|
|
1671
1778
|
// Reset the composition singleton: a failure during an IME cycle must not
|
|
1672
1779
|
// leave module state or a deferred send alive for a later terminal mount.
|
|
1673
1780
|
composition.teardown();
|
|
1781
|
+
disposeMouse();
|
|
1674
1782
|
connection.disconnect();
|
|
1675
1783
|
connState.destroy();
|
|
1676
1784
|
if (toastTimer !== null) {
|
|
1677
1785
|
clearTimeout(toastTimer);
|
|
1678
1786
|
toastTimer = null;
|
|
1679
1787
|
}
|
|
1788
|
+
if (focusResolve !== null) {
|
|
1789
|
+
clearTimeout(focusResolve);
|
|
1790
|
+
focusResolve = null;
|
|
1791
|
+
}
|
|
1680
1792
|
for (const off of subscriptions) {
|
|
1681
1793
|
off();
|
|
1682
1794
|
}
|
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
|