pi-open-tui 0.4.1 → 0.4.3

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.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { copyToClipboard, UserMessageComponent, type MarkdownTransformContext, type Theme } from "@earendil-works/pi-coding-agent";
2
3
  import {
3
4
  compositeTuiLine,
@@ -13,15 +14,14 @@ import {
13
14
  type TuiMouseEvent,
14
15
  type TuiMouseEventResult,
15
16
  } from "@earendil-works/pi-tui";
17
+ import { stripAnsi } from "./utils.ts";
16
18
 
17
19
  const LABEL = "[Copy]";
18
20
  // Below this width a frame plus label would crowd the code, so Pi's plain fences are kept.
19
21
  const MIN_FRAME_WIDTH = 8;
20
22
  // Zero-width APC sequence: survives wrapping and padding, and marks the label column before we strip it.
21
- const MARKER = "\x1b_open-tui:cb\x07";
23
+ const MARKER_PREFIX = "\x1b_open-tui:cb:";
22
24
  const PATCH_KEY = Symbol.for("pi-open-tui.copy-buttons");
23
- // pi-pretty-tui draws its own code-block [Copy]; patching the same methods twice would fight.
24
- const PRETTY_TUI_KEY = Symbol.for("pretty-tui.code-blocks");
25
25
  // Pi only synthesizes `click` for a zero-movement release, so allow a little jitter.
26
26
  const RELEASE_TOLERANCE = 2;
27
27
 
@@ -58,6 +58,7 @@ interface AltScreenInternals {
58
58
  interface MarkdownInternals {
59
59
  text?: unknown;
60
60
  theme: MarkdownTheme;
61
+ invalidate(): void;
61
62
  render(width: number): string[];
62
63
  renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: unknown): string[];
63
64
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
@@ -69,26 +70,39 @@ interface UserMessageInternals {
69
70
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
70
71
  }
71
72
 
73
+ interface MarkdownState {
74
+ active: boolean;
75
+ policy?: { frame: boolean; label: boolean };
76
+ lines?: readonly string[];
77
+ regions: CopyRegion[];
78
+ descriptors: Map<number, { text: string; label: boolean }>;
79
+ nextId: number;
80
+ }
81
+
72
82
  /** Set by our Markdown transformer while a transcript Markdown renders. */
73
- let renderScope: { frame: boolean; label: boolean; ordinal: number; blocks: { text: string; ordinal: number }[] } | undefined;
83
+ let renderScope: { owner: MarkdownInternals; state: MarkdownState; active: boolean; frame: boolean; label: boolean } | undefined;
74
84
  let press: Press | undefined;
75
- const markdownRegions = new WeakMap<object, { lines: readonly string[]; regions: CopyRegion[] }>();
76
- const userRegions = new WeakMap<object, CopyRegion>();
85
+ const regionTargets = new WeakMap<CopyRegion, string>();
77
86
 
78
87
  /**
79
- * Markdown transformer that only records the message kind; Pi calls it during Markdown.render.
88
+ * Pi calls the transform before consulting its native block cache, so policy changes can invalidate it in this pass.
80
89
  * Thinking blocks keep Pi's fences. Streaming replies get the frame but no label: it would copy unfinished code.
81
90
  */
82
91
  export function markCopyScope(markdown: string, context: MarkdownTransformContext): string {
83
92
  if (renderScope) {
84
- renderScope.frame = context.messageType !== "assistant-thinking";
93
+ renderScope.frame = renderScope.active && context.messageType !== "assistant-thinking";
85
94
  renderScope.label = renderScope.frame && !context.isStreaming;
95
+ const previous = renderScope.state.policy;
96
+ if (previous && (previous.frame !== renderScope.frame || previous.label !== renderScope.label)) {
97
+ renderScope.owner.invalidate();
98
+ }
99
+ renderScope.state.policy = { frame: renderScope.frame, label: renderScope.label };
86
100
  }
87
101
  return markdown;
88
102
  }
89
103
 
90
104
  /**
91
- * Rounded frame with the language and the label in the top border, like pi-pretty-tui:
105
+ * Rounded frame with the language and the label in the top border:
92
106
  * `╭─ ts ───── [Copy] ╮`. It replaces the two fence rows, so the block keeps its height.
93
107
  */
94
108
  export function renderFramedCode(
@@ -98,6 +112,7 @@ export function renderFramedCode(
98
112
  width: number,
99
113
  label: string | undefined,
100
114
  border: (text: string) => string = theme.codeBlockBorder,
115
+ marker = "",
101
116
  ): string[] {
102
117
  const inner = width - 4;
103
118
  const highlighted = theme.highlightCode?.(code, lang) ?? code.split("\n").map((line) => theme.codeBlock(line));
@@ -111,8 +126,8 @@ export function renderFramedCode(
111
126
  const widest = Math.max(0, ...rows.map((row) => visibleWidth(row)));
112
127
  const frameWidth = Math.min(width, Math.max(widest + 4, visibleWidth(prefix) + 3 + visibleWidth(suffix) + 1));
113
128
  const fill = Math.max(0, frameWidth - visibleWidth(prefix) - visibleWidth(suffix) - 1);
114
- const top = border("╭─ ") + theme.codeBlockBorder(`${name} `) + border("─".repeat(fill))
115
- + (label === undefined ? "" : ` ${MARKER}${label} `) + border("╮");
129
+ const top = (label === undefined ? marker : "") + border("╭─ ") + theme.codeBlockBorder(`${name} `) + border("─".repeat(fill))
130
+ + (label === undefined ? "" : ` ${marker}${label} `) + border("╮");
116
131
  const body = rows.map((row) => border("│ ") + row + " ".repeat(Math.max(0, frameWidth - 4 - visibleWidth(row))) + border(" │"));
117
132
  return [top, ...body, border(`╰${"─".repeat(frameWidth - 2)}╯`)];
118
133
  }
@@ -145,6 +160,17 @@ export function handleCopyMouse(
145
160
  return { handled: true, render: false };
146
161
  }
147
162
 
163
+ /** Cancel a held click when rendering moves, removes, or changes its copy target. */
164
+ function cancelStalePress(owner: object, regions: readonly CopyRegion[]): void {
165
+ if (press?.owner !== owner) return;
166
+ const previous = press.region;
167
+ if (!regions.some((region) => region.row === previous.row && region.col === previous.col
168
+ && region.width === previous.width && region.text === previous.text
169
+ && regionTargets.get(region) === regionTargets.get(previous))) {
170
+ press = undefined;
171
+ }
172
+ }
173
+
148
174
  /**
149
175
  * Markdown.render normalizes tabs to three spaces before lexing, so token text loses them.
150
176
  * Match the normalized tokens back to source lines: lexing raw lists would expand their tabs first.
@@ -218,21 +244,28 @@ export function installCopyButtons(options: {
218
244
  const altScreenProto = TuiAltScreen.prototype as unknown as AltScreenInternals;
219
245
 
220
246
  const refresh = () => {
247
+ press = undefined;
221
248
  const tui = options.getTui();
222
249
  tui?.invalidate();
223
250
  tui?.requestRender();
224
251
  };
225
- if (markdownProto[PATCH_KEY] || markdownProto[PRETTY_TUI_KEY]) return { cleanup: () => {}, refresh };
252
+ if (markdownProto[PATCH_KEY]) return { cleanup: () => {}, refresh };
226
253
 
227
- if (typeof altScreenProto.handleMouseEvent !== "function" || typeof altScreenProto.createMouseEvent !== "function") {
254
+ if (typeof markdownProto.render !== "function" || typeof markdownProto.renderToken !== "function"
255
+ || typeof markdownProto.invalidate !== "function" || typeof altScreenProto.handleMouseEvent !== "function"
256
+ || typeof altScreenProto.createMouseEvent !== "function") {
228
257
  return { cleanup: () => {}, refresh };
229
258
  }
230
259
  let installed = true;
231
260
  const active = () => installed && options.isEnabled() && options.getTui()?.mode === "fullscreen";
261
+ let markdownRegions = new WeakMap<object, MarkdownState>();
262
+ let userRegions = new WeakMap<object, { region: CopyRegion; source: string; label: string; width: number; line: string }>();
263
+ const markerPrefix = `${MARKER_PREFIX}${randomUUID()}:`;
264
+ const markerPattern = new RegExp(`${markerPrefix}(\\d+):([01])\x07`, "g");
232
265
  const copy = (text: string) => {
233
266
  const tui = options.getTui();
234
267
  const flash = (message: string) => {
235
- if (tui instanceof TuiAltScreen) tui.flash(message);
268
+ if (installed && tui === options.getTui() && tui instanceof TuiAltScreen) tui.flash(message);
236
269
  };
237
270
  copyToClipboard(text).then(() => flash("Copied!"), () => flash("Copy failed"));
238
271
  };
@@ -247,7 +280,10 @@ export function installCopyButtons(options: {
247
280
 
248
281
  const restores = [
249
282
  patch(altScreenProto, "handleMouseEvent", function (this: AltScreenInternals, raw) {
250
- if (!active() || this !== (options.getTui() as unknown)) return originalAltScreenMouse.call(this, raw);
283
+ if (!active() || this !== (options.getTui() as unknown)) {
284
+ press = undefined;
285
+ return originalAltScreenMouse.call(this, raw);
286
+ }
251
287
  const motion = (raw.button & 32) !== 0;
252
288
  if (!raw.release && !motion) press = undefined;
253
289
  // Leave press/drag to Pi's selection; intercept only a qualifying release before copy-on-select runs.
@@ -263,82 +299,150 @@ export function installCopyButtons(options: {
263
299
  }),
264
300
  patch(markdownProto, "renderToken", function (this: MarkdownInternals, token, width, nextTokenType, styleContext) {
265
301
  const scope = renderScope;
266
- if (!installed || !scope || token.type !== "code") return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
267
- const ordinal = scope.ordinal++;
268
- if (!scope.frame || !active() || width < MIN_FRAME_WIDTH) {
302
+ if (!installed || !scope?.active || scope.owner !== this || token.type !== "code") {
269
303
  return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
270
304
  }
271
- const theme = options.getTheme();
272
305
  const showLabel = scope.label && width >= MIN_FRAME_WIDTH + LABEL.length + 2;
273
- const lines = renderFramedCode(
274
- this.theme,
275
- token.text,
276
- token.lang,
277
- width,
278
- showLabel ? label() : undefined,
279
- (text) => theme.fg("borderMuted", text),
280
- );
281
- if (showLabel) scope.blocks.push({ text: token.text, ordinal });
282
- // Same spacing rule as Pi's own code-block rendering.
283
- if (nextTokenType && nextTokenType !== "space") lines.push("");
306
+ const id = scope.state.nextId++;
307
+ const marker = `${markerPrefix}${id}:${showLabel ? 1 : 0}\x07`;
308
+ let lines: string[];
309
+ if (scope.frame && width >= MIN_FRAME_WIDTH) {
310
+ const theme = options.getTheme();
311
+ lines = renderFramedCode(this.theme, token.text, token.lang, width, showLabel ? label() : undefined,
312
+ (text) => theme.fg("borderMuted", text), marker);
313
+ // Same spacing rule as Pi's own code-block rendering.
314
+ if (nextTokenType && nextTokenType !== "space") lines.push("");
315
+ } else {
316
+ lines = originalRenderToken.call(this, token, width, nextTokenType, styleContext);
317
+ // Unlabeled occurrences still count toward the current source ordinal.
318
+ lines[0] = marker + (lines[0] ?? "");
319
+ }
320
+ scope.state.descriptors.set(id, { text: token.text, label: showLabel });
284
321
  return lines;
285
322
  }),
286
323
  patch(markdownProto, "render", function (this: MarkdownInternals, width) {
287
324
  if (!installed) return originalRender.call(this, width);
288
- const outer = renderScope;
289
- const scope = { frame: false, label: false, ordinal: 0, blocks: [] as { text: string; ordinal: number }[] };
290
- renderScope = scope;
291
- let lines: string[];
292
- try {
293
- lines = originalRender.call(this, width);
294
- } finally {
295
- renderScope = outer;
325
+ const enabled = active();
326
+ const previous = markdownRegions.get(this);
327
+ const state: MarkdownState = previous ?? { active: enabled, regions: [], descriptors: new Map(), nextId: 0 };
328
+ if (!previous) {
329
+ markdownRegions.set(this, state);
330
+ // A component may already have cached native fences before this installation.
331
+ this.invalidate();
332
+ } else if (state.active !== enabled) {
333
+ this.invalidate();
334
+ cancelStalePress(this, []);
335
+ state.active = enabled;
336
+ state.policy = undefined;
296
337
  }
297
- // A cache hit returns the array we already processed and skips renderToken.
298
- if (markdownRegions.get(this)?.lines === lines) return lines;
299
- const source = typeof this.text === "string" ? this.text : "";
300
- const regions: CopyRegion[] = [];
301
- let next = 0;
302
- for (let row = 0; row < lines.length; row++) {
303
- const line = lines[row]!;
304
- const at = line.indexOf(MARKER);
305
- if (at < 0) continue;
306
- // The renderer caches this array, so stripping in place keeps the marker out of later frames.
307
- lines[row] = line.split(MARKER).join("");
308
- const block = scope.blocks[next++];
309
- if (!block) continue;
310
- regions.push({
311
- row,
312
- col: visibleWidth(line.slice(0, at)),
313
- width: LABEL.length,
314
- text: restoreTabs(source, block.ordinal, block.text),
315
- });
338
+ const run = () => {
339
+ const outer = renderScope;
340
+ renderScope = { owner: this, state, active: enabled, frame: false, label: false };
341
+ try { return originalRender.call(this, width); }
342
+ finally { renderScope = outer; }
343
+ };
344
+ try {
345
+ let lines = run();
346
+ if (state.lines === lines) return lines;
347
+ // A retained native block can outlive its description after a renderer change.
348
+ const missing = lines.some((line) => [...line.matchAll(markerPattern)].some((match) => !state.descriptors.has(Number(match[1]))));
349
+ if (missing) {
350
+ this.invalidate();
351
+ lines = run();
352
+ }
353
+ const source = typeof this.text === "string" ? this.text : "";
354
+ const regions: CopyRegion[] = [];
355
+ const live = new Set<number>();
356
+ let ordinal = 0;
357
+ for (let row = 0; row < lines.length; row++) {
358
+ const line = lines[row]!;
359
+ let clean = "";
360
+ let from = 0;
361
+ for (const match of line.matchAll(markerPattern)) {
362
+ clean += line.slice(from, match.index);
363
+ from = match.index + match[0].length;
364
+ const id = Number(match[1]);
365
+ const block = state.descriptors.get(id);
366
+ const currentOrdinal = ordinal++;
367
+ if (block) live.add(id);
368
+ if (match[2] !== "1") continue;
369
+ const suffix = line.slice(from);
370
+ const labelAt = suffix.indexOf(LABEL);
371
+ if (labelAt < 0 || stripAnsi(suffix.slice(0, labelAt)) !== "") continue;
372
+ if (!block?.label || !enabled) {
373
+ // Preserve styles and width while removing an unresolved extension label.
374
+ clean += suffix.slice(0, labelAt) + " ".repeat(LABEL.length);
375
+ from += labelAt + LABEL.length;
376
+ continue;
377
+ }
378
+ const region = { row, col: visibleWidth(clean), width: LABEL.length,
379
+ text: restoreTabs(source, currentOrdinal, block.text) };
380
+ regionTargets.set(region, `${currentOrdinal}\0${source}`);
381
+ regions.push(region);
382
+ }
383
+ // Only sanitize the final array; native block lines retain their descriptor IDs.
384
+ lines[row] = clean + line.slice(from);
385
+ }
386
+ for (const id of state.descriptors.keys()) if (!live.has(id)) state.descriptors.delete(id);
387
+ cancelStalePress(this, regions);
388
+ state.lines = lines;
389
+ state.regions = regions;
390
+ return lines;
391
+ } catch (error) {
392
+ cancelStalePress(this, []);
393
+ state.lines = undefined;
394
+ state.regions = [];
395
+ state.descriptors.clear();
396
+ state.policy = undefined;
397
+ this.invalidate();
398
+ throw error;
316
399
  }
317
- markdownRegions.set(this, { lines, regions });
318
- return lines;
319
400
  }),
320
401
  patch(markdownProto, "handleMouse", function (this: MarkdownInternals, event) {
321
- if (!active()) return originalMarkdownMouse?.call(this, event);
402
+ if (!active()) {
403
+ cancelStalePress(this, []);
404
+ return originalMarkdownMouse?.call(this, event);
405
+ }
322
406
  const regions = markdownRegions.get(this)?.regions ?? [];
323
407
  return handleCopyMouse(this, regions, event, copy) ?? originalMarkdownMouse?.call(this, event);
324
408
  }),
325
409
  patch(userProto, "render", function (this: UserMessageInternals, width) {
326
410
  if (!installed) return originalUserRender.call(this, width);
327
411
  const lines = originalUserRender.call(this, width);
328
- userRegions.delete(this);
329
412
  const pad = typeof this.outputPad === "number" ? this.outputPad : 1;
330
413
  const col = width - pad - LABEL.length;
331
- if (!active() || lines.length < 2 || col < 1 || typeof this.text !== "string") return lines;
414
+ if (!active() || lines.length < 2 || col < 1 || typeof this.text !== "string") {
415
+ cancelStalePress(this, []);
416
+ userRegions.delete(this);
417
+ return lines;
418
+ }
332
419
  // The last row is the bubble's bottom padding, so the label never shares a row with a code-block label.
333
420
  const row = lines.length - 1;
334
421
  const theme = options.getTheme();
335
- lines[row] = compositeTuiLine(lines[row]!, theme.bg("userMessageBg", label()), col, LABEL.length, width);
336
- userRegions.set(this, { row, col, width: LABEL.length, text: this.text });
422
+ const button = theme.bg("userMessageBg", label());
423
+ const cached = userRegions.get(this);
424
+ // UserMessage.render returns a fresh array, so compare its undecorated bottom row instead.
425
+ if (cached && cached.source === lines[row] && cached.label === button && cached.width === width
426
+ && cached.region.row === row && cached.region.col === col && cached.region.text === this.text) {
427
+ lines[row] = cached.line;
428
+ return lines;
429
+ }
430
+ const source = lines[row]!;
431
+ lines[row] = compositeTuiLine(source, button, col, LABEL.length, width);
432
+ const region = { row, col, width: LABEL.length, text: this.text };
433
+ cancelStalePress(this, [region]);
434
+ userRegions.set(this, {
435
+ region,
436
+ source, label: button, width, line: lines[row]!,
437
+ });
337
438
  return lines;
338
439
  }),
339
440
  patch(userProto, "handleMouse", function (this: UserMessageInternals, event) {
340
- if (!active()) return originalUserMouse?.call(this, event);
341
- const region = userRegions.get(this);
441
+ if (!active()) {
442
+ cancelStalePress(this, []);
443
+ return originalUserMouse?.call(this, event);
444
+ }
445
+ const region = userRegions.get(this)?.region;
342
446
  return handleCopyMouse(this, region ? [region] : [], event, copy) ?? originalUserMouse?.call(this, event);
343
447
  }),
344
448
  ];
@@ -352,6 +456,8 @@ export function installCopyButtons(options: {
352
456
  for (const restore of restores) restore();
353
457
  delete markdownProto[PATCH_KEY];
354
458
  press = undefined;
459
+ markdownRegions = new WeakMap();
460
+ userRegions = new WeakMap();
355
461
  try {
356
462
  refresh();
357
463
  } catch {
@@ -0,0 +1,119 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { randomUUID } from "node:crypto";
3
+ import { sanitizeStatusPreservingStyles } from "./utils.ts";
4
+
5
+ const BUILTIN_SEGMENTS = [
6
+ "cwd", "hostname", "sessionName", "git", "runtime", "timer", "context", "model", "tokens", "cost",
7
+ ] as const;
8
+
9
+ export type FooterBuiltinSegment = typeof BUILTIN_SEGMENTS[number];
10
+
11
+ export type FooterPosition =
12
+ | { row: 1 | 2; side: "left" | "right"; edge?: "start" | "end" }
13
+ | { before: FooterBuiltinSegment }
14
+ | { after: FooterBuiltinSegment };
15
+
16
+ export interface FooterSegment {
17
+ key: string;
18
+ position: FooterPosition;
19
+ text: string;
20
+ compactText?: string;
21
+ order?: number;
22
+ priority?: number;
23
+ }
24
+
25
+ export interface RegisteredFooterSegment extends FooterSegment {
26
+ order: number;
27
+ priority: number;
28
+ }
29
+
30
+ export type FooterSetMessage = FooterSegment & { lifecycle: string };
31
+ export type FooterRemoveMessage = { key: string; lifecycle: string };
32
+ export type FooterStateMessage = { available: true; lifecycle: string } | { available: false };
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
38
+ function isAnchor(value: unknown): value is FooterBuiltinSegment {
39
+ return typeof value === "string" && BUILTIN_SEGMENTS.some((anchor) => anchor === value);
40
+ }
41
+
42
+ function readPosition(value: unknown): FooterPosition | undefined {
43
+ if (!isRecord(value)) return undefined;
44
+ if ("before" in value) {
45
+ if ("after" in value || "row" in value || "side" in value || "edge" in value) return undefined;
46
+ return isAnchor(value.before) ? { before: value.before } : undefined;
47
+ }
48
+ if ("after" in value) {
49
+ if ("row" in value || "side" in value || "edge" in value) return undefined;
50
+ return isAnchor(value.after) ? { after: value.after } : undefined;
51
+ }
52
+ if (value.row !== 1 && value.row !== 2) return undefined;
53
+ if (value.side !== "left" && value.side !== "right") return undefined;
54
+ if (value.edge !== undefined && value.edge !== "start" && value.edge !== "end") return undefined;
55
+ return { row: value.row, side: value.side, edge: value.edge ?? "end" };
56
+ }
57
+
58
+ function readSegment(value: Record<string, unknown>): RegisteredFooterSegment | undefined {
59
+ if (typeof value.key !== "string" || value.key.trim().length === 0 || typeof value.text !== "string") return undefined;
60
+ if (value.compactText !== undefined && typeof value.compactText !== "string") return undefined;
61
+ if (value.order !== undefined && (typeof value.order !== "number" || !Number.isFinite(value.order))) return undefined;
62
+ if (value.priority !== undefined && (typeof value.priority !== "number" || !Number.isFinite(value.priority))) return undefined;
63
+ const position = readPosition(value.position);
64
+ if (!position) return undefined;
65
+ return {
66
+ key: value.key,
67
+ position,
68
+ text: sanitizeStatusPreservingStyles(value.text),
69
+ compactText: value.compactText === undefined ? undefined : sanitizeStatusPreservingStyles(value.compactText),
70
+ order: value.order ?? 0,
71
+ priority: value.priority ?? -1,
72
+ };
73
+ }
74
+
75
+ /** Session-local storage for the public footer protocol. Pi owns subscription cleanup. */
76
+ export function createFooterSegments(events: ExtensionAPI["events"], requestRender: () => void) {
77
+ let lifecycle: string | undefined;
78
+ const segments = new Map<string, RegisteredFooterSegment>();
79
+ let ordered: readonly RegisteredFooterSegment[] = [];
80
+ const announce = () => {
81
+ const state: FooterStateMessage = lifecycle === undefined
82
+ ? { available: false }
83
+ : { available: true, lifecycle };
84
+ events.emit("open-tui:footer:state", state);
85
+ };
86
+ const changed = () => {
87
+ ordered = [...segments.values()].sort((a, b) => a.order - b.order || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
88
+ requestRender();
89
+ };
90
+
91
+ events.on("open-tui:footer:query", announce);
92
+ events.on("open-tui:footer:set", (data) => {
93
+ if (!lifecycle || !isRecord(data) || data.lifecycle !== lifecycle) return;
94
+ const segment = readSegment(data);
95
+ if (!segment) return;
96
+ segments.set(segment.key, segment);
97
+ changed();
98
+ });
99
+ events.on("open-tui:footer:remove", (data) => {
100
+ if (!lifecycle || !isRecord(data) || data.lifecycle !== lifecycle || typeof data.key !== "string") return;
101
+ if (segments.delete(data.key)) changed();
102
+ });
103
+
104
+ return {
105
+ getSegments: (): readonly RegisteredFooterSegment[] => ordered,
106
+ activate(): void {
107
+ if (lifecycle !== undefined) return;
108
+ lifecycle = randomUUID();
109
+ announce();
110
+ },
111
+ deactivate(): void {
112
+ if (lifecycle === undefined) return;
113
+ lifecycle = undefined;
114
+ segments.clear();
115
+ ordered = [];
116
+ announce();
117
+ },
118
+ };
119
+ }