@cplieger/web-terminal-ui 8.6.0-dev.1 → 8.6.0-dev.11

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.
@@ -19,7 +19,9 @@ export type StatusRecord = SessionInfo & {
19
19
  };
20
20
 
21
21
  // The status vocabulary is module-private so a status's MEANING has one home:
22
- // every consumer asks a predicate below instead of comparing strings.
22
+ // every consumer asks a predicate below instead of comparing strings. The
23
+ // protocol states named below are the built-in sources; a classified OSC 9
24
+ // notification may set any status.
23
25
  //
24
26
  // An exited session is viewable history that can never produce output again, so
25
27
  // session selection prefers live sessions everywhere.
@@ -30,11 +32,14 @@ const STATUS_EXITED = "exited";
30
32
  * restart never paints as a failure. Terminal, like "exited". */
31
33
  const STATUS_CRASHED = "crashed";
32
34
 
33
- /** OSC 9;4 progress state 2, the error state (iTerm2 semantics). A STATE that
34
- * persists until the program reports another, or the process dies. */
35
+ /** An error the program reported: OSC 7501 state `error`, or OSC 9;4 progress
36
+ * state 2 (iTerm2 semantics). A STATE that persists until the program reports
37
+ * another, or the process dies. */
35
38
  const STATUS_FAILED = "failed";
36
39
 
37
- /** OSC 9;4 progress state 4, the warning state (iTerm2 semantics). */
40
+ /** OSC 9;4 progress state 4, the warning state (iTerm2 semantics). OSC 9;4
41
+ * cannot set it once the session has sent an OSC 7501 report, until a
42
+ * terminal reset (RIS): OSC 7501 outranks OSC 9;4 until a RIS forgets it. */
38
43
  const STATUS_WARNING = "warning";
39
44
 
40
45
  /** Whether the session's process is GONE, whichever way it went: reloading onto
@@ -51,8 +56,9 @@ export function statusRevealsDot(status: string): boolean {
51
56
  }
52
57
 
53
58
  /** The human wording for a status, for BOTH the dot's tooltip and the tab's
54
- * accessible name, so they cannot disagree. An unknown status (a newer server)
55
- * falls back to the raw value rather than being hidden. */
59
+ * accessible name, so they cannot disagree. A status outside the built-in
60
+ * vocabulary (a host classifier's, or a newer server's) falls back to the raw
61
+ * value rather than being hidden. */
56
62
  export function statusPhrase(status: string): string {
57
63
  switch (status) {
58
64
  case "working":
@@ -94,22 +100,16 @@ export function normalizeProgress(value: unknown): number {
94
100
  return Math.min(100, Math.round(value));
95
101
  }
96
102
 
97
- /** Whether the OSC 9;4 progress channel is speaking through this status; only
98
- * these may show a bar. The server keeps sending the last percentage while the
99
- * state persists, and `input`, `done`, `idle`, `exited` and `crashed` come from
100
- * other channels, so a percentage under them is a claim about the wrong one. */
101
- export function statusOwnsProgress(status: string): boolean {
102
- return status === "working" || status === STATUS_FAILED || status === STATUS_WARNING;
103
- }
104
-
105
- /** The percentage a status may SHOW. Two things clear it: the program's own
106
- * OSC 9;4;0 (arriving as PROGRESS_ABSENT) and a status the channel does not own
107
- * (kiro-cli parks state 4 at its context-usage percentage when idle, which once
108
- * painted a done dot beside a 72% bar). Deliberately no third clear: 100% is a
109
- * STATE that persists, and a timeout would assert a change the program never
110
- * reported. */
103
+ /** The percentage a status may SHOW. Under any live status it is the server's
104
+ * value, because the status and the percentage can come from different
105
+ * channels: OSC 7501 or a notification can set the status while OSC 9;4
106
+ * supplies the percentage. Two things clear it: the server's PROGRESS_ABSENT
107
+ * (the program cleared or never reported a percentage, e.g. OSC 9;4;0) and an
108
+ * ended process, whose percentage nothing will ever clear. No timeout clears
109
+ * it: 100% is a STATE that persists, and a timeout would assert a change the
110
+ * program never reported. */
111
111
  export function renderedProgress(status: string, progress: number): number {
112
- return statusOwnsProgress(status) ? progress : PROGRESS_ABSENT;
112
+ return isEndedStatus(status) ? PROGRESS_ABSENT : progress;
113
113
  }
114
114
 
115
115
  // The secondary-activity vocabulary: a host-reported background activity that
@@ -183,7 +183,7 @@ export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
183
183
  * a notification. `working` and `warning` are ongoing and informational, so a
184
184
  * dot for them would nag with nothing to act on; `failed` is a parked result the
185
185
  * program does not revisit, so it belongs. Declared once so the raise test, the
186
- * acknowledgement store and every attention sink cannot disagree. */
186
+ * acknowledgement and every attention sink cannot disagree. */
187
187
  export type CueStatus = "input" | "done" | "crashed" | "failed";
188
188
 
189
189
  /** Severity order, most severe first, AND the complete set isCueStatus tests
@@ -221,15 +221,13 @@ export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
221
221
 
222
222
  /** The cue statuses meaning the turn is OVER WITHOUT INCIDENT, the only ones a
223
223
  * live background task may blank. `done` is the whole set: every other cue must
224
- * reach the viewer AT ONCE, and `failed` here is OSC 9;4 state 2 rather than a
225
- * turn verdict. */
224
+ * reach the viewer AT ONCE, and `failed` is an error the program reported,
225
+ * not a clean end of the turn. */
226
226
  const SETTLED_CUES: ReadonlySet<string> = new Set<CueStatus>(["done"]);
227
227
 
228
228
  /** The status the CUE surfaces see: the session's own, unless a settled turn's
229
- * background task is still running, in which case "". "" and NOT "idle": "" means
230
- * NO INFORMATION, so the seen map is left alone, where "idle" is a real non-cue
231
- * state that would forget the acknowledgement and re-raise the cue when the task
232
- * ends. The status DOT keeps rendering the real state. */
229
+ * background task is still running, in which case "" (no cue). The status DOT
230
+ * keeps rendering the real state. */
233
231
  export function foldedCueStatus(status: string, activity: string): string {
234
232
  if (!SETTLED_CUES.has(status) || normalizeActivity(activity) === "") {
235
233
  return status;
@@ -237,25 +235,29 @@ export function foldedCueStatus(status: string, activity: string): string {
237
235
  return "";
238
236
  }
239
237
 
240
- /** Whether a session's CURRENT status is a cue this viewer has not acknowledged;
241
- * the one predicate behind the raise and the attention count. A latch arriving
242
- * on a shown tab is acknowledged at arrival, so no caller needs a special case. */
243
- export function isUnseenCue(
244
- status: string,
245
- id: string,
246
- seen: ReadonlyMap<string, CueStatus>,
247
- ): status is CueStatus {
248
- return isCueStatus(status) && seen.get(id) !== status;
249
- }
250
-
251
238
  /** What the cue fold reads per tab; structural, so a caller passes its own tab
252
- * objects. */
239
+ * objects. `status` is the FOLDED status (foldedCueStatus). */
253
240
  interface CueCandidate {
254
- readonly id: string;
255
241
  readonly status: string;
242
+ /** the server's number for that status's occurrence */
243
+ readonly statusSeq: number;
244
+ /** the server-confirmed seen floor */
245
+ readonly seenSeq: number;
246
+ /** this page's optimistic read floor, 0 when none */
247
+ readonly ackSeq: number;
248
+ }
249
+
250
+ /** Whether a session's CURRENT status is a cue no reader has acknowledged; the
251
+ * one predicate behind the raise and the attention count. The floor is the
252
+ * higher of what the server confirmed and what this page has already sent, so
253
+ * a read paints at once and a lagging record cannot relight it. */
254
+ export function isUnread<T extends CueCandidate>(
255
+ tab: T,
256
+ ): tab is T & { readonly status: CueStatus } {
257
+ return isCueStatus(tab.status) && tab.statusSeq > Math.max(tab.seenSeq, tab.ackSeq);
256
258
  }
257
259
 
258
- /** The unseen cues over the tab list: a COUNT for the title and the badge, one
260
+ /** The unread cues over the tab list: a COUNT for the title and the badge, one
259
261
  * WORST for the icon. A count is set-valued and needs no rule for choosing among
260
262
  * sessions; severity is a total order, so the icon's choice is not arbitrary.
261
263
  * Neither names a session, the standing constraint on a page-wide surface. */
@@ -264,16 +266,12 @@ interface CueSummary {
264
266
  readonly worst: CueStatus | "";
265
267
  }
266
268
 
267
- /** Fold the tab list and this viewer's acknowledgements into the one summary
268
- * every attention surface renders. */
269
- export function summarizeCues(
270
- tabs: readonly CueCandidate[],
271
- seen: ReadonlyMap<string, CueStatus>,
272
- ): CueSummary {
269
+ /** Fold the tab list into the one summary every attention surface renders. */
270
+ export function summarizeCues(tabs: readonly CueCandidate[]): CueSummary {
273
271
  let count = 0;
274
272
  let worst: CueStatus | "" = "";
275
273
  for (const tab of tabs) {
276
- if (!isUnseenCue(tab.status, tab.id, seen)) {
274
+ if (!isUnread(tab)) {
277
275
  continue;
278
276
  }
279
277
  count += 1;
@@ -282,52 +280,10 @@ export function summarizeCues(
282
280
  return { count, worst };
283
281
  }
284
282
 
285
- /** localStorage key for the cues this viewer has SEEN: session id -> the
286
- * acknowledged status. Remembered because `input` and `done` are latched on the
287
- * server and re-delivered in every snapshot, so a dismissed dot came back on
288
- * every reload; client-side because "seen" is the VIEWER's, not the session's;
289
- * per session because several background tabs can hold a latch at once. */
290
- export const CUE_SEEN_KEY = "wt-cue-seen";
291
-
292
- /** Bound on the acknowledgement map, so a corrupted or hostile stored value
293
- * cannot make the restore path do unbounded work. */
294
- export const MAX_PERSISTED_CUE_SEEN = 200;
295
-
296
- /** Read stored acknowledgements into a clean map, dropping anything untrusted: a
297
- * lost acknowledgement only re-lights a dot the user can dismiss again. */
298
- export function parseCueSeen(raw: string | null): Map<string, CueStatus> {
299
- const out = new Map<string, CueStatus>();
300
- if (raw === null) {
301
- return out;
302
- }
303
- let data: unknown;
304
- try {
305
- data = JSON.parse(raw);
306
- } catch {
307
- return out;
308
- }
309
- // Arrays and null are typeof "object" too, and neither is a cue map.
310
- if (typeof data !== "object" || data === null || Array.isArray(data)) {
311
- return out;
312
- }
313
- for (const [id, status] of Object.entries(data)) {
314
- if (id === "" || typeof status !== "string" || !isCueStatus(status)) {
315
- continue;
316
- }
317
- out.set(id, status);
318
- if (out.size >= MAX_PERSISTED_CUE_SEEN) {
319
- break;
320
- }
321
- }
322
- return out;
323
- }
324
-
325
- /** serializeCueSeen encodes acknowledgements for storage. The caller keeps the
326
- * live map within the cap (see markCueSeen), so this does not truncate: a silent
327
- * truncation here would drop whichever entries the parser happened to read last,
328
- * which is the opposite of what an eviction should discard. */
329
- export function serializeCueSeen(seen: ReadonlyMap<string, CueStatus>): string {
330
- return JSON.stringify(Object.fromEntries(seen));
283
+ /** A status-occurrence number off the wire: a non-negative safe integer, or null
284
+ * for anything else, which leaves the tab's value unchanged. */
285
+ export function normalizeSeq(raw: unknown): number | null {
286
+ return typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 0 ? raw : null;
331
287
  }
332
288
 
333
289
  /** TabOrderKey is what the strip's order is decided by: the server's shared
@@ -424,10 +380,9 @@ export interface Tab {
424
380
  /** The chip's determinate progress bar (the 2px line on its bottom edge).
425
381
  * Present on every chip, hidden until a percentage exists. */
426
382
  progressEl: HTMLElement;
427
- /** The last OSC 9;4 percentage the server reported for this session, or
383
+ /** The last percentage the server reported for this session, or
428
384
  * PROGRESS_ABSENT. Held raw: whether it is currently SHOWN is a render-time
429
- * question (renderedProgress), and the prefix it produces is never stored on
430
- * any title field. */
385
+ * question (renderedProgress). */
431
386
  progress: number;
432
387
  /** The chip's secondary activity mark (the rounded-square ring beside the
433
388
  * status dot). Present on every chip, and costing no layout until a state
@@ -447,9 +402,10 @@ export interface Tab {
447
402
  * offset away, and it stopped meaning the same line once the session produced
448
403
  * output while backgrounded. In memory only. */
449
404
  view: ViewMemory | null;
450
- /** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
451
- * Its activity dot is shown only while true; a session that never reports
452
- * activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
405
+ /** Sticky: true once this session emitted a genuine activity signal (OSC 7501
406
+ * status, OSC 9;4 progress or a classified OSC 9 notification). Its activity
407
+ * dot is shown only while true; a session that never reports activity (a
408
+ * plain shell) keeps a clean, dot-less tab. Fed from the server's
453
409
  * reportsActivity via applyStatus. */
454
410
  reports: boolean;
455
411
  /** The user's pinned name (the server's `pinnedTitle` wire field). Outranks
@@ -457,6 +413,25 @@ export interface Tab {
457
413
  * tab menu's "use the automatic name" action. Undefined or empty means the tab
458
414
  * has no user-set name. */
459
415
  pinnedTitle?: string | undefined;
416
+ /** The server's number for the status this tab holds (0: none yet). */
417
+ statusSeq: number;
418
+ /** The server-confirmed seen floor. Only rises: a record carrying a lower one
419
+ * is older, never a retraction. */
420
+ seenSeq: number;
421
+ /** This page's optimistic read floor: the occurrence its latest ack sent, 0
422
+ * when none. A 204 clears it once seenSeq covers it, and a non-404 failure
423
+ * only while it still holds the failed number; a 404 keeps it until the
424
+ * vanished tab is removed. */
425
+ ackSeq: number;
426
+ /** The occurrence whose ack last failed with a non-404, 0 when none; the
427
+ * automatic ack skips it so a failing server sees one PUT per occurrence. */
428
+ failedSeq: number;
429
+ /** The occurrence a banner this page posted for the session belongs to, 0
430
+ * when none. */
431
+ bannerSeq: number;
432
+ /** The stream epoch at which a status-stream record last updated this tab, 0
433
+ * when none: a list requested before it is older. */
434
+ streamAt: number;
460
435
  /** Monotonic per-tab rename counter. A rename or clear increments it and
461
436
  * captures the value; the response is applied only if it is still current when
462
437
  * it lands, so a slow failure cannot roll back a newer rename, a later clear,
@@ -518,6 +493,12 @@ interface SessionAPI {
518
493
  /** Replace the shared pane layout. Throws on non-2xx as `setOrder` does: a 409
519
494
  * means a side names a session that is not live. */
520
495
  setLayout(layout: PaneLayout): Promise<void>;
496
+ /** Acknowledge `seq` for every reader. The server keeps the higher floor and
497
+ * caps it at its own statusSeq, so a resolve confirms the floor reached `seq`
498
+ * only for a `seq` this page received. Throws a SessionAPIError on non-2xx; a
499
+ * 404 means the session is gone. Sent with `keepalive`, so a read just before
500
+ * the page goes away still lands. */
501
+ markSeen(id: string, seq: number): Promise<void>;
521
502
  }
522
503
 
523
504
  /** The server's pane-layout record: which session each pane shows, the handle's
@@ -584,8 +565,8 @@ const SESSION_API_TIMEOUT_MS = 15000;
584
565
 
585
566
  /** A non-2xx response from the session API, carrying what the server said. A
586
567
  * host may TEMPORARILY refuse session creation (a 503 with `Retry-After` and a
587
- * body message while it installs tools on first boot), and a caller that only
588
- * saw an `Error` read that as a broken page. */
568
+ * body message while it starts up), and a caller that only saw an `Error`
569
+ * read that as a broken page. */
589
570
  export class SessionAPIError extends Error {
590
571
  /** HTTP status, so a caller can branch on 503 (retry) vs 429 vs 5xx. */
591
572
  readonly status: number;
@@ -754,6 +735,17 @@ export function createSessionAPI(apiBase: string, realm: SessionAPIRealm): Sessi
754
735
  throw await sessionError("set layout", r);
755
736
  }
756
737
  },
738
+ async markSeen(id: string, seq: number): Promise<void> {
739
+ const r = await request(`${apiBase}/${encodeURIComponent(id)}/seen`, {
740
+ method: "PUT",
741
+ headers: { "Content-Type": "application/json" },
742
+ body: JSON.stringify({ seq }),
743
+ keepalive: true,
744
+ });
745
+ if (!r.ok) {
746
+ throw await sessionError("mark seen", r);
747
+ }
748
+ },
757
749
  };
758
750
  }
759
751
 
@@ -14,7 +14,6 @@ import { activityPhrase, normalizeActivity, statusPhrase, statusRevealsDot } fro
14
14
  // motion is spelled in — index.ts owns the mechanism, strip.ts owns the values,
15
15
  // the same split switcher.ts keeps for the mobile swipe.
16
16
 
17
- // Twin of marotte static-src/tabs-drag.ts's constants; change both or neither.
18
17
  /** How long (ms) the pointer must have been un-moved before a position report at an
19
18
  * unchanged position (a `dragover`, or the pointer path's re-report on this tick) is
20
19
  * believed as "stopped". Small, because such a report is positive evidence of rest;
@@ -149,14 +148,10 @@ const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" wi
149
148
  // background-tab notification dot rides on it (see switchButtonHTML). Same
150
149
  // viewBox + stroke=currentcolor treatment as the others.
151
150
  const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
152
- // The state is the button's aria-expanded, not its icon. The viewBox equals the
153
- // 20px box 30-tabs.css draws it in, one unit per CSS px: that stylesheet's
154
- // per-DPR geometry is written in those units.
155
- const SPLIT_SVG =
156
- `<svg class="wt-split-icon" viewBox="0 0 20 20" aria-hidden="true">` +
157
- `<rect class="wt-split-icon-left" x="1" y="4" width="6" height="12" rx="1.5"/>` +
158
- `<rect class="wt-split-icon-divider" x="9" y="2" width="2" height="16" rx="1"/>` +
159
- `<rect class="wt-split-icon-right" x="13" y="4" width="6" height="12" rx="1.5"/></svg>`;
151
+ // The keyboard glyph's rounded square split down the middle, so the two buttons
152
+ // beside each other read as one size and weight. The state is the button's
153
+ // aria-expanded, not its icon.
154
+ const SPLIT_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3.5"/><path d="M12 4v16"/></svg>`;
160
155
 
161
156
  /** The ONE builder for a tab chip's content (status dot, label, progress bar,
162
157
  * close), shared by the strip chip, the mobile active row and each mobile list
@@ -240,7 +235,7 @@ export function paintStatusDot(el: HTMLElement, status: string, reports: boolean
240
235
  * a tooltip worded by activityPhrase. No state REMOVES the attribute, which is
241
236
  * what collapses the mark's footprint to zero. The reveal is the mark's OWN
242
237
  * state, not the status dot's reportsActivity gate: a background run is a fact
243
- * independent of whether the program ever spoke OSC 9. */
238
+ * independent of whether the program ever reported status. */
244
239
  export function paintActivityMark(el: HTMLElement, state: string, count: number): void {
245
240
  const phrase = activityPhrase(state, count);
246
241
  if (phrase === "") {
@@ -40,17 +40,18 @@ export function iconVariantHref(href: string, variant: string): string | null {
40
40
  return `${href.slice(0, at)}-${variant}${href.slice(at)}`;
41
41
  }
42
42
 
43
- /** Render reports onto `sinks`, change-gated on the last rendered value: the
44
- * title doubles as the browser-tab label and the bookmark name, and
45
- * re-assigning an icon href makes some browsers re-fetch it. `pagehide` restores
46
- * the surfaces (a browser remembers ONE icon per URL for the bookmark and the
47
- * history row, so a tab closed on a lit cue would leave a status variant standing
48
- * in for the app), `pageshow` repaints the last report for a page that came back
49
- * from the back-forward cache, and `freeze` changes nothing because a frozen tab
50
- * is still in the strip rendering its icon. */
43
+ /** Render reports onto `sinks`. The title and icon are change-gated: the title is
44
+ * also the tab label and bookmark name, and re-assigning an icon href makes some
45
+ * browsers re-fetch it. The badge is OS state a platform may drop a write to
46
+ * without rejecting, so it is also re-asserted on `resync`, a return to visible,
47
+ * window focus and `pageshow`. `pagehide` restores the title and icon (a browser
48
+ * remembers ONE icon per URL) but leaves the badge, which means "unread" for the
49
+ * whole app rather than for this page. */
51
50
  export function createAttention(sinks: AttentionSinks, win: Window): AttentionSurface {
52
- let reported: AttentionState = NO_ATTENTION;
51
+ let reported: AttentionState | null = null;
53
52
  let rendered: AttentionState | null = null;
53
+ let badged: number | null = null;
54
+ let pendingResync: number | null = null;
54
55
 
55
56
  function render(next: AttentionState): void {
56
57
  const countChanged = next.count !== rendered?.count;
@@ -58,24 +59,58 @@ export function createAttention(sinks: AttentionSinks, win: Window): AttentionSu
58
59
  rendered = next;
59
60
  if (countChanged) {
60
61
  sinks.setTitleMark(titleMarkFor(next.count));
61
- sinks.setBadge?.(next.count);
62
62
  }
63
63
  if (iconChanged) {
64
64
  sinks.setIcon?.(next.icon);
65
65
  }
66
66
  }
67
+ function cancelResync(): void {
68
+ if (pendingResync !== null) {
69
+ win.clearTimeout(pendingResync);
70
+ pendingResync = null;
71
+ }
72
+ }
73
+ function writeBadge(count: number): void {
74
+ cancelResync();
75
+ badged = count;
76
+ sinks.setBadge?.(count);
77
+ }
78
+ const listeners = new AbortController();
79
+ const { signal } = listeners;
80
+ // Deferred a task so this surface's listener and the caller's report on one
81
+ // event write once. Before the first report, a write would wipe the badge
82
+ // another page or a previous run set; after dispose, it would raise a cleared one.
83
+ function resync(): void {
84
+ if (reported === null || signal.aborted || pendingResync !== null) {
85
+ return;
86
+ }
87
+ pendingResync = win.setTimeout(() => {
88
+ pendingResync = null;
89
+ if (reported !== null && !signal.aborted) {
90
+ writeBadge(reported.count);
91
+ }
92
+ }, 0);
93
+ }
67
94
 
68
95
  function onPageGone(): void {
69
96
  render(NO_ATTENTION);
70
97
  }
71
98
  function onPageBack(): void {
72
- render(reported);
99
+ if (reported !== null) {
100
+ render(reported);
101
+ }
102
+ resync();
103
+ }
104
+ function onVisibility(): void {
105
+ if (win.document.visibilityState !== "hidden") {
106
+ resync();
107
+ }
73
108
  }
74
- const listeners = new AbortController();
75
- const { signal } = listeners;
76
109
  try {
77
110
  win.addEventListener("pagehide", onPageGone, { signal });
78
111
  win.addEventListener("pageshow", onPageBack, { signal });
112
+ win.addEventListener("focus", resync, { signal });
113
+ win.document.addEventListener("visibilitychange", onVisibility, { signal });
79
114
  } catch (err) {
80
115
  listeners.abort();
81
116
  throw err;
@@ -85,10 +120,15 @@ export function createAttention(sinks: AttentionSinks, win: Window): AttentionSu
85
120
  report(state) {
86
121
  reported = state;
87
122
  render(state);
123
+ if (state.count !== badged) {
124
+ writeBadge(state.count);
125
+ }
88
126
  },
127
+ resync,
89
128
  dispose() {
90
129
  listeners.abort();
91
130
  render(NO_ATTENTION);
131
+ writeBadge(0);
92
132
  },
93
133
  };
94
134
  }
@@ -38,24 +38,41 @@ export function shouldNotify(sessionIsActive: boolean, pageVisible: boolean): bo
38
38
  return !(sessionIsActive && pageVisible);
39
39
  }
40
40
 
41
- /** Bound on the per-session dedupe map: each key is a live session server-side,
42
- * so a real page is nowhere near this and a hostile stream cannot grow it. */
41
+ /** Bound on each per-session map: each key is a live session server-side, so a
42
+ * real page is nowhere near this and a hostile stream cannot grow it. */
43
43
  const MAX_TRACKED_SESSIONS = 200;
44
44
 
45
45
  export function createNotifier(env: NotifierEnv): Notifier {
46
46
  // Highest sequence delivered per session. The engine never replays a
47
47
  // notification; this makes a doubled frame unable to notify twice for one event.
48
48
  const lastSeq = new Map<string, number>();
49
+ // The banner on screen per session. A page notification is invisible to
50
+ // ServiceWorkerRegistration.getNotifications(), so only this reference can
51
+ // close it once its session is read.
52
+ const shown = new Map<string, NotificationLike>();
49
53
  let armed = false;
50
54
  let requested = false;
51
55
 
52
- function evict(): void {
53
- while (lastSeq.size > MAX_TRACKED_SESSIONS) {
54
- const oldest = lastSeq.keys().next().value;
56
+ // `drop` must remove the key, or this never ends.
57
+ function evict(map: Map<string, unknown>, drop: (id: string) => void): void {
58
+ while (map.size > MAX_TRACKED_SESSIONS) {
59
+ const oldest = map.keys().next().value;
55
60
  if (oldest === undefined) {
56
61
  return;
57
62
  }
58
- lastSeq.delete(oldest);
63
+ drop(oldest);
64
+ }
65
+ }
66
+ function retract(id: string): void {
67
+ const posted = shown.get(id);
68
+ if (posted === undefined) {
69
+ return;
70
+ }
71
+ shown.delete(id);
72
+ try {
73
+ posted.close();
74
+ } catch {
75
+ /* a banner the platform already took down */
59
76
  }
60
77
  }
61
78
 
@@ -71,7 +88,7 @@ export function createNotifier(env: NotifierEnv): Notifier {
71
88
  return false;
72
89
  }
73
90
  lastSeq.set(ev.id, seq);
74
- evict();
91
+ evict(lastSeq, (id) => lastSeq.delete(id));
75
92
  }
76
93
  if (!shouldNotify(view.sessionIsActive, env.pageVisible())) {
77
94
  return false;
@@ -90,7 +107,16 @@ export function createNotifier(env: NotifierEnv): Notifier {
90
107
  posted.onclick = (): void => {
91
108
  view.activate();
92
109
  posted.close();
110
+ if (shown.get(ev.id) === posted) {
111
+ shown.delete(ev.id);
112
+ }
93
113
  };
114
+ // Re-inserted so map order is banner age, and the banner evicted is
115
+ // closed: a dropped reference would leave it (and Android's launcher
116
+ // dot) up with nothing able to retract it.
117
+ shown.delete(ev.id);
118
+ shown.set(ev.id, posted);
119
+ evict(shown, retract);
94
120
  return true;
95
121
  } catch {
96
122
  // Safari throws on `new Notification` outside an installed PWA.
@@ -117,8 +143,10 @@ export function createNotifier(env: NotifierEnv): Notifier {
117
143
  /* a browser that refuses to be asked is a tab-only browser */
118
144
  }
119
145
  },
146
+ retract,
120
147
  forget(id): void {
121
148
  lastSeq.delete(id);
149
+ retract(id);
122
150
  },
123
151
  };
124
152
  }
@@ -77,6 +77,18 @@ function typedChar(ev: KeyboardEvent): string | null {
77
77
  return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
78
78
  }
79
79
 
80
+ /** Ctrl+C (Shift allowed) or Ctrl+Insert: the chords that copy a selection. A
81
+ * non-Latin layout's C is its physical KeyC; a Latin layout's is `key`. */
82
+ function isCopyChord(ev: KeyboardEvent): boolean {
83
+ if (!ev.ctrlKey || ev.altKey || ev.metaKey) {
84
+ return false;
85
+ }
86
+ if (ev.key === "Insert" || ev.key === "c" || ev.key === "C") {
87
+ return true;
88
+ }
89
+ return ev.code === "KeyC" && !/^[a-z]$/i.test(ev.key);
90
+ }
91
+
80
92
  /** iOS sends U+00A0 where a space was typed; shared by every send path so one
81
93
  * physical key cannot produce two byte sequences. */
82
94
  function normalizeTypedText(text: string): string {
@@ -986,8 +998,9 @@ function buildPaneInto(
986
998
  if (!ownsSelection() && !keysUnclaimed()) {
987
999
  return;
988
1000
  }
989
- // Features while the selection is STILL INTACT and before the modifier
990
- // gate: clipboard's Ctrl+Shift+C is a modified key that reads the selection.
1001
+ // Features while the selection is STILL INTACT and before the key is
1002
+ // encoded: clipboard's Ctrl+Shift+C reads the selection, and its claim on
1003
+ // Ctrl+V keeps 0x16 off the wire.
991
1004
  if (runKeydownChain(ev)) {
992
1005
  return;
993
1006
  }
@@ -995,7 +1008,7 @@ function buildPaneInto(
995
1008
  // beforeinput or input, `_tryToHandlePressesEvent:` in
996
1009
  // https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/ios/WKContentViewInteraction.mm),
997
1010
  // so a dead key or an IME takes focus and passes through, ahead of the
998
- // modifier gate that would keep Option+e.
1011
+ // decision below, which drops a key that types nothing.
999
1012
  if (
1000
1013
  ev.key === "Dead" ||
1001
1014
  ev.key === "Process" ||
@@ -1010,21 +1023,25 @@ function buildPaneInto(
1010
1023
  return;
1011
1024
  }
1012
1025
  // Decided before taking focus, because a bare modifier press (Shift on its
1013
- // way to Shift+click) must leave the selection alone. Computed only for an
1014
- // "ignore", so the encoder always wins a key it maps.
1026
+ // way to Shift+click) must leave the selection alone. Modifiers alone never
1027
+ // reject a key the encoder maps, as on the focused path.
1015
1028
  const result = mapKeyboardEvent(ev, engine.modes);
1016
1029
  const char = result.kind === "ignore" ? typedChar(ev) : null;
1017
- if (result.kind === "ignore" && char === null) {
1018
- return;
1019
- }
1020
- // Modified keys stay with the browser except an AltGr CHARACTER, the way
1021
- // some layouts type it; AltGraph alone is not a reliable signal (Firefox
1022
- // reports it for plain Option on macOS), and Alt+ArrowLeft is browser back.
1023
- const altGraphChar = char !== null && ev.getModifierState("AltGraph");
1024
- if ((ev.ctrlKey || ev.metaKey || ev.altKey) && !altGraphChar) {
1025
- return;
1030
+ if (result.kind === "ignore") {
1031
+ if (char === null) {
1032
+ return;
1033
+ }
1034
+ // An unencoded character with Ctrl, Alt or Cmd is the browser's (Cmd+V,
1035
+ // Ctrl+1). A desktop AltGr character needs no exception: it arrives with
1036
+ // neither modifier (the engine's encodeKittyDisambiguate cites why), and
1037
+ // Firefox sets AltGraph on every macOS Option chord.
1038
+ if (ev.ctrlKey || ev.metaKey || ev.altKey) {
1039
+ return;
1040
+ }
1026
1041
  }
1027
- if (ev.key === "Tab") {
1042
+ // Tab leaves the page (no keyboard trap); a copy chord over this
1043
+ // terminal's selection is the browser's copy, not a key for the shell.
1044
+ if (ev.key === "Tab" || (ownsSelection() && isCopyChord(ev))) {
1028
1045
  return;
1029
1046
  }
1030
1047
  doc.getSelection()?.removeAllRanges();
@@ -438,7 +438,7 @@ function createShellInto(
438
438
  function attention(options: AttentionOptions): AttentionReporter {
439
439
  // A setup still running past destroy() gets a reporter that writes nothing.
440
440
  if (destroyed) {
441
- return { report: () => undefined };
441
+ return { report: () => undefined, resync: () => undefined };
442
442
  }
443
443
  const surface = createAttention(
444
444
  browserAttentionSinks(