@cplieger/web-terminal-ui 8.6.0-dev.4 → 8.6.0-dev.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "8.6.0-dev.4",
3
+ "version": "8.6.0-dev.5",
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": "MPL-2.0",
@@ -64,7 +64,7 @@
64
64
  "html-validate": "11.16.2",
65
65
  "knip": "6.41.0",
66
66
  "playwright": "1.64.0",
67
- "prettier": "3.9.9",
67
+ "prettier": "3.9.10",
68
68
  "stylelint": "17.16.0",
69
69
  "stylelint-config-standard": "40.0.0",
70
70
  "typescript": "npm:@typescript/typescript6@6.0.2",
@@ -28,7 +28,6 @@ import {
28
28
  } from "./route.js";
29
29
  import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
30
30
  import {
31
- CUE_SEEN_KEY,
32
31
  MAX_PERSISTED_CUE_SEEN,
33
32
  MAX_PINNED_NAME,
34
33
  PROGRESS_ABSENT,
@@ -39,6 +38,8 @@ import {
39
38
  createSessionAPI,
40
39
  createTombstones,
41
40
  cueIconName,
41
+ cueSeenID,
42
+ cueSeenKey,
42
43
  foldedCueStatus,
43
44
  hasPinnedName,
44
45
  isCueStatus,
@@ -51,7 +52,6 @@ import {
51
52
  parseCueSeen,
52
53
  renderedProgress,
53
54
  sanitizePinnedName,
54
- serializeCueSeen,
55
55
  statusPhrase,
56
56
  summarizeCues,
57
57
  tabAccessibleName,
@@ -196,26 +196,47 @@ const CATCHUP_SETTLE_MS = 250;
196
196
  * multiple frames; ordinary streaming must never arm the cue. */
197
197
  const CATCHUP_MIN_BACKLOG = 400;
198
198
 
199
+ function* storedCueSeen(win: Pick<Window, "localStorage">): Generator<readonly [string, string]> {
200
+ try {
201
+ const store = win.localStorage;
202
+ for (let i = 0; i < store.length; i++) {
203
+ const key = store.key(i);
204
+ const id = key === null ? null : cueSeenID(key);
205
+ if (key !== null && id !== null) {
206
+ yield [id, store.getItem(key) ?? ""];
207
+ }
208
+ }
209
+ } catch {
210
+ /* storage unavailable */
211
+ }
212
+ }
213
+
199
214
  /** The acknowledged background-tab cues, or an empty map when storage throws or
200
215
  * holds nothing trustworthy: the dot simply lights again. */
201
216
  function readCueSeen(win: Pick<Window, "localStorage">): Map<string, CueStatus> {
217
+ return parseCueSeen(storedCueSeen(win));
218
+ }
219
+
220
+ /** Best-effort: a full quota must not break the dismissal the user just
221
+ * performed on screen. */
222
+ function writeCueSeen(win: Pick<Window, "localStorage">, id: string, status: CueStatus): void {
202
223
  try {
203
- return parseCueSeen(win.localStorage.getItem(CUE_SEEN_KEY));
224
+ win.localStorage.setItem(cueSeenKey(id), status);
204
225
  } catch {
205
- return new Map<string, CueStatus>();
226
+ /* storage unavailable or full: the dismissal still holds for this page */
206
227
  }
207
228
  }
208
229
 
209
- /** writeCueSeen persists acknowledgements, best-effort: a full quota must not
210
- * break the dismissal the user just performed on screen. */
211
- function writeCueSeen(
212
- win: Pick<Window, "localStorage">,
213
- seen: ReadonlyMap<string, CueStatus>,
214
- ): void {
230
+ /** Remove `id`'s stored acknowledgement only while it still holds `status`:
231
+ * another window may since have acknowledged the session's next cue. */
232
+ function dropCueSeen(win: Pick<Window, "localStorage">, id: string, status: CueStatus): void {
215
233
  try {
216
- win.localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
234
+ const key = cueSeenKey(id);
235
+ if (win.localStorage.getItem(key) === status) {
236
+ win.localStorage.removeItem(key);
237
+ }
217
238
  } catch {
218
- /* storage unavailable or full — the dismissal still holds for this page */
239
+ /* storage unavailable: nothing stored to remove */
219
240
  }
220
241
  }
221
242
 
@@ -682,36 +703,40 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
682
703
  // The session that raised the pending cue, so arriving on that tab (a
683
704
  // swipe or any switch) acknowledges it without opening the list.
684
705
  let switchNotifyId: string | null = null;
685
- // Cues this viewer has already SEEN, per session (see CUE_SEEN_KEY).
706
+ // Cues this viewer has already SEEN, per session (see CUE_SEEN_PREFIX).
686
707
  // Loaded here so a reload starts from what the user already dismissed:
687
708
  // input/done stay LATCHED server-side, and the status stream re-pushes the
688
709
  // latch in the snapshot it sends on every open, so without this a
689
710
  // dismissed dot came back on the next load — and on every SSE reconnect,
690
711
  // which on a phone is just returning to a backgrounded page.
691
712
  const cueSeen = readCueSeen(win);
713
+ // The out-of-page surfaces render the SAME unseen-cue set the switch dot
714
+ // shows, fed only through paintAttention below.
715
+ const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
692
716
  // markCueSeen records that this viewer has seen `id` holding `status`:
693
717
  // either because that tab is the one on screen, or because the tray listing
694
- // every tab's dot was opened. A non-cue status is not an acknowledgeable
695
- // event, so it is ignored rather than stored.
718
+ // every tab's dot was opened. Every read re-asserts the badge, which may
719
+ // still show a write the platform dropped though the count is unchanged,
720
+ // and closes the session's banner; a non-cue status stores nothing.
696
721
  function markCueSeen(id: string, status: string): void {
722
+ attention.resync();
723
+ ctx.shell.notifications.retract(id);
697
724
  if (!isCueStatus(status) || cueSeen.get(id) === status) {
698
725
  return;
699
726
  }
700
727
  cueSeen.set(id, status);
701
- // Evict the oldest entries so the live map obeys the same cap the parser
702
- // does. dropTab prunes a session closed while this page was open, but one
703
- // that vanished while the page was CLOSED leaves an entry nothing else
704
- // collects; unbounded, that would eventually push the map past the cap and
705
- // make the parser discard whatever it read last — dropping fresh
706
- // acknowledgements to keep dead ones.
728
+ writeCueSeen(win, id, status);
729
+ // Evict the oldest so storage obeys the parser's cap: dropTab prunes a
730
+ // session closed while this page was open, but one that vanished while the
731
+ // page was CLOSED leaves an entry nothing else collects.
707
732
  while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
708
- const oldest = cueSeen.keys().next().value;
709
- if (oldest === undefined) {
733
+ const [oldest, held] = cueSeen.entries().next().value ?? [];
734
+ if (oldest === undefined || held === undefined) {
710
735
  break;
711
736
  }
712
737
  cueSeen.delete(oldest);
738
+ dropCueSeen(win, oldest, held);
713
739
  }
714
- writeCueSeen(win, cueSeen);
715
740
  // An acknowledgement can change the unseen count without touching the tab
716
741
  // list, so syncChrome's sweep is not enough on its own: opening the tray
717
742
  // and arriving on a tab both land here and neither adds or removes a chip.
@@ -722,13 +747,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
722
747
  // (a new working phase, an exit) and when the session goes away. Every
723
748
  // caller ends in syncChrome, which repaints the out-of-page surfaces.
724
749
  function forgetCueSeen(id: string): void {
725
- if (cueSeen.delete(id)) {
726
- writeCueSeen(win, cueSeen);
750
+ const known = cueSeen.get(id);
751
+ if (known === undefined) {
752
+ return;
727
753
  }
754
+ cueSeen.delete(id);
755
+ dropCueSeen(win, id, known);
728
756
  }
729
- // The out-of-page surfaces render the SAME unseen-cue set the switch dot
730
- // shows, fed only through paintAttention below.
731
- const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
732
757
  // visibilityState, not document.hasFocus(), which is false for a
733
758
  // visible-but-unfocused window where the terminal IS on screen.
734
759
  function pageVisible(): boolean {
@@ -753,6 +778,36 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
753
778
  ctx.defer(() => {
754
779
  doc.removeEventListener("visibilitychange", onPageVisible);
755
780
  });
781
+ // Every page writes the one app badge, so each mirrors the shared map;
782
+ // otherwise a hidden page keeps counting a cue the reader saw elsewhere.
783
+ // A null key is another page's localStorage.clear().
784
+ function onCueSeenStored(e: StorageEvent): void {
785
+ if (e.key !== null && cueSeenID(e.key) === null) {
786
+ return;
787
+ }
788
+ const stored = readCueSeen(win);
789
+ for (const id of [...cueSeen.keys()]) {
790
+ if (!stored.has(id)) {
791
+ cueSeen.delete(id);
792
+ }
793
+ }
794
+ for (const [id, status] of stored) {
795
+ if (cueSeen.get(id) !== status) {
796
+ cueSeen.set(id, status);
797
+ // Read elsewhere, but only this page can close the banners it posted.
798
+ ctx.shell.notifications.retract(id);
799
+ }
800
+ }
801
+ if (switchNotifyId !== null && cueSeen.get(switchNotifyId) === switchNotify) {
802
+ clearSwitchNotify();
803
+ }
804
+ attention.resync();
805
+ paintAttention();
806
+ }
807
+ win.addEventListener("storage", onCueSeenStored);
808
+ ctx.defer(() => {
809
+ win.removeEventListener("storage", onCueSeenStored);
810
+ });
756
811
 
757
812
  // A FOLD over the tab list rather than incremental bookkeeping: no state of
758
813
  // its own can go stale, and the shell no-ops when nothing changed.
@@ -3738,11 +3793,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3738
3793
  let streamOpens = 0;
3739
3794
  const offStreamOpen = monitor.onStreamOpen?.(() => {
3740
3795
  streamOpens++;
3741
- if (streamOpens === 1) {
3742
- void reconcileOnce();
3743
- } else {
3744
- void withPlacementHold(() => inReconcileSlot(reconcileNow));
3745
- }
3796
+ const settled =
3797
+ streamOpens === 1
3798
+ ? reconcileOnce()
3799
+ : withPlacementHold(() => inReconcileSlot(reconcileNow));
3800
+ void settled.then(() => {
3801
+ attention.resync();
3802
+ });
3746
3803
  });
3747
3804
  if (offStreamOpen) {
3748
3805
  ctx.defer(offStreamOpen);
@@ -282,54 +282,49 @@ export function summarizeCues(
282
282
  return { count, worst };
283
283
  }
284
284
 
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
285
+ /** localStorage key prefix for the cues this viewer has SEEN: one key per
286
+ * session, `cueSeenKey(id)`, holding the acknowledged status. Remembered because
287
+ * `input` and `done` are latched on the server and re-delivered in every
288
+ * snapshot, so a dismissed dot came back on every reload; client-side because
289
+ * "seen" is the VIEWER's, not the session's. One key per session because every
290
+ * window of the app writes here: two windows acknowledging different sessions
291
+ * write different keys, where one shared map lets the later write drop the
292
+ * other's acknowledgement. */
293
+ const CUE_SEEN_PREFIX = "wt-cue-seen/";
294
+
295
+ /** The storage key holding `id`'s acknowledgement. */
296
+ export function cueSeenKey(id: string): string {
297
+ return CUE_SEEN_PREFIX + id;
298
+ }
299
+
300
+ /** The session id a storage key acknowledges, or null for any other key. */
301
+ export function cueSeenID(key: string): string | null {
302
+ return key.startsWith(CUE_SEEN_PREFIX) && key.length > CUE_SEEN_PREFIX.length
303
+ ? key.slice(CUE_SEEN_PREFIX.length)
304
+ : null;
305
+ }
306
+
307
+ /** Bound on the acknowledgements read back, so a corrupted or hostile store
293
308
  * cannot make the restore path do unbounded work. */
294
309
  export const MAX_PERSISTED_CUE_SEEN = 200;
295
310
 
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> {
311
+ /** Read stored [session id, raw status] entries into a clean map, dropping
312
+ * anything untrusted (a lost acknowledgement only re-lights a dot the user can
313
+ * dismiss again), and stop reading at the cap. */
314
+ export function parseCueSeen(entries: Iterable<readonly [string, string]>): Map<string, CueStatus> {
299
315
  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);
316
+ for (const [id, status] of entries) {
318
317
  if (out.size >= MAX_PERSISTED_CUE_SEEN) {
319
318
  break;
320
319
  }
320
+ if (id === "" || !isCueStatus(status)) {
321
+ continue;
322
+ }
323
+ out.set(id, status);
321
324
  }
322
325
  return out;
323
326
  }
324
327
 
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));
331
- }
332
-
333
328
  /** TabOrderKey is what the strip's order is decided by: the server's shared
334
329
  * position for the session, its creation timestamp, and its id. Both `Tab` and
335
330
  * the wire `SessionInfo` carry these three fields, so either can be passed
@@ -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
  }
@@ -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(
@@ -240,6 +240,12 @@ export interface AttentionReporter {
240
240
  /** Render `state`. Idempotent: a state equal to the last one reported touches
241
241
  * nothing, so a status sweep may call it every tick. */
242
242
  report(state: AttentionState): void;
243
+ /** Write the latest reported count to the app badge again on the next task,
244
+ * changed or not; a `report` that writes the badge before then satisfies it.
245
+ * The page cannot read the badge back and a platform may drop a write
246
+ * silently, so a caller resyncs wherever the two may have diverged, ahead of
247
+ * the report that follows. A no-op before the first report. */
248
+ resync(): void;
243
249
  }
244
250
 
245
251
  /** The notification a session status event carries (OSC 9 `<message>`). Both
@@ -276,7 +282,10 @@ export interface Notifier {
276
282
  arm(): void;
277
283
  /** Note a user gesture: the ONLY moment a permission prompt may be raised. */
278
284
  gesture(): void;
279
- /** Drop a closed session's dedupe state. */
285
+ /** Close the notification delivered for `id`, if one is still up: the user has
286
+ * seen that session, so the banner no longer reports anything unread. */
287
+ retract(id: string): void;
288
+ /** Drop a closed session's dedupe state and retract its notification. */
280
289
  forget(id: string): void;
281
290
  }
282
291