@cplieger/web-terminal-ui 5.0.3 → 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 +3 -2
- package/src/features/activity-monitor.ts +4 -2
- package/src/features/tabs/index.ts +149 -42
- package/src/features/tabs/model.ts +166 -7
- 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"
|
|
@@ -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,25 @@ import {
|
|
|
29
29
|
createTombstones,
|
|
30
30
|
hasPinnedName,
|
|
31
31
|
isCueStatus,
|
|
32
|
+
isEndedStatus,
|
|
33
|
+
normalizeProgress,
|
|
32
34
|
orderedInsertIndex,
|
|
33
35
|
parseCueSeen,
|
|
34
36
|
parseTabOrder,
|
|
37
|
+
progressLabel,
|
|
38
|
+
renderedProgress,
|
|
35
39
|
sanitizePinnedName,
|
|
36
40
|
serializeCueSeen,
|
|
37
41
|
serializeTabOrder,
|
|
42
|
+
statusPhrase,
|
|
43
|
+
tabAccessibleName,
|
|
38
44
|
} from "./model.js";
|
|
45
|
+
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
39
46
|
import {
|
|
40
47
|
TAB_HTML,
|
|
41
48
|
kbButtonHTML,
|
|
42
49
|
newButtonHTML,
|
|
50
|
+
paintProgress,
|
|
43
51
|
paintStatusDot,
|
|
44
52
|
pick,
|
|
45
53
|
switchButtonHTML,
|
|
@@ -472,6 +480,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
472
480
|
const swInner = pick(switcher, ".wt-switcher-current-inner");
|
|
473
481
|
const swDot = pick(switcher, ".wt-switcher-dot");
|
|
474
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");
|
|
475
487
|
const swClose = pick(switcher, ".wt-switcher-current-close");
|
|
476
488
|
// The active-tab elements that translate together during a horizontal
|
|
477
489
|
// swipe: the content (dot + label) and the close (x). Moving both keeps the
|
|
@@ -491,8 +503,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
491
503
|
// Latest-wins notification state for the switch button's dot: overwritten
|
|
492
504
|
// by each qualifying background-tab event (applyStatus) and cleared when
|
|
493
505
|
// the list opens (expandSwitcher) or the raising tab is visited or closed
|
|
494
|
-
// (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden.
|
|
495
|
-
|
|
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 = "";
|
|
496
510
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
497
511
|
// swipe or any switch) acknowledges it without opening the list.
|
|
498
512
|
let switchNotifyId: string | null = null;
|
|
@@ -535,15 +549,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
535
549
|
writeCueSeen(cueSeen);
|
|
536
550
|
}
|
|
537
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());
|
|
538
559
|
function paintSwitchDot(): void {
|
|
539
560
|
// 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.
|
|
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.
|
|
543
566
|
if (switchNotify === "") {
|
|
544
567
|
delete swSwitchDot.dataset["status"];
|
|
568
|
+
swSwitchDot.removeAttribute("title");
|
|
545
569
|
} else {
|
|
546
570
|
swSwitchDot.dataset["status"] = switchNotify;
|
|
571
|
+
swSwitchDot.title = statusPhrase(switchNotify);
|
|
547
572
|
}
|
|
548
573
|
}
|
|
549
574
|
function clearSwitchNotify(): void {
|
|
@@ -707,6 +732,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
707
732
|
// same window title, or two tabs whose last submitted line was identical),
|
|
708
733
|
// the second and later get a " (k)" suffix in creation order, so the strip
|
|
709
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.
|
|
710
741
|
function relabelAll(): void {
|
|
711
742
|
// Count only real (non-fallback) labels, so multiple untitled tabs all
|
|
712
743
|
// read "New tab" without a numeric suffix.
|
|
@@ -729,11 +760,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
729
760
|
}
|
|
730
761
|
}
|
|
731
762
|
t.display = display;
|
|
732
|
-
|
|
733
|
-
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)));
|
|
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 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
|
+
|
|
737
788
|
const termInput = (): HTMLElement | null =>
|
|
738
789
|
ctx.surface().querySelector<HTMLElement>(".term-input");
|
|
739
790
|
function focusInput(): void {
|
|
@@ -825,8 +876,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
825
876
|
function syncMobile(): void {
|
|
826
877
|
const idx = tabList.findIndex((t) => t.id === activeId);
|
|
827
878
|
const active = idx >= 0 ? tabList[idx] : undefined;
|
|
828
|
-
swLabel.textContent = active ? active.display : "";
|
|
829
|
-
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);
|
|
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,14 +899,15 @@ 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
|
-
|
|
855
|
-
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),
|
|
856
910
|
);
|
|
857
|
-
pick(row, ".wt-switcher-row-label").textContent = t.display;
|
|
858
911
|
}
|
|
859
912
|
// renderSwitcherList reconciles the expanded list to a row per OTHER tab
|
|
860
913
|
// (the active tab lives in the bar row), REUSING existing row elements
|
|
@@ -1070,6 +1123,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1070
1123
|
persistOrder();
|
|
1071
1124
|
}
|
|
1072
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
|
+
|
|
1073
1136
|
// persistOrder stores the strip's current arrangement so it survives a
|
|
1074
1137
|
// reload. It hangs off syncChrome — the one function every list mutation
|
|
1075
1138
|
// already ends with (reorder, create, adopt, close, reconcile) — rather than
|
|
@@ -1197,12 +1260,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1197
1260
|
}
|
|
1198
1261
|
}
|
|
1199
1262
|
|
|
1200
|
-
function addTabChrome(info:
|
|
1263
|
+
function addTabChrome(info: StatusRecord): Tab {
|
|
1201
1264
|
const el = fromHTML(TAB_HTML);
|
|
1202
1265
|
const label = el.querySelector<HTMLElement>(".wt-tab-label");
|
|
1203
1266
|
const dot = el.querySelector<HTMLElement>(".wt-tab-dot");
|
|
1267
|
+
const progressEl = el.querySelector<HTMLElement>(".wt-progress-bar");
|
|
1204
1268
|
const close = el.querySelector<HTMLButtonElement>(".wt-tab-close");
|
|
1205
|
-
if (!label || !dot || !close) {
|
|
1269
|
+
if (!label || !dot || !progressEl || !close) {
|
|
1206
1270
|
throw new Error("web-terminal-ui: tab chrome missing parts");
|
|
1207
1271
|
}
|
|
1208
1272
|
paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
|
|
@@ -1232,16 +1296,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1232
1296
|
el,
|
|
1233
1297
|
label,
|
|
1234
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),
|
|
1235
1303
|
aria,
|
|
1236
1304
|
scrollTop: 0,
|
|
1237
1305
|
following: true,
|
|
1238
1306
|
reports: reportsOf(info.reportsActivity),
|
|
1239
1307
|
};
|
|
1308
|
+
paintProgress(progressEl, renderedProgress(info.status, tab.progress));
|
|
1240
1309
|
// Set an initial label immediately (relabelAll refines it with de-dup
|
|
1241
1310
|
// once the tab is in tabList and syncChrome runs).
|
|
1242
1311
|
tab.display = baseLabel(tab).text;
|
|
1243
|
-
label.textContent = tab.display;
|
|
1244
|
-
aria.setLabel(
|
|
1312
|
+
label.textContent = progressLabel(tab.display, renderedProgress(info.status, tab.progress));
|
|
1313
|
+
aria.setLabel(tabAccessibleName(label.textContent, info.status));
|
|
1245
1314
|
el.addEventListener("click", (e) => {
|
|
1246
1315
|
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
1247
1316
|
return; // handled by the close button
|
|
@@ -1716,14 +1785,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1716
1785
|
// stream / poll adopt the existing sessions below; without this they would
|
|
1717
1786
|
// render inert (blank, never connecting) until the user taps a tab. This
|
|
1718
1787
|
// is the sibling of the '+'-retry recovery the bootstrap already handles.
|
|
1719
|
-
// A live tab outranks an
|
|
1788
|
+
// A live tab outranks an ENDED one (its dot status is fed by the same
|
|
1720
1789
|
// SSE/poll that adopted it); a corpse is only auto-activated when nothing
|
|
1721
|
-
// 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.
|
|
1722
1793
|
function ensureActive(): void {
|
|
1723
1794
|
if (activeId !== null) {
|
|
1724
1795
|
return;
|
|
1725
1796
|
}
|
|
1726
|
-
const first = tabList.find((t) => t
|
|
1797
|
+
const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
|
|
1727
1798
|
if (first) {
|
|
1728
1799
|
switchTo(first.id);
|
|
1729
1800
|
}
|
|
@@ -1831,6 +1902,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1831
1902
|
// sit in storage forever.
|
|
1832
1903
|
acknowledgeSwitchNotify(id);
|
|
1833
1904
|
forgetCueSeen(id);
|
|
1905
|
+
notifier.forget(id); // no more notifications can arrive for a gone session
|
|
1834
1906
|
tab.aria.remove();
|
|
1835
1907
|
// Remove immediately (no exit animation): a lingering element made the
|
|
1836
1908
|
// "+" teleport after a delay, and made a last-tab replacement appear in
|
|
@@ -2212,12 +2284,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2212
2284
|
(e) => {
|
|
2213
2285
|
menuTouch = e.pointerType === "touch";
|
|
2214
2286
|
noteChromePress();
|
|
2287
|
+
notifier.gesture();
|
|
2215
2288
|
},
|
|
2216
2289
|
{ passive: true },
|
|
2217
2290
|
);
|
|
2218
2291
|
// The switcher's half of that snapshot (its rows and the x are buttons, so
|
|
2219
2292
|
// pressing one focuses it and blurs the terminal exactly as a chip does).
|
|
2220
|
-
|
|
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
|
+
);
|
|
2221
2302
|
function hideTabMenu(): void {
|
|
2222
2303
|
tabMenu.classList.remove("visible");
|
|
2223
2304
|
tabMenu.replaceChildren();
|
|
@@ -2329,13 +2410,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2329
2410
|
// one, and its title fields are interchangeable strings that as positional
|
|
2330
2411
|
// parameters would be trivially swappable. reports is the server's sticky reportsActivity flag, passed
|
|
2331
2412
|
// separately because it is normalised (reportsOf) before it gets here.
|
|
2332
|
-
function applyStatus(rec:
|
|
2413
|
+
function applyStatus(rec: StatusRecord, reports: boolean): void {
|
|
2333
2414
|
const t = tabList.find((tab) => tab.id === rec.id);
|
|
2334
2415
|
if (!t) {
|
|
2335
2416
|
return;
|
|
2336
2417
|
}
|
|
2337
2418
|
t.reports = reports;
|
|
2338
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
|
+
}
|
|
2339
2437
|
// Latest-wins background-tab notification for the switch button's dot: a
|
|
2340
2438
|
// background tab (not the active one) reaching "input" (needs you) or
|
|
2341
2439
|
// "done" (turn finished) raises the cue in that colour; each qualifying
|
|
@@ -2468,6 +2566,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2468
2566
|
applyStatus(s, reportsOf(s.reportsActivity));
|
|
2469
2567
|
ensureActive();
|
|
2470
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
|
+
});
|
|
2471
2579
|
});
|
|
2472
2580
|
offStreamOpen = monitor.onStreamOpen?.(() => {
|
|
2473
2581
|
void reconcileOnce();
|
|
@@ -3001,13 +3109,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3001
3109
|
} catch {
|
|
3002
3110
|
sessions = [];
|
|
3003
3111
|
}
|
|
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
|
|
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))) {
|
|
3011
3119
|
try {
|
|
3012
3120
|
sessions = [...sessions, await createSessionHonouringRetry(api, ctx, () => tornDown)];
|
|
3013
3121
|
} catch (err) {
|
|
@@ -3047,15 +3155,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3047
3155
|
// Activate the previously-active session if it still exists, else the
|
|
3048
3156
|
// first (oldest). Session ids are stable server-side, so a page reload
|
|
3049
3157
|
// 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
|
-
);
|
|
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));
|
|
3059
3166
|
const oldestLive = tabList.find((t) => liveIds.has(t.id));
|
|
3060
3167
|
let startTab = oldestLive ?? tabList[0];
|
|
3061
3168
|
try {
|
|
@@ -14,11 +14,157 @@ import type { TabHandle } from "../../kernel/types.js";
|
|
|
14
14
|
// contract has one home. Type-only import: erases at compile, no runtime dep.
|
|
15
15
|
export type { SessionInfo };
|
|
16
16
|
|
|
17
|
+
/** A status record as the tabs feature consumes it: the session's REST wire
|
|
18
|
+
* shape plus the percentage that exists only on the status STREAM
|
|
19
|
+
* (SessionStatus.progressValue). Both status sources flow through one code path
|
|
20
|
+
* this way, and the optionality is meaningful: the polling fallback lists
|
|
21
|
+
* SessionInfo with no percentage at all, which means "no information" and must
|
|
22
|
+
* not be read as "the percentage was cleared". */
|
|
23
|
+
export type StatusRecord = SessionInfo & { readonly progressValue?: number };
|
|
24
|
+
|
|
25
|
+
// The status vocabulary, module-private on purpose: every consumer asks one of
|
|
26
|
+
// the predicates below (isEndedStatus / statusRevealsDot / isCueStatus /
|
|
27
|
+
// statusPhrase) instead of comparing strings itself, so a status's
|
|
28
|
+
// MEANING has exactly one home. Adding a status means adding it to the
|
|
29
|
+
// predicates that should include it, not to a comparison at each call site.
|
|
30
|
+
//
|
|
17
31
|
// The server-side status of a session whose process has exited (mirrors the
|
|
18
32
|
// engine's terminal.StatusExited). Such a session is viewable history — its
|
|
19
33
|
// final screen replays and the kernel shows "Session ended" — but it can never
|
|
20
34
|
// produce output again, so session selection prefers live sessions everywhere.
|
|
21
|
-
|
|
35
|
+
const STATUS_EXITED = "exited";
|
|
36
|
+
|
|
37
|
+
/** The other end-of-process status (engine terminal.StatusCrashed): a non-zero
|
|
38
|
+
* exit status, or a terminating signal the program was not asked for. A
|
|
39
|
+
* server-initiated end (a closed tab, the idle reaper, a server shutdown) is
|
|
40
|
+
* reported as "exited", so a routine restart never paints as a failure. Like
|
|
41
|
+
* "exited" it is terminal: nothing clears it, and it outranks every progress
|
|
42
|
+
* state. */
|
|
43
|
+
const STATUS_CRASHED = "crashed";
|
|
44
|
+
|
|
45
|
+
/** OSC 9;4 progress state 2, the error state (iTerm2 semantics). A STATE, not an
|
|
46
|
+
* event: it persists until the program reports another progress state, or the
|
|
47
|
+
* process dies (which outranks it). */
|
|
48
|
+
const STATUS_FAILED = "failed";
|
|
49
|
+
|
|
50
|
+
/** OSC 9;4 progress state 4, the warning state (iTerm2 semantics; ConEmu calls
|
|
51
|
+
* the same state paused). Same persistence rules as STATUS_FAILED. */
|
|
52
|
+
const STATUS_WARNING = "warning";
|
|
53
|
+
|
|
54
|
+
/** isEndedStatus reports whether a status means the session's process is GONE,
|
|
55
|
+
* whichever way it went. Session selection asks this — not `=== "exited"` —
|
|
56
|
+
* because a crashed session is exactly as dead as an exited one: reloading onto
|
|
57
|
+
* either is the stuck-loading wedge the bootstrap's live-session preference
|
|
58
|
+
* exists to avoid. */
|
|
59
|
+
export function isEndedStatus(status: string): boolean {
|
|
60
|
+
return status === STATUS_EXITED || status === STATUS_CRASHED;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The statuses whose dot must be visible even if the server never set the
|
|
64
|
+
* session's sticky reportsActivity flag. `warning` and `failed` are OSC 9;4
|
|
65
|
+
* states, so the flag is set for them in practice and this only floors it;
|
|
66
|
+
* `crashed` is the case that needs it — a plain shell that dies badly has
|
|
67
|
+
* reported no activity in its life, and its red dot is the one thing the user
|
|
68
|
+
* most needs to see. An ordinary `exited` deliberately stays gated: a clean end
|
|
69
|
+
* is not news. */
|
|
70
|
+
export function statusRevealsDot(status: string): boolean {
|
|
71
|
+
return status === STATUS_WARNING || status === STATUS_FAILED || status === STATUS_CRASHED;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** statusPhrase is the human wording for a status, used for BOTH the dot's hover
|
|
75
|
+
* tooltip and the suffix in a tab's accessible name — one definition, so what a
|
|
76
|
+
* sighted user reads on hover and what a screen reader announces cannot
|
|
77
|
+
* disagree. An unknown status (a newer server: the wire is parsed, not
|
|
78
|
+
* validated) falls back to the raw value rather than being hidden. */
|
|
79
|
+
export function statusPhrase(status: string): string {
|
|
80
|
+
switch (status) {
|
|
81
|
+
case "working":
|
|
82
|
+
return "working";
|
|
83
|
+
case STATUS_WARNING:
|
|
84
|
+
return "warning reported";
|
|
85
|
+
case STATUS_FAILED:
|
|
86
|
+
return "error reported";
|
|
87
|
+
case "input":
|
|
88
|
+
return "waiting for you";
|
|
89
|
+
case "done":
|
|
90
|
+
return "turn finished";
|
|
91
|
+
case STATUS_EXITED:
|
|
92
|
+
return "session ended";
|
|
93
|
+
case STATUS_CRASHED:
|
|
94
|
+
return "process crashed";
|
|
95
|
+
case "idle":
|
|
96
|
+
case "":
|
|
97
|
+
return "idle";
|
|
98
|
+
default:
|
|
99
|
+
return status;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** PROGRESS_ABSENT is "no percentage" (the engine's own marker). Absence is -1,
|
|
104
|
+
* never 0: a session that reported nothing is not a session at 0%, and the two
|
|
105
|
+
* must render differently (no bar at all vs an empty bar). */
|
|
106
|
+
export const PROGRESS_ABSENT = -1;
|
|
107
|
+
|
|
108
|
+
/** normalizeProgress cleans a percentage off the wire into PROGRESS_ABSENT or
|
|
109
|
+
* 0-100. The engine already clamps, so this is the untrusted-JSON guard: a
|
|
110
|
+
* string, a NaN, an Infinity or a negative all mean "absent" rather than
|
|
111
|
+
* throwing or rendering a nonsense bar, and an out-of-range high value clamps
|
|
112
|
+
* rather than overflowing the chip. */
|
|
113
|
+
export function normalizeProgress(value: unknown): number {
|
|
114
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
115
|
+
return PROGRESS_ABSENT;
|
|
116
|
+
}
|
|
117
|
+
return Math.min(100, Math.round(value));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** renderedProgress is the percentage a status may actually SHOW. The tab keeps
|
|
121
|
+
* the last value the server reported; this is the one place that decides whether
|
|
122
|
+
* it is still meaningful, so the clearing rule has a single home.
|
|
123
|
+
*
|
|
124
|
+
* There are exactly TWO clears, and both are honest — neither invents a state
|
|
125
|
+
* change the program never made:
|
|
126
|
+
*
|
|
127
|
+
* 1. The program's own clear: OSC 9;4;0, or the abbreviated OSC 9;4, which the
|
|
128
|
+
* engine reports as percentage -1. That is the spec path, and the value
|
|
129
|
+
* arriving as PROGRESS_ABSENT is all it takes.
|
|
130
|
+
* 2. The process is GONE (exited or crashed) — checked here. A dead process's
|
|
131
|
+
* progress is meaningless, and nothing will ever clear it, so the bar and
|
|
132
|
+
* the title prefix drop with the process.
|
|
133
|
+
*
|
|
134
|
+
* There is deliberately NO third clear. 100% is not special: state 1 at 100 is a
|
|
135
|
+
* STATE that persists, and the progress channel carries no "done" signal at all
|
|
136
|
+
* (our `done` comes from the notification channel, a separate mechanism), so a
|
|
137
|
+
* program that pins 100 and goes quiet keeps its bar — the same "persists until
|
|
138
|
+
* the program says otherwise" property the indeterminate state already has.
|
|
139
|
+
* There is also no TIMEOUT: a timer would assert a change the program never
|
|
140
|
+
* reported. And a `done`/`input` latch does not clear it either, because a latch
|
|
141
|
+
* says something about the notification channel, not about the progress the
|
|
142
|
+
* program last reported. (In practice this is not a stale-bar risk for the one
|
|
143
|
+
* agent that drives it: kiro-cli never sends state 1, and sends state 0 when it
|
|
144
|
+
* goes idle.) */
|
|
145
|
+
export function renderedProgress(status: string, progress: number): number {
|
|
146
|
+
return isEndedStatus(status) ? PROGRESS_ABSENT : progress;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** progressLabel prefixes a rendered label with its percentage ("78% · one").
|
|
150
|
+
*
|
|
151
|
+
* Applied at RENDER time and never stored: `Tab.title`, `Tab.pinnedTitle` and
|
|
152
|
+
* `Tab.display` stay the plain name, so a manual rename round-trips unprefixed,
|
|
153
|
+
* an automatic title is untouched, de-duplication still compares real labels,
|
|
154
|
+
* and clearing the progress needs no cleanup — the next paint simply stops
|
|
155
|
+
* adding the prefix. */
|
|
156
|
+
export function progressLabel(display: string, progress: number): string {
|
|
157
|
+
return progress < 0 ? display : `${String(progress)}% · ${display}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** tabAccessibleName is a tab's announced name: its rendered label plus the
|
|
161
|
+
* session's state. The dots are aria-hidden="true" (they are decoration with no
|
|
162
|
+
* text), so before this the status was invisible to a screen reader — a fact a
|
|
163
|
+
* red crashed state makes worse. This deliberately changes the announced text
|
|
164
|
+
* for the existing states too. */
|
|
165
|
+
export function tabAccessibleName(renderedLabel: string, status: string): string {
|
|
166
|
+
return `${renderedLabel} — ${statusPhrase(status)}`;
|
|
167
|
+
}
|
|
22
168
|
|
|
23
169
|
// localStorage key for the last active session id, so a page reload reopens the
|
|
24
170
|
// tab the user left on rather than always defaulting to the oldest one.
|
|
@@ -87,15 +233,20 @@ export function serializeTabOrder(ids: readonly string[]): string {
|
|
|
87
233
|
return JSON.stringify(ids.slice(0, MAX_PERSISTED_TAB_ORDER));
|
|
88
234
|
}
|
|
89
235
|
|
|
90
|
-
/** The
|
|
91
|
-
* cue
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
236
|
+
/** The session statuses that raise the mobile switcher's aggregate attention
|
|
237
|
+
* cue. The rule is "only states that want the user — it's like a notification":
|
|
238
|
+
* a background terminal blocked on the user ("input"), one whose turn finished
|
|
239
|
+
* ("done"), and one whose process died badly ("crashed"). The three animated
|
|
240
|
+
* progress states are deliberately excluded — working / warning / failed are
|
|
241
|
+
* ongoing and informational, and an animated dot pinned to the switch button
|
|
242
|
+
* would nag with nothing to act on. Declared once here so the raise test and
|
|
243
|
+
* the acknowledgement store cannot disagree about which statuses are
|
|
244
|
+
* cue-worthy. */
|
|
245
|
+
export type CueStatus = "input" | "done" | "crashed";
|
|
95
246
|
|
|
96
247
|
/** isCueStatus narrows a raw server status to a cue-worthy one. */
|
|
97
248
|
export function isCueStatus(status: string): status is CueStatus {
|
|
98
|
-
return status === "input" || status === "done";
|
|
249
|
+
return status === "input" || status === "done" || status === STATUS_CRASHED;
|
|
99
250
|
}
|
|
100
251
|
|
|
101
252
|
/** localStorage key for the cues this viewer has already SEEN: session id -> the
|
|
@@ -221,6 +372,14 @@ export interface Tab {
|
|
|
221
372
|
el: HTMLElement;
|
|
222
373
|
label: HTMLElement;
|
|
223
374
|
dot: HTMLElement;
|
|
375
|
+
/** The chip's determinate progress bar (the 2px line on its bottom edge).
|
|
376
|
+
* Present on every chip, hidden until a percentage exists. */
|
|
377
|
+
progressEl: HTMLElement;
|
|
378
|
+
/** The last OSC 9;4 percentage the server reported for this session, or
|
|
379
|
+
* PROGRESS_ABSENT. Held raw: whether it is currently SHOWN is a render-time
|
|
380
|
+
* question (renderedProgress), and the prefix it produces is never stored on
|
|
381
|
+
* any title field. */
|
|
382
|
+
progress: number;
|
|
224
383
|
aria: TabHandle;
|
|
225
384
|
scrollTop: number;
|
|
226
385
|
following: boolean;
|