@cplieger/web-terminal-ui 5.1.0 → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/css/10-primitives.css +5 -1
- package/css/21-scroll-to-bottom.css +5 -1
- package/css/30-tabs.css +24 -5
- package/css/31-switcher.css +4 -1
- package/package.json +3 -3
- package/src/features/dom.ts +63 -3
- package/src/features/scroll-to-bottom.ts +8 -9
- package/src/features/tabs/index.ts +36 -41
- package/src/features/tabs/model.ts +50 -32
- package/src/features/tabs/notify.ts +57 -8
package/README.md
CHANGED
|
@@ -143,12 +143,20 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
|
|
|
143
143
|
(working, warning, error), the frozen ringed needs-input dot, two static discs
|
|
144
144
|
(a green finished turn, a red crashed process), and two hollow ones (idle and a
|
|
145
145
|
dim ended session). A
|
|
146
|
-
reported percentage additionally renders a 2px determinate bar on the chip
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
146
|
+
reported percentage additionally renders a 2px determinate bar on the chip,
|
|
147
|
+
shown only while one of those three progress states is the session's current
|
|
148
|
+
status. The number itself is announced in the tab's accessible name and drawn
|
|
149
|
+
nowhere: no terminal emulator puts a percentage next to a tab label, and a chip
|
|
150
|
+
that shrinks toward a 100px floor has no width to spare. It is likewise never
|
|
151
|
+
written to the browser document title, since one page title cannot represent
|
|
152
|
+
several sessions. An OSC 9 notification is posted as a browser
|
|
150
153
|
notification when the user is not already looking at that terminal (permission
|
|
151
154
|
is requested on a user gesture; a denial degrades silently to the tab dots).
|
|
155
|
+
Clicking one switches to the session that raised it: the Notifications API's
|
|
156
|
+
own click default already focuses the page, and the handler supplies the
|
|
157
|
+
in-page half. These are non-persistent notifications, so per the API the
|
|
158
|
+
constructor throws on most mobile browsers, where the notification degrades to
|
|
159
|
+
the tab dots.
|
|
152
160
|
|
|
153
161
|
### Options
|
|
154
162
|
|
package/css/10-primitives.css
CHANGED
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
user-select: none;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/* Press. The pointerdown-cancelling bar buttons (holdFocusOnPress in
|
|
42
|
+
features/dom.ts) never enter :active in Firefox, so they paint .wt-pressed on
|
|
43
|
+
themselves and every press rule in this package matches either. */
|
|
44
|
+
:where(.wt-root) .wt-btn:active,
|
|
45
|
+
:where(.wt-root) .wt-btn.wt-pressed {
|
|
42
46
|
background: var(--text-active);
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
border-color: color-mix(in oklch, var(--text) 35%, transparent);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/* Press fills the background. .wt-pressed rides along: this button cancels its
|
|
42
|
+
pointerdown default and so never enters :active in Firefox (holdFocusOnPress
|
|
43
|
+
in features/dom.ts). */
|
|
44
|
+
:where(.wt-root) .wt-scroll-bottom:active,
|
|
45
|
+
:where(.wt-root) .wt-scroll-bottom.wt-pressed {
|
|
42
46
|
background: var(--text-active);
|
|
43
47
|
}
|
|
44
48
|
|
package/css/30-tabs.css
CHANGED
|
@@ -81,6 +81,16 @@
|
|
|
81
81
|
overlays (.wt-tab-ghost, .wt-tab-menu) are children of .wt-root, not of a
|
|
82
82
|
chip, so this cannot capture them. */
|
|
83
83
|
position: relative;
|
|
84
|
+
/* ...and the clip that keeps that bar inside the chip. A 2px line at
|
|
85
|
+
`left: 0; bottom: 0` has square ends, so without this it pokes out through
|
|
86
|
+
both rounded bottom corners and reads as running edge to edge. The clip is
|
|
87
|
+
applied to the PADDING box with the chip's inner radius (--radius-inner less
|
|
88
|
+
the 1px border), which is exactly the shape the bar must follow, and it is
|
|
89
|
+
what the other two chip sites already do (.wt-switcher-current,
|
|
90
|
+
.wt-switcher-row-select). Nothing else in the chip needs to escape: the
|
|
91
|
+
dot's ripple stays within the --sp-2 padding at its visible amplitude, and
|
|
92
|
+
the rename field's 2px/1px-offset focus ring sits well inside it. */
|
|
93
|
+
overflow: hidden;
|
|
84
94
|
/* ONE height for every control in the strip: the chip, the "+" and the
|
|
85
95
|
keyboard button all resolve to --touch-target, so a chip and a button can
|
|
86
96
|
never disagree about how tall the row is. Before this the chip's height was
|
|
@@ -337,8 +347,11 @@
|
|
|
337
347
|
|
|
338
348
|
/* Press: brighten the hover fill with the active-tab fg (the
|
|
339
349
|
.wt-switcher-current press recipe — a lighter press over an already-filled
|
|
340
|
-
control), keeping the accent glyph.
|
|
341
|
-
|
|
350
|
+
control), keeping the accent glyph. .wt-pressed rides along because this
|
|
351
|
+
button cancels its pointerdown default and so never enters :active in Firefox
|
|
352
|
+
(holdFocusOnPress in features/dom.ts). */
|
|
353
|
+
:where(.wt-root) .wt-tab-new:active,
|
|
354
|
+
:where(.wt-root) .wt-tab-new.wt-pressed {
|
|
342
355
|
background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 12%);
|
|
343
356
|
}
|
|
344
357
|
|
|
@@ -632,7 +645,15 @@
|
|
|
632
645
|
Painted only while a percentage actually exists: the element carries the
|
|
633
646
|
`hidden` attribute otherwise (paintProgress), so an absent value (-1) renders
|
|
634
647
|
NO bar rather than a zero-width or empty one. Its width is the only inline
|
|
635
|
-
style — the percentage is data, not styling.
|
|
648
|
+
style — the percentage is data, not styling.
|
|
649
|
+
|
|
650
|
+
The rounded ENDS are the chip's job, not this rule's: each chip site clips its
|
|
651
|
+
own overflow (.wt-tab here, .wt-switcher-current and .wt-switcher-row-select in
|
|
652
|
+
31-switcher.css), so the bar is cut to the chip's inner corner radius at both
|
|
653
|
+
ends. A radius on the bar itself cannot do that job — a 2px-tall box scales
|
|
654
|
+
every radius down to 2px under the CSS overlap rule, so it would trace a 2px
|
|
655
|
+
curve against the chip's --radius-inner one and still square off wherever the
|
|
656
|
+
percentage stops short of the edge. */
|
|
636
657
|
:where(.wt-root) .wt-progress-bar {
|
|
637
658
|
position: absolute;
|
|
638
659
|
left: 0;
|
|
@@ -640,8 +661,6 @@
|
|
|
640
661
|
height: 2px;
|
|
641
662
|
max-width: 100%;
|
|
642
663
|
background: var(--accent);
|
|
643
|
-
/* Follow the chip's rounded bottom-left corner instead of poking out of it. */
|
|
644
|
-
border-bottom-left-radius: inherit;
|
|
645
664
|
pointer-events: none;
|
|
646
665
|
}
|
|
647
666
|
|
package/css/31-switcher.css
CHANGED
|
@@ -339,7 +339,10 @@
|
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
-
|
|
342
|
+
/* Press. .wt-pressed rides along: this button cancels its pointerdown default
|
|
343
|
+
and so never enters :active in Firefox (holdFocusOnPress in features/dom.ts). */
|
|
344
|
+
:where(.wt-root) .wt-switcher-new:active,
|
|
345
|
+
:where(.wt-root) .wt-switcher-new.wt-pressed {
|
|
343
346
|
background: var(--tab-hover-bg);
|
|
344
347
|
}
|
|
345
348
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "5.1
|
|
3
|
+
"version": "5.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"eslint": "10.8.0",
|
|
57
57
|
"fast-check": "4.9.0",
|
|
58
58
|
"happy-dom": "20.11.1",
|
|
59
|
-
"html-validate": "11.
|
|
60
|
-
"knip": "6.
|
|
59
|
+
"html-validate": "11.6.0",
|
|
60
|
+
"knip": "6.30.0",
|
|
61
61
|
"prettier": "3.9.6",
|
|
62
62
|
"stylelint": "17.14.1",
|
|
63
63
|
"stylelint-config-standard": "40.0.0",
|
package/src/features/dom.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Small shared
|
|
1
|
+
// Small shared helpers for features: building a chrome element from a static,
|
|
2
2
|
// trusted HTML string (icons, buttons) via a <template> clone, the same pattern
|
|
3
|
-
// the kernel uses for its core subtree
|
|
4
|
-
// here, so it carries no injection
|
|
3
|
+
// the kernel uses for its core subtree, and the press wiring the bar buttons
|
|
4
|
+
// share. No interpolation ever passes through here, so it carries no injection
|
|
5
|
+
// surface.
|
|
5
6
|
|
|
6
7
|
/** Parse a static HTML string and return its first element. */
|
|
7
8
|
export function fromHTML(html: string): HTMLElement {
|
|
@@ -13,3 +14,62 @@ export function fromHTML(html: string): HTMLElement {
|
|
|
13
14
|
}
|
|
14
15
|
return el;
|
|
15
16
|
}
|
|
17
|
+
|
|
18
|
+
/** The press class the chrome buttons wired by holdFocusOnPress paint on
|
|
19
|
+
* themselves. Every `:active` press rule for such a button pairs with it (see
|
|
20
|
+
* the CSS contract test), because those buttons never enter `:active` in
|
|
21
|
+
* Firefox. */
|
|
22
|
+
export const PRESSED_CLASS = "wt-pressed";
|
|
23
|
+
|
|
24
|
+
/** holdFocusOnPress wires a chrome button that must NOT take the keyboard off
|
|
25
|
+
* the terminal, and paints its own press state because of it.
|
|
26
|
+
*
|
|
27
|
+
* Focus: the hidden terminal textarea stays focused. A bar button needs no
|
|
28
|
+
* focus of its own, and letting the press shift focus makes iOS/iPadOS consume
|
|
29
|
+
* the FIRST tap to blur the input (so "+" only fired on the second tap — the
|
|
30
|
+
* reported double-tap), while on a desktop it parks the keyboard on the button,
|
|
31
|
+
* where the strip's own keydown handling eats arrows as "switch tab" and Delete
|
|
32
|
+
* as "close tab". Cancelling pointerdown's default prevents both.
|
|
33
|
+
*
|
|
34
|
+
* Press feedback: cancelling that default is also what suppresses the browser's
|
|
35
|
+
* OWN activation state — Firefox ties `:active` to the mousedown default action,
|
|
36
|
+
* and preventDefault on pointerdown suppresses the compatibility mousedown
|
|
37
|
+
* entirely (measured on Firefox 140: no mousedown event, `matches(":active")`
|
|
38
|
+
* false, background unchanged for as long as the button is held; Chromium and
|
|
39
|
+
* WebKit still apply it). So these buttons — and ONLY these — carry the press
|
|
40
|
+
* class, and their CSS press rules match either. That is why the two jobs live
|
|
41
|
+
* in one helper: a caller that cancels the default without painting the class
|
|
42
|
+
* ships a control with no press feedback in one engine, which is exactly the
|
|
43
|
+
* defect this replaced.
|
|
44
|
+
*
|
|
45
|
+
* The class is dropped on release, on cancel, and when the pointer leaves while
|
|
46
|
+
* held (a press dragged off the button is not an activation), mirroring what
|
|
47
|
+
* `:active` does on its own. A non-primary button press paints nothing, also
|
|
48
|
+
* matching it. The release listeners live on `window` for the life of the press
|
|
49
|
+
* (an `AbortController`, the pattern the switcher gesture uses) rather than on
|
|
50
|
+
* the button: `.wt-scroll-bottom` acts on the press and hides itself, so by the
|
|
51
|
+
* time the release lands the button is `display: none` and receives no
|
|
52
|
+
* `pointerup` at all — bound to the button, the class would stay painted and the
|
|
53
|
+
* control would come back already lit. */
|
|
54
|
+
export function holdFocusOnPress(btn: HTMLElement): void {
|
|
55
|
+
let release: (() => void) | null = null;
|
|
56
|
+
btn.addEventListener("pointerdown", (e) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
if (e.button !== 0 || release !== null) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
btn.classList.add(PRESSED_CLASS);
|
|
62
|
+
const held = new AbortController();
|
|
63
|
+
release = () => {
|
|
64
|
+
held.abort();
|
|
65
|
+
release = null;
|
|
66
|
+
btn.classList.remove(PRESSED_CLASS);
|
|
67
|
+
};
|
|
68
|
+
const end = (): void => {
|
|
69
|
+
release?.();
|
|
70
|
+
};
|
|
71
|
+
window.addEventListener("pointerup", end, { signal: held.signal });
|
|
72
|
+
window.addEventListener("pointercancel", end, { signal: held.signal });
|
|
73
|
+
btn.addEventListener("pointerleave", end, { signal: held.signal });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// scroll.scrollToBottom.
|
|
6
6
|
|
|
7
7
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
8
|
-
import { fromHTML } from "./dom.js";
|
|
8
|
+
import { fromHTML, holdFocusOnPress } from "./dom.js";
|
|
9
9
|
|
|
10
10
|
const BUTTON_HTML = `
|
|
11
11
|
<button type="button" class="wt-btn wt-scroll-bottom" aria-label="Scroll to bottom">
|
|
@@ -39,14 +39,13 @@ export function scrollToBottom(): TerminalFeature {
|
|
|
39
39
|
surface.scrollTo({ top: surface.scrollHeight, behavior: "smooth" });
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
// pointerdown (like the toolbar keys) so touch devices
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
btn.addEventListener("pointerdown", onDown);
|
|
42
|
+
// pointerdown (like the toolbar keys) so touch devices get the jump on the
|
|
43
|
+
// press rather than the release; click is kept for keyboard activation.
|
|
44
|
+
// jump is idempotent, so the pair is safe. holdFocusOnPress keeps the
|
|
45
|
+
// keyboard on the terminal across the press and paints the press class the
|
|
46
|
+
// cancelled default costs this button.
|
|
47
|
+
holdFocusOnPress(btn);
|
|
48
|
+
btn.addEventListener("pointerdown", jump);
|
|
50
49
|
btn.addEventListener("click", jump);
|
|
51
50
|
|
|
52
51
|
// Visible only while scrolled up; the region reflects it via a class.
|
|
@@ -12,7 +12,7 @@ import { LineStore, modes } from "@cplieger/web-terminal-engine";
|
|
|
12
12
|
import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
|
|
13
13
|
import type { ActivityMonitorApi } from "../activity-monitor.js";
|
|
14
14
|
import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
15
|
-
import { fromHTML } from "../dom.js";
|
|
15
|
+
import { fromHTML, holdFocusOnPress } from "../dom.js";
|
|
16
16
|
import { createClickSwallow, placeMenuAt } from "../menu-position.js";
|
|
17
17
|
import type { CueStatus, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
18
18
|
import {
|
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
orderedInsertIndex,
|
|
35
35
|
parseCueSeen,
|
|
36
36
|
parseTabOrder,
|
|
37
|
-
progressLabel,
|
|
38
37
|
renderedProgress,
|
|
39
38
|
sanitizePinnedName,
|
|
40
39
|
serializeCueSeen,
|
|
@@ -337,16 +336,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
337
336
|
// and reflects its open state on every keyboard button.
|
|
338
337
|
function makeNewButton(cls: string): HTMLElement {
|
|
339
338
|
const btn = fromHTML(newButtonHTML(cls));
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
// tab (see focusAfterSwitch). It does NOT make one press one activation —
|
|
346
|
-
// that is create()'s in-flight coalescing, for the opposite failure.
|
|
347
|
-
btn.addEventListener("pointerdown", (e) => {
|
|
348
|
-
e.preventDefault();
|
|
349
|
-
});
|
|
339
|
+
// Keeps the keyboard on the terminal and paints its own press state;
|
|
340
|
+
// holdFocusOnPress explains why those two are one job. It does NOT make
|
|
341
|
+
// one press one activation — that is create()'s in-flight coalescing,
|
|
342
|
+
// for the opposite failure.
|
|
343
|
+
holdFocusOnPress(btn);
|
|
350
344
|
btn.addEventListener("click", () => {
|
|
351
345
|
void create();
|
|
352
346
|
});
|
|
@@ -354,11 +348,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
354
348
|
}
|
|
355
349
|
function makeKbButton(cls: string): HTMLElement {
|
|
356
350
|
const btn = fromHTML(kbButtonHTML(cls));
|
|
357
|
-
|
|
358
|
-
// textarea focused so the toggle fires on the first tap on iOS/iPadOS.
|
|
359
|
-
btn.addEventListener("pointerdown", (e) => {
|
|
360
|
-
e.preventDefault();
|
|
361
|
-
});
|
|
351
|
+
holdFocusOnPress(btn);
|
|
362
352
|
btn.addEventListener("click", () => {
|
|
363
353
|
const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
|
|
364
354
|
if (!kb) {
|
|
@@ -376,15 +366,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
376
366
|
}
|
|
377
367
|
// makeSwitchButton builds the mobile switcher's dedicated open/close
|
|
378
368
|
// button (its notification dot is painted by paintSwitchDot). Like the
|
|
379
|
-
// other bar buttons it
|
|
380
|
-
//
|
|
381
|
-
// tap on iOS/iPadOS); its click toggles the list (toggleSwitcher opens when
|
|
382
|
-
// collapsed, closes when expanded).
|
|
369
|
+
// other bar buttons it goes through holdFocusOnPress; its click toggles the
|
|
370
|
+
// list (toggleSwitcher opens when collapsed, closes when expanded).
|
|
383
371
|
function makeSwitchButton(): HTMLElement {
|
|
384
372
|
const btn = fromHTML(switchButtonHTML("wt-switcher-switch wt-btn"));
|
|
385
|
-
btn
|
|
386
|
-
e.preventDefault();
|
|
387
|
-
});
|
|
373
|
+
holdFocusOnPress(btn);
|
|
388
374
|
btn.addEventListener("click", () => {
|
|
389
375
|
toggleSwitcher();
|
|
390
376
|
});
|
|
@@ -760,12 +746,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
760
746
|
}
|
|
761
747
|
}
|
|
762
748
|
t.display = display;
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
// cannot tell a working tab from a crashed one
|
|
768
|
-
|
|
749
|
+
t.label.textContent = display;
|
|
750
|
+
// The accessible name carries the state and the percentage as well as
|
|
751
|
+
// the label: the dots are aria-hidden decoration and the progress bar
|
|
752
|
+
// is a 2px line with no text, so without this a screen-reader user
|
|
753
|
+
// cannot tell a working tab from a crashed one, or hear how far along
|
|
754
|
+
// a determinate one is.
|
|
755
|
+
t.aria.setLabel(tabAccessibleName(display, statusOf(t), shownProgress(t)));
|
|
769
756
|
}
|
|
770
757
|
}
|
|
771
758
|
|
|
@@ -777,10 +764,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
777
764
|
}
|
|
778
765
|
|
|
779
766
|
/** shownProgress is a tab's percentage as it may currently be DISPLAYED:
|
|
780
|
-
* the last value the server reported, dropped
|
|
781
|
-
* Those are the only two ways a percentage
|
|
782
|
-
* own OSC 9;4;0 (an explicit -1) and
|
|
783
|
-
* renderedProgress for why there is no third. */
|
|
767
|
+
* the last value the server reported, dropped unless the current status is
|
|
768
|
+
* one the progress channel owns. Those are the only two ways a percentage
|
|
769
|
+
* stops showing — the program's own OSC 9;4;0 (an explicit -1) and a status
|
|
770
|
+
* from another channel; see renderedProgress for why there is no third. */
|
|
784
771
|
function shownProgress(t: Tab): number {
|
|
785
772
|
return renderedProgress(statusOf(t), t.progress);
|
|
786
773
|
}
|
|
@@ -876,7 +863,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
876
863
|
function syncMobile(): void {
|
|
877
864
|
const idx = tabList.findIndex((t) => t.id === activeId);
|
|
878
865
|
const active = idx >= 0 ? tabList[idx] : undefined;
|
|
879
|
-
swLabel.textContent = active ?
|
|
866
|
+
swLabel.textContent = active ? active.display : "";
|
|
880
867
|
paintStatusDot(swDot, active ? statusOf(active) : "idle", active?.reports ?? false);
|
|
881
868
|
paintProgress(swProgress, active ? shownProgress(active) : PROGRESS_ABSENT);
|
|
882
869
|
// The aggregate background-notification cue rides the dedicated switch
|
|
@@ -904,10 +891,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
904
891
|
function updateRow(row: HTMLElement, t: Tab): void {
|
|
905
892
|
paintStatusDot(pick(row, ".wt-switcher-row-dot"), statusOf(t), t.reports);
|
|
906
893
|
paintProgress(pick(row, ".wt-progress-bar"), shownProgress(t));
|
|
907
|
-
pick(row, ".wt-switcher-row-label").textContent =
|
|
908
|
-
t.display,
|
|
909
|
-
shownProgress(t),
|
|
910
|
-
);
|
|
894
|
+
pick(row, ".wt-switcher-row-label").textContent = t.display;
|
|
911
895
|
}
|
|
912
896
|
// renderSwitcherList reconciles the expanded list to a row per OTHER tab
|
|
913
897
|
// (the active tab lives in the bar row), REUSING existing row elements
|
|
@@ -1309,8 +1293,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1309
1293
|
// Set an initial label immediately (relabelAll refines it with de-dup
|
|
1310
1294
|
// once the tab is in tabList and syncChrome runs).
|
|
1311
1295
|
tab.display = baseLabel(tab).text;
|
|
1312
|
-
label.textContent =
|
|
1313
|
-
aria.setLabel(
|
|
1296
|
+
label.textContent = tab.display;
|
|
1297
|
+
aria.setLabel(
|
|
1298
|
+
tabAccessibleName(tab.display, info.status, renderedProgress(info.status, tab.progress)),
|
|
1299
|
+
);
|
|
1314
1300
|
el.addEventListener("click", (e) => {
|
|
1315
1301
|
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
1316
1302
|
return; // handled by the close button
|
|
@@ -2575,6 +2561,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2575
2561
|
notifier.deliver(s, {
|
|
2576
2562
|
sessionIsActive: s.id === activeId,
|
|
2577
2563
|
label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
|
|
2564
|
+
// What clicking the notification does. The platform's own click
|
|
2565
|
+
// default focuses the page; this supplies the other half, landing the
|
|
2566
|
+
// user on the session that raised the notification rather than on
|
|
2567
|
+
// whichever tab they last left active. switchTo already no-ops on an
|
|
2568
|
+
// id that is gone, which is the case that matters here: the prompt may
|
|
2569
|
+
// have been answered, or the tab closed, between post and click.
|
|
2570
|
+
activate: () => {
|
|
2571
|
+
switchTo(s.id);
|
|
2572
|
+
},
|
|
2578
2573
|
});
|
|
2579
2574
|
});
|
|
2580
2575
|
offStreamOpen = monitor.onStreamOpen?.(() => {
|
|
@@ -117,53 +117,71 @@ export function normalizeProgress(value: unknown): number {
|
|
|
117
117
|
return Math.min(100, Math.round(value));
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** statusOwnsProgress reports whether a status is one the program's OSC 9;4
|
|
121
|
+
* progress channel is currently speaking through. Only these may show a bar.
|
|
122
|
+
*
|
|
123
|
+
* The tab keeps the last percentage the server reported, and the server keeps
|
|
124
|
+
* sending it (correctly — a progress state persists until the program changes
|
|
125
|
+
* it), so this predicate is what stops that value from being painted underneath a
|
|
126
|
+
* status that came from somewhere else entirely. `input` and `done` come from the
|
|
127
|
+
* NOTIFICATION channel; `idle` is the absence of a progress state; `exited` and
|
|
128
|
+
* `crashed` are process facts. A percentage under any of those is a claim about
|
|
129
|
+
* a different channel than the one the reader is looking at. */
|
|
130
|
+
export function statusOwnsProgress(status: string): boolean {
|
|
131
|
+
return status === "working" || status === STATUS_FAILED || status === STATUS_WARNING;
|
|
132
|
+
}
|
|
133
|
+
|
|
120
134
|
/** renderedProgress is the percentage a status may actually SHOW. The tab keeps
|
|
121
135
|
* the last value the server reported; this is the one place that decides whether
|
|
122
136
|
* it is still meaningful, so the clearing rule has a single home.
|
|
123
137
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
138
|
+
* Two things clear it, and both are honest — neither invents a state change the
|
|
139
|
+
* program never made:
|
|
126
140
|
*
|
|
127
141
|
* 1. The program's own clear: OSC 9;4;0, or the abbreviated OSC 9;4, which the
|
|
128
142
|
* engine reports as percentage -1. That is the spec path, and the value
|
|
129
143
|
* arriving as PROGRESS_ABSENT is all it takes.
|
|
130
|
-
* 2. The
|
|
131
|
-
*
|
|
132
|
-
*
|
|
144
|
+
* 2. The status is not one the progress channel owns (statusOwnsProgress) —
|
|
145
|
+
* checked here. That covers a dead process, whose progress is meaningless
|
|
146
|
+
* and which nothing will ever clear, and equally a `done` or `input` latch,
|
|
147
|
+
* which is the notification channel talking. Measured: kiro-cli parks
|
|
148
|
+
* progress state 4 at its context-usage percentage when idle, so a finished
|
|
149
|
+
* turn used to paint a green done dot beside a 72% bar — a number about the
|
|
150
|
+
* context window, rendered as how far along the work was.
|
|
133
151
|
*
|
|
134
152
|
* 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
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* the program says otherwise" property the indeterminate state already has.
|
|
153
|
+
* STATE that persists, and the progress channel carries no "done" signal at all,
|
|
154
|
+
* so a program that pins 100 and goes quiet keeps its bar — the same "persists
|
|
155
|
+
* until the program says otherwise" property the indeterminate state already has.
|
|
139
156
|
* There is also no TIMEOUT: a timer would assert a change the program never
|
|
140
|
-
* reported
|
|
141
|
-
*
|
|
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.) */
|
|
157
|
+
* reported (Ghostty's ~15s one does, which is why it also asks emitters to
|
|
158
|
+
* refresh once a second; nothing here requires a keep-alive). */
|
|
145
159
|
export function renderedProgress(status: string, progress: number): number {
|
|
146
|
-
return
|
|
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}`;
|
|
160
|
+
return statusOwnsProgress(status) ? progress : PROGRESS_ABSENT;
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
/** tabAccessibleName is a tab's announced name: its rendered label plus the
|
|
161
|
-
* session's state
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
* session's state, and the percentage when one is showing.
|
|
165
|
+
*
|
|
166
|
+
* The percentage is announced but never DRAWN as text. No terminal emulator puts
|
|
167
|
+
* a number next to a tab label — Windows Terminal draws a ring over the tab icon,
|
|
168
|
+
* Ghostty and kitty a bar on a window edge, ConEmu a percentage in the WINDOW
|
|
169
|
+
* title — and the visible number cost label width in a chip that already shrinks
|
|
170
|
+
* to a 100px floor. The bar answers "how far" for the eye; this answers it for a
|
|
171
|
+
* screen reader, which cannot see the bar at all (it is decoration with no text).
|
|
172
|
+
*
|
|
173
|
+
* Pass PROGRESS_ABSENT for progress to announce the state alone. Callers hand in
|
|
174
|
+
* renderedProgress's output, so a percentage the tab is not showing is not
|
|
175
|
+
* announced either. */
|
|
176
|
+
export function tabAccessibleName(
|
|
177
|
+
renderedLabel: string,
|
|
178
|
+
status: string,
|
|
179
|
+
progress: number = PROGRESS_ABSENT,
|
|
180
|
+
): string {
|
|
181
|
+
const state = statusPhrase(status);
|
|
182
|
+
return progress < 0
|
|
183
|
+
? `${renderedLabel} — ${state}`
|
|
184
|
+
: `${renderedLabel} — ${state}, ${String(progress)}%`;
|
|
167
185
|
}
|
|
168
186
|
|
|
169
187
|
// localStorage key for the last active session id, so a page reload reopens the
|
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
// BROWSER notifications. The spec names the display mode ("post a
|
|
3
3
|
// notification"), which is why this one signal is not tab-only chrome: a
|
|
4
4
|
// finished turn or a permission prompt has to be able to reach a user who is
|
|
5
|
-
// looking at another app.
|
|
5
|
+
// looking at another app. Clicking one takes the user to the session that raised
|
|
6
|
+
// it (see deliver).
|
|
7
|
+
//
|
|
8
|
+
// NON-PERSISTENT notifications (the `new Notification()` constructor), which is
|
|
9
|
+
// the standard surface available to a page with no service worker. Their lifetime
|
|
10
|
+
// is tied to the page's, which suits this app exactly: the notification is about
|
|
11
|
+
// a live PTY the page is attached to, and there is nothing useful to click once
|
|
12
|
+
// the page is gone. Per the Notifications API the constructor throws on most
|
|
13
|
+
// mobile browsers, where persistent (service-worker) notifications are the only
|
|
14
|
+
// route; that throw is caught below and degrades to tab-only chrome. Registering
|
|
15
|
+
// a service worker for an app that is otherwise entirely live is a cost this does
|
|
16
|
+
// not pay — mobile support for the standard constructor is the platforms' side of
|
|
17
|
+
// the contract.
|
|
6
18
|
//
|
|
7
19
|
// A notification is an EVENT, not a state (that is the structural difference
|
|
8
20
|
// from OSC 9;4 progress, which is a state and drives the status dot): the engine
|
|
@@ -15,13 +27,20 @@
|
|
|
15
27
|
// browserNotifierEnv() below. The notification text NEVER enters the DOM — see
|
|
16
28
|
// the note on deliver().
|
|
17
29
|
|
|
18
|
-
/** The slice of `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
30
|
+
/** The slice of a non-persistent `Notification` instance this feature uses: the
|
|
31
|
+
* `click` handler and `close()`. The instance is no longer discarded — attaching
|
|
32
|
+
* a click handler is what lets a notification finish its job (see deliver).
|
|
33
|
+
* Module-private: it is the ctor type's return shape, not a consumer surface. */
|
|
34
|
+
interface NotificationLike {
|
|
35
|
+
onclick: ((event: Event) => void) | null;
|
|
36
|
+
close: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The slice of `window.Notification` used here. */
|
|
21
40
|
export type NotificationCtorLike = new (
|
|
22
41
|
title: string,
|
|
23
42
|
options?: { body?: string; tag?: string },
|
|
24
|
-
) =>
|
|
43
|
+
) => NotificationLike;
|
|
25
44
|
|
|
26
45
|
/** The capabilities a notifier needs, all injected. `permission` and `request`
|
|
27
46
|
* are read as plain strings rather than the DOM's NotificationPermission union:
|
|
@@ -71,8 +90,15 @@ export interface Notifier {
|
|
|
71
90
|
/** Deliver one status event's notification, if it carries a new one. Returns
|
|
72
91
|
* true when a browser notification was actually posted (false covers every
|
|
73
92
|
* degraded path: no notification in the event, a repeat, suppression, no API,
|
|
74
|
-
* no permission, a throwing constructor).
|
|
75
|
-
|
|
93
|
+
* no permission, a throwing constructor).
|
|
94
|
+
*
|
|
95
|
+
* `view.activate` is what the notification's click performs in the page. The
|
|
96
|
+
* caller supplies it, so this module still knows nothing about sessions, tabs
|
|
97
|
+
* or the DOM. */
|
|
98
|
+
deliver(
|
|
99
|
+
ev: NotificationEvent,
|
|
100
|
+
view: { sessionIsActive: boolean; label: string; activate: () => void },
|
|
101
|
+
): boolean;
|
|
76
102
|
/** Note that this page has a session capable of notifying, which is what makes
|
|
77
103
|
* a permission prompt worth raising at all. */
|
|
78
104
|
arm(): void;
|
|
@@ -135,7 +161,30 @@ export function createNotifier(env: NotifierEnv): Notifier {
|
|
|
135
161
|
// label. The engine already strips control/bidi runes and clamps the
|
|
136
162
|
// length; that is not widened here. If this ever needs an in-page
|
|
137
163
|
// surface, it must use textContent.
|
|
138
|
-
new Ctor(view.label, { body: text, tag: ev.id });
|
|
164
|
+
const posted = new Ctor(view.label, { body: text, tag: ev.id });
|
|
165
|
+
// A notification the user clicks should land them on the terminal that
|
|
166
|
+
// raised it. Half of that is the click event's OWN default behaviour —
|
|
167
|
+
// per the Notifications API, activating a non-persistent notification
|
|
168
|
+
// moves focus to the viewport of its browsing context — so this handler
|
|
169
|
+
// deliberately does NOT preventDefault() and does NOT call
|
|
170
|
+
// window.focus(): the platform already brings the page forward, and the
|
|
171
|
+
// only missing half is switching to the right session inside it. That
|
|
172
|
+
// asymmetry is the whole bug this fixes: the page came forward and then
|
|
173
|
+
// showed whichever tab the user happened to leave active.
|
|
174
|
+
//
|
|
175
|
+
// NOT the `navigate` option, which is the declarative alternative and
|
|
176
|
+
// bypasses the click event entirely: it NAVIGATES, which for a live
|
|
177
|
+
// terminal means dropping every WebSocket and replaying scrollback to
|
|
178
|
+
// arrive somewhere the page already was. A same-page switch is both
|
|
179
|
+
// cheaper and correct. Do not "modernise" this into `navigate`.
|
|
180
|
+
posted.onclick = (): void => {
|
|
181
|
+
view.activate();
|
|
182
|
+
// The user has just acted on it, so it is no longer relevant — the
|
|
183
|
+
// documented reason to close one. (Closing is only wrong when used as
|
|
184
|
+
// a display timer, which also strips it from the notification tray
|
|
185
|
+
// before the user can interact.)
|
|
186
|
+
posted.close();
|
|
187
|
+
};
|
|
139
188
|
return true;
|
|
140
189
|
} catch {
|
|
141
190
|
// Safari throws on `new Notification` outside an installed PWA even when
|