@cplieger/web-terminal-ui 5.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -15
- package/css/00-tokens.css +53 -1
- package/css/30-tabs.css +178 -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 +245 -45
- package/src/features/tabs/model.ts +239 -1
- package/src/features/tabs/notify.ts +216 -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/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.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./startup-copy": "./src/kernel/startup-copy.ts",
|
|
10
|
+
"./style-contract": "./src/kernel/style-contract.ts",
|
|
10
11
|
"./presets": "./src/presets.ts",
|
|
11
12
|
"./presets/single": "./src/presets/single.ts",
|
|
12
13
|
"./presets/touch": "./src/presets/touch.ts",
|
|
@@ -39,7 +40,7 @@
|
|
|
39
40
|
"verify": "bash scripts/verify.sh"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
|
-
"@cplieger/web-terminal-engine": "^3.
|
|
43
|
+
"@cplieger/web-terminal-engine": "^3.3.0"
|
|
43
44
|
},
|
|
44
45
|
"overrides": {
|
|
45
46
|
"qs": "^6.15.2"
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
"@types/node": "25.9.5",
|
|
53
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
54
55
|
"@vitest/coverage-v8": "4.1.10",
|
|
55
|
-
"eslint": "10.
|
|
56
|
+
"eslint": "10.8.0",
|
|
56
57
|
"fast-check": "4.9.0",
|
|
57
58
|
"happy-dom": "20.11.1",
|
|
58
59
|
"html-validate": "11.5.6",
|
|
@@ -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,11 +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 { SessionInfo, Tab } from "./model.js";
|
|
17
|
+
import type { CueStatus, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
18
18
|
import {
|
|
19
19
|
ACTIVE_TAB_KEY,
|
|
20
|
+
CUE_SEEN_KEY,
|
|
21
|
+
MAX_PERSISTED_CUE_SEEN,
|
|
20
22
|
MAX_PINNED_NAME,
|
|
21
|
-
|
|
23
|
+
PROGRESS_ABSENT,
|
|
22
24
|
SWIPE_HINT_KEY,
|
|
23
25
|
SessionAPIError,
|
|
24
26
|
TAB_ORDER_KEY,
|
|
@@ -26,15 +28,26 @@ import {
|
|
|
26
28
|
createSessionAPI,
|
|
27
29
|
createTombstones,
|
|
28
30
|
hasPinnedName,
|
|
31
|
+
isCueStatus,
|
|
32
|
+
isEndedStatus,
|
|
33
|
+
normalizeProgress,
|
|
29
34
|
orderedInsertIndex,
|
|
35
|
+
parseCueSeen,
|
|
30
36
|
parseTabOrder,
|
|
37
|
+
progressLabel,
|
|
38
|
+
renderedProgress,
|
|
31
39
|
sanitizePinnedName,
|
|
40
|
+
serializeCueSeen,
|
|
32
41
|
serializeTabOrder,
|
|
42
|
+
statusPhrase,
|
|
43
|
+
tabAccessibleName,
|
|
33
44
|
} from "./model.js";
|
|
45
|
+
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
34
46
|
import {
|
|
35
47
|
TAB_HTML,
|
|
36
48
|
kbButtonHTML,
|
|
37
49
|
newButtonHTML,
|
|
50
|
+
paintProgress,
|
|
38
51
|
paintStatusDot,
|
|
39
52
|
pick,
|
|
40
53
|
switchButtonHTML,
|
|
@@ -186,6 +199,29 @@ function writeSavedOrder(ids: readonly string[]): void {
|
|
|
186
199
|
}
|
|
187
200
|
}
|
|
188
201
|
|
|
202
|
+
/** readCueSeen loads the acknowledged background-tab cues, or an empty map when
|
|
203
|
+
* there is none to trust. Storage itself can throw (Safari private mode, a
|
|
204
|
+
* disabled third-party context, an embedder's iframe) and an unreadable map is
|
|
205
|
+
* never fatal: the dot simply lights again, exactly as it behaved before
|
|
206
|
+
* acknowledgements were remembered. */
|
|
207
|
+
function readCueSeen(): Map<string, CueStatus> {
|
|
208
|
+
try {
|
|
209
|
+
return parseCueSeen(localStorage.getItem(CUE_SEEN_KEY));
|
|
210
|
+
} catch {
|
|
211
|
+
return new Map<string, CueStatus>();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** writeCueSeen persists acknowledgements, best-effort: a full quota must not
|
|
216
|
+
* break the dismissal the user just performed on screen. */
|
|
217
|
+
function writeCueSeen(seen: ReadonlyMap<string, CueStatus>): void {
|
|
218
|
+
try {
|
|
219
|
+
localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
|
|
220
|
+
} catch {
|
|
221
|
+
/* storage unavailable or full — the dismissal still holds for this page */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
189
225
|
async function createSessionHonouringRetry(
|
|
190
226
|
api: { create: () => Promise<SessionInfo> },
|
|
191
227
|
ctx: TerminalContext,
|
|
@@ -444,6 +480,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
444
480
|
const swInner = pick(switcher, ".wt-switcher-current-inner");
|
|
445
481
|
const swDot = pick(switcher, ".wt-switcher-dot");
|
|
446
482
|
const swLabel = pick(switcher, ".wt-switcher-label");
|
|
483
|
+
// The active row's determinate progress bar. Scoped to the current chip:
|
|
484
|
+
// every expanded list row carries a .wt-progress-bar of its own, so an
|
|
485
|
+
// unscoped pick would be ambiguous the moment the list is populated.
|
|
486
|
+
const swProgress = pick(switcher, ".wt-switcher-current .wt-progress-bar");
|
|
447
487
|
const swClose = pick(switcher, ".wt-switcher-current-close");
|
|
448
488
|
// The active-tab elements that translate together during a horizontal
|
|
449
489
|
// swipe: the content (dot + label) and the close (x). Moving both keeps the
|
|
@@ -463,20 +503,72 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
463
503
|
// Latest-wins notification state for the switch button's dot: overwritten
|
|
464
504
|
// by each qualifying background-tab event (applyStatus) and cleared when
|
|
465
505
|
// the list opens (expandSwitcher) or the raising tab is visited or closed
|
|
466
|
-
// (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden.
|
|
467
|
-
|
|
506
|
+
// (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden. The
|
|
507
|
+
// cue-worthy set is CueStatus (input / done / crashed — the states that
|
|
508
|
+
// want the user), declared once in model.ts beside isCueStatus.
|
|
509
|
+
let switchNotify: "" | CueStatus = "";
|
|
468
510
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
469
511
|
// swipe or any switch) acknowledges it without opening the list.
|
|
470
512
|
let switchNotifyId: string | null = null;
|
|
513
|
+
// Cues this viewer has already SEEN, per session (see CUE_SEEN_KEY).
|
|
514
|
+
// Loaded here so a reload starts from what the user already dismissed:
|
|
515
|
+
// input/done stay LATCHED server-side, and the status stream re-pushes the
|
|
516
|
+
// latch in the snapshot it sends on every open, so without this a
|
|
517
|
+
// dismissed dot came back on the next load — and on every SSE reconnect,
|
|
518
|
+
// which on a phone is just returning to a backgrounded page.
|
|
519
|
+
const cueSeen = readCueSeen();
|
|
520
|
+
// markCueSeen records that this viewer has seen `id` holding `status`:
|
|
521
|
+
// either because that tab is the one on screen, or because the tray listing
|
|
522
|
+
// every tab's dot was opened. A non-cue status is not an acknowledgeable
|
|
523
|
+
// event, so it is ignored rather than stored.
|
|
524
|
+
function markCueSeen(id: string, status: string): void {
|
|
525
|
+
if (!isCueStatus(status) || cueSeen.get(id) === status) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
cueSeen.set(id, status);
|
|
529
|
+
// Evict the oldest entries so the live map obeys the same cap the parser
|
|
530
|
+
// does. dropTab prunes a session closed while this page was open, but one
|
|
531
|
+
// that vanished while the page was CLOSED leaves an entry nothing else
|
|
532
|
+
// collects; unbounded, that would eventually push the map past the cap and
|
|
533
|
+
// make the parser discard whatever it read last — dropping fresh
|
|
534
|
+
// acknowledgements to keep dead ones.
|
|
535
|
+
while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
|
|
536
|
+
const oldest = cueSeen.keys().next().value;
|
|
537
|
+
if (oldest === undefined) {
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
cueSeen.delete(oldest);
|
|
541
|
+
}
|
|
542
|
+
writeCueSeen(cueSeen);
|
|
543
|
+
}
|
|
544
|
+
// forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
|
|
545
|
+
// is a fresh cue: called when its status moves off the acknowledged value
|
|
546
|
+
// (a new working phase, an exit) and when the session goes away.
|
|
547
|
+
function forgetCueSeen(id: string): void {
|
|
548
|
+
if (cueSeen.delete(id)) {
|
|
549
|
+
writeCueSeen(cueSeen);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// OSC 9 Form B notifications: the one signal the specs say to surface
|
|
553
|
+
// OUTSIDE the page ("post a notification"), so a finished turn can reach a
|
|
554
|
+
// user who is looking at another app. All the policy — the suppression
|
|
555
|
+
// rule, the gesture-gated permission request, the silent tab-only
|
|
556
|
+
// degradation — lives in notify.ts; this feature only feeds it events and
|
|
557
|
+
// gestures.
|
|
558
|
+
const notifier = createNotifier(browserNotifierEnv());
|
|
471
559
|
function paintSwitchDot(): void {
|
|
472
560
|
// Reuse the per-tab status-dot colours (single source, css/05-tabs.css
|
|
473
|
-
// .wt-status-dot[data-status="input"|"done"]) instead of
|
|
474
|
-
// them: the dot has the .wt-status-dot class, so data-status
|
|
475
|
-
// exactly like the tabs' own dots.
|
|
561
|
+
// .wt-status-dot[data-status="input"|"done"|"crashed"]) instead of
|
|
562
|
+
// re-declaring them: the dot has the .wt-status-dot class, so data-status
|
|
563
|
+
// colours it exactly like the tabs' own dots. Its tooltip comes from the
|
|
564
|
+
// same wording map as theirs, so the aggregate cue names the state it is
|
|
565
|
+
// showing rather than being a coloured dot with no explanation.
|
|
476
566
|
if (switchNotify === "") {
|
|
477
567
|
delete swSwitchDot.dataset["status"];
|
|
568
|
+
swSwitchDot.removeAttribute("title");
|
|
478
569
|
} else {
|
|
479
570
|
swSwitchDot.dataset["status"] = switchNotify;
|
|
571
|
+
swSwitchDot.title = statusPhrase(switchNotify);
|
|
480
572
|
}
|
|
481
573
|
}
|
|
482
574
|
function clearSwitchNotify(): void {
|
|
@@ -640,6 +732,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
640
732
|
// same window title, or two tabs whose last submitted line was identical),
|
|
641
733
|
// the second and later get a " (k)" suffix in creation order, so the strip
|
|
642
734
|
// never shows two identical labels.
|
|
735
|
+
//
|
|
736
|
+
// The OSC 9;4 percentage prefix ("78% · one") is applied HERE, at render
|
|
737
|
+
// time, and stored nowhere: `display` stays the plain de-duplicated label,
|
|
738
|
+
// so de-duplication compares real names, the rename field opens on the real
|
|
739
|
+
// name, `list()` reports the real name, and clearing the progress needs no
|
|
740
|
+
// cleanup — the next paint simply stops adding it.
|
|
643
741
|
function relabelAll(): void {
|
|
644
742
|
// Count only real (non-fallback) labels, so multiple untitled tabs all
|
|
645
743
|
// read "New tab" without a numeric suffix.
|
|
@@ -662,11 +760,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
662
760
|
}
|
|
663
761
|
}
|
|
664
762
|
t.display = display;
|
|
665
|
-
|
|
666
|
-
t.
|
|
763
|
+
const rendered = progressLabel(display, shownProgress(t));
|
|
764
|
+
t.label.textContent = rendered;
|
|
765
|
+
// The accessible name carries the state as well as the label: the dots
|
|
766
|
+
// are aria-hidden decoration, so without this a screen-reader user
|
|
767
|
+
// cannot tell a working tab from a crashed one.
|
|
768
|
+
t.aria.setLabel(tabAccessibleName(rendered, statusOf(t)));
|
|
667
769
|
}
|
|
668
770
|
}
|
|
669
771
|
|
|
772
|
+
/** statusOf reads a tab's current status back off its dot, which is where
|
|
773
|
+
* applyStatus records it (the dot is the one element that always has it,
|
|
774
|
+
* on every chip site). */
|
|
775
|
+
function statusOf(t: Tab): string {
|
|
776
|
+
return t.dot.dataset["status"] ?? "idle";
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** shownProgress is a tab's percentage as it may currently be DISPLAYED:
|
|
780
|
+
* the last value the server reported, dropped once the process is gone.
|
|
781
|
+
* Those are the only two ways a percentage stops showing — the program's
|
|
782
|
+
* own OSC 9;4;0 (an explicit -1) and the process ending; see
|
|
783
|
+
* renderedProgress for why there is no third. */
|
|
784
|
+
function shownProgress(t: Tab): number {
|
|
785
|
+
return renderedProgress(statusOf(t), t.progress);
|
|
786
|
+
}
|
|
787
|
+
|
|
670
788
|
const termInput = (): HTMLElement | null =>
|
|
671
789
|
ctx.surface().querySelector<HTMLElement>(".term-input");
|
|
672
790
|
function focusInput(): void {
|
|
@@ -758,8 +876,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
758
876
|
function syncMobile(): void {
|
|
759
877
|
const idx = tabList.findIndex((t) => t.id === activeId);
|
|
760
878
|
const active = idx >= 0 ? tabList[idx] : undefined;
|
|
761
|
-
swLabel.textContent = active ? active.display : "";
|
|
762
|
-
paintStatusDot(swDot, active
|
|
879
|
+
swLabel.textContent = active ? progressLabel(active.display, shownProgress(active)) : "";
|
|
880
|
+
paintStatusDot(swDot, active ? statusOf(active) : "idle", active?.reports ?? false);
|
|
881
|
+
paintProgress(swProgress, active ? shownProgress(active) : PROGRESS_ABSENT);
|
|
763
882
|
// The aggregate background-notification cue rides the dedicated switch
|
|
764
883
|
// button's dot (paintSwitchDot), not the active surface (it did not fit
|
|
765
884
|
// there).
|
|
@@ -780,14 +899,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
780
899
|
});
|
|
781
900
|
return row;
|
|
782
901
|
}
|
|
783
|
-
// 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).
|
|
784
904
|
function updateRow(row: HTMLElement, t: Tab): void {
|
|
785
|
-
paintStatusDot(
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
t.
|
|
905
|
+
paintStatusDot(pick(row, ".wt-switcher-row-dot"), statusOf(t), t.reports);
|
|
906
|
+
paintProgress(pick(row, ".wt-progress-bar"), shownProgress(t));
|
|
907
|
+
pick(row, ".wt-switcher-row-label").textContent = progressLabel(
|
|
908
|
+
t.display,
|
|
909
|
+
shownProgress(t),
|
|
789
910
|
);
|
|
790
|
-
pick(row, ".wt-switcher-row-label").textContent = t.display;
|
|
791
911
|
}
|
|
792
912
|
// renderSwitcherList reconciles the expanded list to a row per OTHER tab
|
|
793
913
|
// (the active tab lives in the bar row), REUSING existing row elements
|
|
@@ -1003,6 +1123,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1003
1123
|
persistOrder();
|
|
1004
1124
|
}
|
|
1005
1125
|
|
|
1126
|
+
// The percentage is deliberately NOT written into the browser document
|
|
1127
|
+
// title. ConEmu's spec names the taskbar/title as a display site, and a
|
|
1128
|
+
// page's document title is the nearest analogue — but a page has exactly
|
|
1129
|
+
// ONE title while this UI multiplexes many sessions, so any rule for
|
|
1130
|
+
// choosing whose percentage it shows is arbitrary. Even restricted to the
|
|
1131
|
+
// active tab it churns a surface that doubles as the browser-tab label and
|
|
1132
|
+
// the bookmark name, and it invites reading one session's progress as the
|
|
1133
|
+
// window's. The per-chip prefix carries the same information without the
|
|
1134
|
+
// conflict, because each chip shows its own session. Do not add it back.
|
|
1135
|
+
|
|
1006
1136
|
// persistOrder stores the strip's current arrangement so it survives a
|
|
1007
1137
|
// reload. It hangs off syncChrome — the one function every list mutation
|
|
1008
1138
|
// already ends with (reorder, create, adopt, close, reconcile) — rather than
|
|
@@ -1100,6 +1230,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1100
1230
|
}
|
|
1101
1231
|
// Opening the list acknowledges any pending background-tab notification:
|
|
1102
1232
|
// the user is now looking at the tabs, so clear the switch button's dot.
|
|
1233
|
+
// The tray lists EVERY tab with its own status dot, so every latched tab
|
|
1234
|
+
// is acknowledged here, not just the cue's latest subject — several tabs
|
|
1235
|
+
// can be latched at once while the dot only ever showed the newest, and an
|
|
1236
|
+
// unacknowledged sibling would re-raise it on the next load.
|
|
1237
|
+
for (const t of tabList) {
|
|
1238
|
+
markCueSeen(t.id, t.dot.dataset["status"] ?? "");
|
|
1239
|
+
}
|
|
1103
1240
|
clearSwitchNotify();
|
|
1104
1241
|
renderSwitcherList();
|
|
1105
1242
|
setExpandedState(true);
|
|
@@ -1123,12 +1260,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1123
1260
|
}
|
|
1124
1261
|
}
|
|
1125
1262
|
|
|
1126
|
-
function addTabChrome(info:
|
|
1263
|
+
function addTabChrome(info: StatusRecord): Tab {
|
|
1127
1264
|
const el = fromHTML(TAB_HTML);
|
|
1128
1265
|
const label = el.querySelector<HTMLElement>(".wt-tab-label");
|
|
1129
1266
|
const dot = el.querySelector<HTMLElement>(".wt-tab-dot");
|
|
1267
|
+
const progressEl = el.querySelector<HTMLElement>(".wt-progress-bar");
|
|
1130
1268
|
const close = el.querySelector<HTMLButtonElement>(".wt-tab-close");
|
|
1131
|
-
if (!label || !dot || !close) {
|
|
1269
|
+
if (!label || !dot || !progressEl || !close) {
|
|
1132
1270
|
throw new Error("web-terminal-ui: tab chrome missing parts");
|
|
1133
1271
|
}
|
|
1134
1272
|
paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
|
|
@@ -1158,16 +1296,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1158
1296
|
el,
|
|
1159
1297
|
label,
|
|
1160
1298
|
dot,
|
|
1299
|
+
progressEl,
|
|
1300
|
+
// A percentage exists only on the status STREAM, so a tab adopted from
|
|
1301
|
+
// the REST list starts with none; the first status event fills it in.
|
|
1302
|
+
progress: normalizeProgress(info.progressValue),
|
|
1161
1303
|
aria,
|
|
1162
1304
|
scrollTop: 0,
|
|
1163
1305
|
following: true,
|
|
1164
1306
|
reports: reportsOf(info.reportsActivity),
|
|
1165
1307
|
};
|
|
1308
|
+
paintProgress(progressEl, renderedProgress(info.status, tab.progress));
|
|
1166
1309
|
// Set an initial label immediately (relabelAll refines it with de-dup
|
|
1167
1310
|
// once the tab is in tabList and syncChrome runs).
|
|
1168
1311
|
tab.display = baseLabel(tab).text;
|
|
1169
|
-
label.textContent = tab.display;
|
|
1170
|
-
aria.setLabel(
|
|
1312
|
+
label.textContent = progressLabel(tab.display, renderedProgress(info.status, tab.progress));
|
|
1313
|
+
aria.setLabel(tabAccessibleName(label.textContent, info.status));
|
|
1171
1314
|
el.addEventListener("click", (e) => {
|
|
1172
1315
|
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
1173
1316
|
return; // handled by the close button
|
|
@@ -1371,8 +1514,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1371
1514
|
activeId = next.id;
|
|
1372
1515
|
// Arriving on the tab that raised the switch-button cue resolves it
|
|
1373
1516
|
// (a swipe through the tabs must dismiss the dot, not only opening the
|
|
1374
|
-
// list).
|
|
1517
|
+
// list). Its current latch is acknowledged whether or not it was the
|
|
1518
|
+
// cue's subject: the terminal is now on screen, so a reload must not
|
|
1519
|
+
// notify about a state the user just looked at.
|
|
1375
1520
|
acknowledgeSwitchNotify(next.id);
|
|
1521
|
+
markCueSeen(next.id, next.dot.dataset["status"] ?? "");
|
|
1376
1522
|
try {
|
|
1377
1523
|
localStorage.setItem(ACTIVE_TAB_KEY, next.id);
|
|
1378
1524
|
} catch {
|
|
@@ -1639,14 +1785,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1639
1785
|
// stream / poll adopt the existing sessions below; without this they would
|
|
1640
1786
|
// render inert (blank, never connecting) until the user taps a tab. This
|
|
1641
1787
|
// is the sibling of the '+'-retry recovery the bootstrap already handles.
|
|
1642
|
-
// A live tab outranks an
|
|
1788
|
+
// A live tab outranks an ENDED one (its dot status is fed by the same
|
|
1643
1789
|
// SSE/poll that adopted it); a corpse is only auto-activated when nothing
|
|
1644
|
-
// else exists.
|
|
1790
|
+
// else exists. "Ended" covers both ways a process goes (exited and
|
|
1791
|
+
// crashed) — a crashed session is exactly as unable to produce output as a
|
|
1792
|
+
// cleanly exited one, so activating it would wedge the page the same way.
|
|
1645
1793
|
function ensureActive(): void {
|
|
1646
1794
|
if (activeId !== null) {
|
|
1647
1795
|
return;
|
|
1648
1796
|
}
|
|
1649
|
-
const first = tabList.find((t) => t
|
|
1797
|
+
const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
|
|
1650
1798
|
if (first) {
|
|
1651
1799
|
switchTo(first.id);
|
|
1652
1800
|
}
|
|
@@ -1749,8 +1897,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1749
1897
|
// the server reaping it cannot re-adopt the just-closed tab.
|
|
1750
1898
|
tombstones.add(id);
|
|
1751
1899
|
// A pending switch-button cue whose subject just closed is moot: clear
|
|
1752
|
-
// it rather than leaving a dot no tab visit can ever resolve.
|
|
1900
|
+
// it rather than leaving a dot no tab visit can ever resolve. Its stored
|
|
1901
|
+
// acknowledgement goes too — the session is gone, so the entry would only
|
|
1902
|
+
// sit in storage forever.
|
|
1753
1903
|
acknowledgeSwitchNotify(id);
|
|
1904
|
+
forgetCueSeen(id);
|
|
1905
|
+
notifier.forget(id); // no more notifications can arrive for a gone session
|
|
1754
1906
|
tab.aria.remove();
|
|
1755
1907
|
// Remove immediately (no exit animation): a lingering element made the
|
|
1756
1908
|
// "+" teleport after a delay, and made a last-tab replacement appear in
|
|
@@ -2132,12 +2284,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2132
2284
|
(e) => {
|
|
2133
2285
|
menuTouch = e.pointerType === "touch";
|
|
2134
2286
|
noteChromePress();
|
|
2287
|
+
notifier.gesture();
|
|
2135
2288
|
},
|
|
2136
2289
|
{ passive: true },
|
|
2137
2290
|
);
|
|
2138
2291
|
// The switcher's half of that snapshot (its rows and the x are buttons, so
|
|
2139
2292
|
// pressing one focuses it and blurs the terminal exactly as a chip does).
|
|
2140
|
-
|
|
2293
|
+
// It is also the mobile half of the notification-permission gesture.
|
|
2294
|
+
switcher.addEventListener(
|
|
2295
|
+
"pointerdown",
|
|
2296
|
+
() => {
|
|
2297
|
+
noteChromePress();
|
|
2298
|
+
notifier.gesture();
|
|
2299
|
+
},
|
|
2300
|
+
{ passive: true },
|
|
2301
|
+
);
|
|
2141
2302
|
function hideTabMenu(): void {
|
|
2142
2303
|
tabMenu.classList.remove("visible");
|
|
2143
2304
|
tabMenu.replaceChildren();
|
|
@@ -2249,13 +2410,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2249
2410
|
// one, and its title fields are interchangeable strings that as positional
|
|
2250
2411
|
// parameters would be trivially swappable. reports is the server's sticky reportsActivity flag, passed
|
|
2251
2412
|
// separately because it is normalised (reportsOf) before it gets here.
|
|
2252
|
-
function applyStatus(rec:
|
|
2413
|
+
function applyStatus(rec: StatusRecord, reports: boolean): void {
|
|
2253
2414
|
const t = tabList.find((tab) => tab.id === rec.id);
|
|
2254
2415
|
if (!t) {
|
|
2255
2416
|
return;
|
|
2256
2417
|
}
|
|
2257
2418
|
t.reports = reports;
|
|
2258
2419
|
paintStatusDot(t.dot, rec.status, reports);
|
|
2420
|
+
// The OSC 9;4 percentage. Only an explicitly PRESENT value updates the
|
|
2421
|
+
// tab: the polling fallback lists SessionInfo, which carries no
|
|
2422
|
+
// percentage at all, and reading that absence as "cleared" would blank a
|
|
2423
|
+
// live bar on every poll tick. The spec's own clear (OSC 9;4;0, or the
|
|
2424
|
+
// abbreviated form) arrives as an explicit -1; the only other clear is
|
|
2425
|
+
// the process ending, applied at render time (renderedProgress). No
|
|
2426
|
+
// timer, and nothing special about 100%.
|
|
2427
|
+
if (rec.progressValue !== undefined) {
|
|
2428
|
+
t.progress = normalizeProgress(rec.progressValue);
|
|
2429
|
+
}
|
|
2430
|
+
paintProgress(t.progressEl, shownProgress(t));
|
|
2431
|
+
// A session that reports activity is a session whose program speaks OSC 9,
|
|
2432
|
+
// so it is also one that may post a notification: arm the permission
|
|
2433
|
+
// request for the next user gesture (see notify.ts).
|
|
2434
|
+
if (reports) {
|
|
2435
|
+
notifier.arm();
|
|
2436
|
+
}
|
|
2259
2437
|
// Latest-wins background-tab notification for the switch button's dot: a
|
|
2260
2438
|
// background tab (not the active one) reaching "input" (needs you) or
|
|
2261
2439
|
// "done" (turn finished) raises the cue in that colour; each qualifying
|
|
@@ -2263,7 +2441,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2263
2441
|
// list opens. The active surface keeps its own needs-input cue (see
|
|
2264
2442
|
// syncMobile); this is the moved + upgraded, glanceable version on the
|
|
2265
2443
|
// dedicated button.
|
|
2266
|
-
|
|
2444
|
+
//
|
|
2445
|
+
// Both statuses are LATCHED server-side, so this runs on re-delivered
|
|
2446
|
+
// state as well as on genuine transitions: every SSE (re)open pushes a
|
|
2447
|
+
// snapshot and the poll fallback re-lists every few seconds. What makes a
|
|
2448
|
+
// dismissal stick across those is cueSeen — a latch this viewer already
|
|
2449
|
+
// acknowledged raises nothing, while a status that moved on drops the
|
|
2450
|
+
// acknowledgement so the next latch is a fresh cue.
|
|
2451
|
+
if (!isCueStatus(rec.status)) {
|
|
2452
|
+
forgetCueSeen(rec.id);
|
|
2453
|
+
} else if (rec.id === activeId) {
|
|
2454
|
+
// The user is looking at this terminal as it latches, so there is
|
|
2455
|
+
// nothing to notify — and nothing to re-raise once they move away.
|
|
2456
|
+
markCueSeen(rec.id, rec.status);
|
|
2457
|
+
} else if (cueSeen.get(rec.id) !== rec.status) {
|
|
2267
2458
|
switchNotify = rec.status;
|
|
2268
2459
|
switchNotifyId = rec.id;
|
|
2269
2460
|
paintSwitchDot();
|
|
@@ -2375,6 +2566,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2375
2566
|
applyStatus(s, reportsOf(s.reportsActivity));
|
|
2376
2567
|
ensureActive();
|
|
2377
2568
|
syncChrome();
|
|
2569
|
+
// A notification is an EVENT the engine delivers once, on the sweep
|
|
2570
|
+
// that first observes it, so it is handled here rather than in
|
|
2571
|
+
// applyStatus (which also runs on re-delivered STATE). Delivered after
|
|
2572
|
+
// syncChrome so the notification's title is the label the user would
|
|
2573
|
+
// see. The status record is the notification's carrier whether or not
|
|
2574
|
+
// the server has a classifier installed.
|
|
2575
|
+
notifier.deliver(s, {
|
|
2576
|
+
sessionIsActive: s.id === activeId,
|
|
2577
|
+
label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
|
|
2578
|
+
});
|
|
2378
2579
|
});
|
|
2379
2580
|
offStreamOpen = monitor.onStreamOpen?.(() => {
|
|
2380
2581
|
void reconcileOnce();
|
|
@@ -2908,13 +3109,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2908
3109
|
} catch {
|
|
2909
3110
|
sessions = [];
|
|
2910
3111
|
}
|
|
2911
|
-
// Spawn a fresh session unless a LIVE one is listed. An
|
|
2912
|
-
// is viewable history, not a working
|
|
2913
|
-
// every session has died (the agent
|
|
2914
|
-
// crash) onto a corpse was the
|
|
2915
|
-
// still adopted below (switch to
|
|
2916
|
-
// them by hand).
|
|
2917
|
-
if (!sessions.some((s) => s.status
|
|
3112
|
+
// Spawn a fresh session unless a LIVE one is listed. An ended session
|
|
3113
|
+
// (exited cleanly OR crashed) is viewable history, not a working
|
|
3114
|
+
// terminal — booting a page whose every session has died (the agent
|
|
3115
|
+
// exited: a sign-in dead end, a crash) onto a corpse was the
|
|
3116
|
+
// stuck-loading wedge. The dead ones are still adopted below (switch to
|
|
3117
|
+
// them to read their final screen; close them by hand).
|
|
3118
|
+
if (!sessions.some((s) => !isEndedStatus(s.status))) {
|
|
2918
3119
|
try {
|
|
2919
3120
|
sessions = [...sessions, await createSessionHonouringRetry(api, ctx, () => tornDown)];
|
|
2920
3121
|
} catch (err) {
|
|
@@ -2954,15 +3155,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2954
3155
|
// Activate the previously-active session if it still exists, else the
|
|
2955
3156
|
// first (oldest). Session ids are stable server-side, so a page reload
|
|
2956
3157
|
// reconnects to the tab the user left on instead of always the oldest.
|
|
2957
|
-
// Live sessions outrank exited
|
|
2958
|
-
// its session is still live (a reload used to restore
|
|
2959
|
-
// corpse of a died-while-away session and wedge
|
|
2960
|
-
// is the oldest LIVE tab. Only when nothing is
|
|
2961
|
-
// above failed too) does
|
|
2962
|
-
// with the "Session ended" banner beats a blank
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
);
|
|
3158
|
+
// Live sessions outrank ended ones (exited or crashed): the saved id is
|
|
3159
|
+
// honored only while its session is still live (a reload used to restore
|
|
3160
|
+
// straight onto the corpse of a died-while-away session and wedge
|
|
3161
|
+
// there), and the default is the oldest LIVE tab. Only when nothing is
|
|
3162
|
+
// live (the fresh-spawn above failed too) does a dead tab start — a
|
|
3163
|
+
// frozen final screen with the "Session ended" banner beats a blank
|
|
3164
|
+
// page.
|
|
3165
|
+
const liveIds = new Set(sessions.filter((s) => !isEndedStatus(s.status)).map((s) => s.id));
|
|
2966
3166
|
const oldestLive = tabList.find((t) => liveIds.has(t.id));
|
|
2967
3167
|
let startTab = oldestLive ?? tabList[0];
|
|
2968
3168
|
try {
|