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