pi-open-tui 0.4.2 → 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.
@@ -22,6 +22,7 @@ export interface FooterSegments {
22
22
  tokens: boolean;
23
23
  cost: boolean;
24
24
  liveTps: boolean;
25
+ extensionSegments: boolean;
25
26
  extensionStatuses: boolean;
26
27
  capitalizeProviderName: boolean;
27
28
  nerdIconSpacing: boolean;
@@ -41,6 +42,14 @@ export interface ThinkingPeekConfig {
41
42
  lines: ThinkingPeekLines;
42
43
  }
43
44
 
45
+ export interface ToolViewConfig {
46
+ mode: ToolViewMode;
47
+ /** In compact mode, also collapse tools that are not Pi's own built-ins. */
48
+ extensionTools: boolean;
49
+ /** Tool names that always keep their native rendering. */
50
+ exclude: string[];
51
+ }
52
+
44
53
  export interface OpenTuiConfig {
45
54
  enabled: boolean;
46
55
  /** Override Pi's header with the open-tui header. When false, the header slot is left to Pi or another extension. */
@@ -56,9 +65,7 @@ export interface OpenTuiConfig {
56
65
  footerSegments: FooterSegments;
57
66
  telemetry: TelemetryConfig;
58
67
  thinkingPeek: ThinkingPeekConfig;
59
- toolView: {
60
- mode: ToolViewMode;
61
- };
68
+ toolView: ToolViewConfig;
62
69
  }
63
70
 
64
71
  export const DEFAULT_CONFIG: OpenTuiConfig = {
@@ -83,6 +90,7 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
83
90
  tokens: true,
84
91
  cost: true,
85
92
  liveTps: true,
93
+ extensionSegments: true,
86
94
  extensionStatuses: true,
87
95
  capitalizeProviderName: true,
88
96
  nerdIconSpacing: false,
@@ -101,6 +109,8 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
101
109
  },
102
110
  toolView: {
103
111
  mode: "native",
112
+ extensionTools: false,
113
+ exclude: [],
104
114
  },
105
115
  };
106
116
 
@@ -113,6 +123,17 @@ function normalizeThinkingPeekLines(value: unknown): ThinkingPeekLines {
113
123
  return value === 0 || value === 1 || value === 2 ? value : DEFAULT_CONFIG.thinkingPeek.lines;
114
124
  }
115
125
 
126
+ function normalizeToolView(value: unknown): ToolViewConfig {
127
+ const source = typeof value === "object" && value !== null && !Array.isArray(value)
128
+ ? (value as Partial<Record<keyof ToolViewConfig, unknown>>)
129
+ : {};
130
+ return {
131
+ mode: source.mode === "compact" ? "compact" : DEFAULT_CONFIG.toolView.mode,
132
+ extensionTools: source.extensionTools === true,
133
+ exclude: Array.isArray(source.exclude) ? source.exclude.filter((name): name is string => typeof name === "string") : [],
134
+ };
135
+ }
136
+
116
137
  const ICON_MODES: IconMode[] = ["auto", "nerd", "unicode", "ascii"];
117
138
 
118
139
  // Non-boolean flags (e.g. "false" strings) fall back to defaults instead of reading as truthy.
@@ -198,10 +219,7 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
198
219
  } else {
199
220
  config.thinkingPeek.lines = normalizeThinkingPeekLines(config.thinkingPeek.lines);
200
221
  }
201
- if (typeof config.toolView !== "object" || config.toolView === null
202
- || (config.toolView.mode !== "native" && config.toolView.mode !== "compact")) {
203
- config.toolView = structuredClone(DEFAULT_CONFIG.toolView);
204
- }
222
+ config.toolView = normalizeToolView(config.toolView);
205
223
  return config;
206
224
  } catch (err) {
207
225
  notify?.(`open-tui config parse error: ${err instanceof Error ? err.message : String(err)}`, "warning");
@@ -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, { region: CopyRegion; source: string; label: string; width: number; line: string }>();
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
  }
@@ -150,7 +165,8 @@ function cancelStalePress(owner: object, regions: readonly CopyRegion[]): void {
150
165
  if (press?.owner !== owner) return;
151
166
  const previous = press.region;
152
167
  if (!regions.some((region) => region.row === previous.row && region.col === previous.col
153
- && region.width === previous.width && region.text === previous.text)) {
168
+ && region.width === previous.width && region.text === previous.text
169
+ && regionTargets.get(region) === regionTargets.get(previous))) {
154
170
  press = undefined;
155
171
  }
156
172
  }
@@ -228,21 +244,28 @@ export function installCopyButtons(options: {
228
244
  const altScreenProto = TuiAltScreen.prototype as unknown as AltScreenInternals;
229
245
 
230
246
  const refresh = () => {
247
+ press = undefined;
231
248
  const tui = options.getTui();
232
249
  tui?.invalidate();
233
250
  tui?.requestRender();
234
251
  };
235
- if (markdownProto[PATCH_KEY] || markdownProto[PRETTY_TUI_KEY]) return { cleanup: () => {}, refresh };
252
+ if (markdownProto[PATCH_KEY]) return { cleanup: () => {}, refresh };
236
253
 
237
- 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") {
238
257
  return { cleanup: () => {}, refresh };
239
258
  }
240
259
  let installed = true;
241
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");
242
265
  const copy = (text: string) => {
243
266
  const tui = options.getTui();
244
267
  const flash = (message: string) => {
245
- if (tui instanceof TuiAltScreen) tui.flash(message);
268
+ if (installed && tui === options.getTui() && tui instanceof TuiAltScreen) tui.flash(message);
246
269
  };
247
270
  copyToClipboard(text).then(() => flash("Copied!"), () => flash("Copy failed"));
248
271
  };
@@ -257,7 +280,10 @@ export function installCopyButtons(options: {
257
280
 
258
281
  const restores = [
259
282
  patch(altScreenProto, "handleMouseEvent", function (this: AltScreenInternals, raw) {
260
- 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
+ }
261
287
  const motion = (raw.button & 32) !== 0;
262
288
  if (!raw.release && !motion) press = undefined;
263
289
  // Leave press/drag to Pi's selection; intercept only a qualifying release before copy-on-select runs.
@@ -273,63 +299,110 @@ export function installCopyButtons(options: {
273
299
  }),
274
300
  patch(markdownProto, "renderToken", function (this: MarkdownInternals, token, width, nextTokenType, styleContext) {
275
301
  const scope = renderScope;
276
- if (!installed || !scope || token.type !== "code") return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
277
- const ordinal = scope.ordinal++;
278
- if (!scope.frame || !active() || width < MIN_FRAME_WIDTH) {
302
+ if (!installed || !scope?.active || scope.owner !== this || token.type !== "code") {
279
303
  return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
280
304
  }
281
- const theme = options.getTheme();
282
305
  const showLabel = scope.label && width >= MIN_FRAME_WIDTH + LABEL.length + 2;
283
- const lines = renderFramedCode(
284
- this.theme,
285
- token.text,
286
- token.lang,
287
- width,
288
- showLabel ? label() : undefined,
289
- (text) => theme.fg("borderMuted", text),
290
- );
291
- if (showLabel) scope.blocks.push({ text: token.text, ordinal });
292
- // Same spacing rule as Pi's own code-block rendering.
293
- 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 });
294
321
  return lines;
295
322
  }),
296
323
  patch(markdownProto, "render", function (this: MarkdownInternals, width) {
297
324
  if (!installed) return originalRender.call(this, width);
298
- const outer = renderScope;
299
- const scope = { frame: false, label: false, ordinal: 0, blocks: [] as { text: string; ordinal: number }[] };
300
- renderScope = scope;
301
- let lines: string[];
302
- try {
303
- lines = originalRender.call(this, width);
304
- } finally {
305
- 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;
306
337
  }
307
- // A cache hit returns the array we already processed and skips renderToken.
308
- if (markdownRegions.get(this)?.lines === lines) return lines;
309
- const source = typeof this.text === "string" ? this.text : "";
310
- const regions: CopyRegion[] = [];
311
- let next = 0;
312
- for (let row = 0; row < lines.length; row++) {
313
- const line = lines[row]!;
314
- const at = line.indexOf(MARKER);
315
- if (at < 0) continue;
316
- // The renderer caches this array, so stripping in place keeps the marker out of later frames.
317
- lines[row] = line.split(MARKER).join("");
318
- const block = scope.blocks[next++];
319
- if (!block) continue;
320
- regions.push({
321
- row,
322
- col: visibleWidth(line.slice(0, at)),
323
- width: LABEL.length,
324
- text: restoreTabs(source, block.ordinal, block.text),
325
- });
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;
326
399
  }
327
- cancelStalePress(this, regions);
328
- markdownRegions.set(this, { lines, regions });
329
- return lines;
330
400
  }),
331
401
  patch(markdownProto, "handleMouse", function (this: MarkdownInternals, event) {
332
- if (!active()) return originalMarkdownMouse?.call(this, event);
402
+ if (!active()) {
403
+ cancelStalePress(this, []);
404
+ return originalMarkdownMouse?.call(this, event);
405
+ }
333
406
  const regions = markdownRegions.get(this)?.regions ?? [];
334
407
  return handleCopyMouse(this, regions, event, copy) ?? originalMarkdownMouse?.call(this, event);
335
408
  }),
@@ -365,7 +438,10 @@ export function installCopyButtons(options: {
365
438
  return lines;
366
439
  }),
367
440
  patch(userProto, "handleMouse", function (this: UserMessageInternals, event) {
368
- if (!active()) return originalUserMouse?.call(this, event);
441
+ if (!active()) {
442
+ cancelStalePress(this, []);
443
+ return originalUserMouse?.call(this, event);
444
+ }
369
445
  const region = userRegions.get(this)?.region;
370
446
  return handleCopyMouse(this, region ? [region] : [], event, copy) ?? originalUserMouse?.call(this, event);
371
447
  }),
@@ -380,6 +456,8 @@ export function installCopyButtons(options: {
380
456
  for (const restore of restores) restore();
381
457
  delete markdownProto[PATCH_KEY];
382
458
  press = undefined;
459
+ markdownRegions = new WeakMap();
460
+ userRegions = new WeakMap();
383
461
  try {
384
462
  refresh();
385
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
+ }