@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 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 and
147
- a `78% ·` prefix on the tab label; it is deliberately never written to the
148
- browser document title, since one page title cannot represent several
149
- sessions. An OSC 9 notification is posted as a browser
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
 
@@ -38,7 +38,11 @@
38
38
  user-select: none;
39
39
  }
40
40
 
41
- :where(.wt-root) .wt-btn:active {
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
- :where(.wt-root) .wt-scroll-bottom:active {
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
- :where(.wt-root) .wt-tab-new:active {
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
 
@@ -339,7 +339,10 @@
339
339
  }
340
340
  }
341
341
 
342
- :where(.wt-root) .wt-switcher-new:active {
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.0",
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.5.6",
60
- "knip": "6.29.0",
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",
@@ -1,7 +1,8 @@
1
- // Small shared helper for features: build a chrome element from a static,
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. No interpolation ever passes through
4
- // here, so it carries no injection surface.
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 show press
43
- // feedback; preventDefault keeps focus on the terminal. click is kept for
44
- // keyboard activation. jump is idempotent, so the pair is safe.
45
- const onDown = (e: PointerEvent): void => {
46
- e.preventDefault();
47
- jump();
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
- // Keep the hidden terminal textarea focused: a bar button needs no focus
341
- // of its own, and letting the tap shift focus makes iOS/iPadOS consume
342
- // the FIRST tap to blur the input (so "+" only fired create on the second
343
- // tap — the reported double-tap). preventDefault on pointerdown fires the
344
- // action on the first tap; switchTo then re-focuses the input for the new
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
- // First-tap focus retention (see makeNewButton): keep the terminal
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 preventDefaults pointerdown for first-tap focus
380
- // retention (keeps the terminal textarea focused so it fires on the first
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.addEventListener("pointerdown", (e) => {
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
- 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)));
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 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. */
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 ? progressLabel(active.display, shownProgress(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 = progressLabel(
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 = progressLabel(tab.display, renderedProgress(info.status, tab.progress));
1313
- aria.setLabel(tabAccessibleName(label.textContent, info.status));
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
- * There are exactly TWO clears, and both are honest — neither invents a state
125
- * change the program never made:
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 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.
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
- * (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.
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. 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.) */
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 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}`;
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. 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)}`;
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 `window.Notification` used here. Constructed and dropped: the
19
- * browser owns the notification's life, and we keep no handle (nothing in this
20
- * UI closes or replaces one programmatically). */
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
- ) => unknown;
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
- deliver(ev: NotificationEvent, view: { sessionIsActive: boolean; label: string }): boolean;
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