pi-open-tui 0.4.2 → 0.4.4

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,9 +19,12 @@ export interface FooterSegments {
19
19
  gitCommit: boolean;
20
20
  runtime: boolean;
21
21
  context: boolean;
22
+ /** Context progress bar and its icon; off keeps the percentage and token usage. */
23
+ contextBar: boolean;
22
24
  tokens: boolean;
23
25
  cost: boolean;
24
26
  liveTps: boolean;
27
+ extensionSegments: boolean;
25
28
  extensionStatuses: boolean;
26
29
  capitalizeProviderName: boolean;
27
30
  nerdIconSpacing: boolean;
@@ -41,6 +44,14 @@ export interface ThinkingPeekConfig {
41
44
  lines: ThinkingPeekLines;
42
45
  }
43
46
 
47
+ export interface ToolViewConfig {
48
+ mode: ToolViewMode;
49
+ /** In compact mode, also collapse tools that are not Pi's own built-ins. */
50
+ extensionTools: boolean;
51
+ /** Tool names that always keep their native rendering. */
52
+ exclude: string[];
53
+ }
54
+
44
55
  export interface OpenTuiConfig {
45
56
  enabled: boolean;
46
57
  /** Override Pi's header with the open-tui header. When false, the header slot is left to Pi or another extension. */
@@ -56,9 +67,7 @@ export interface OpenTuiConfig {
56
67
  footerSegments: FooterSegments;
57
68
  telemetry: TelemetryConfig;
58
69
  thinkingPeek: ThinkingPeekConfig;
59
- toolView: {
60
- mode: ToolViewMode;
61
- };
70
+ toolView: ToolViewConfig;
62
71
  }
63
72
 
64
73
  export const DEFAULT_CONFIG: OpenTuiConfig = {
@@ -80,9 +89,11 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
80
89
  gitCommit: false,
81
90
  runtime: true,
82
91
  context: true,
92
+ contextBar: true,
83
93
  tokens: true,
84
94
  cost: true,
85
95
  liveTps: true,
96
+ extensionSegments: true,
86
97
  extensionStatuses: true,
87
98
  capitalizeProviderName: true,
88
99
  nerdIconSpacing: false,
@@ -101,6 +112,8 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
101
112
  },
102
113
  toolView: {
103
114
  mode: "native",
115
+ extensionTools: false,
116
+ exclude: [],
104
117
  },
105
118
  };
106
119
 
@@ -113,6 +126,17 @@ function normalizeThinkingPeekLines(value: unknown): ThinkingPeekLines {
113
126
  return value === 0 || value === 1 || value === 2 ? value : DEFAULT_CONFIG.thinkingPeek.lines;
114
127
  }
115
128
 
129
+ function normalizeToolView(value: unknown): ToolViewConfig {
130
+ const source = typeof value === "object" && value !== null && !Array.isArray(value)
131
+ ? (value as Partial<Record<keyof ToolViewConfig, unknown>>)
132
+ : {};
133
+ return {
134
+ mode: source.mode === "compact" ? "compact" : DEFAULT_CONFIG.toolView.mode,
135
+ extensionTools: source.extensionTools === true,
136
+ exclude: Array.isArray(source.exclude) ? source.exclude.filter((name): name is string => typeof name === "string") : [],
137
+ };
138
+ }
139
+
116
140
  const ICON_MODES: IconMode[] = ["auto", "nerd", "unicode", "ascii"];
117
141
 
118
142
  // Non-boolean flags (e.g. "false" strings) fall back to defaults instead of reading as truthy.
@@ -198,10 +222,7 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
198
222
  } else {
199
223
  config.thinkingPeek.lines = normalizeThinkingPeekLines(config.thinkingPeek.lines);
200
224
  }
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
- }
225
+ config.toolView = normalizeToolView(config.toolView);
205
226
  return config;
206
227
  } catch (err) {
207
228
  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
 
@@ -48,6 +48,7 @@ interface RawMouseEvent {
48
48
  }
49
49
 
50
50
  interface AltScreenInternals {
51
+ children: TUI["children"];
51
52
  handleMouseEvent(event: RawMouseEvent): void;
52
53
  createMouseEvent(type: TuiMouseEvent["type"], button: number, x: number, y: number): TuiMouseEvent;
53
54
  resetTextSelection(): void;
@@ -58,6 +59,7 @@ interface AltScreenInternals {
58
59
  interface MarkdownInternals {
59
60
  text?: unknown;
60
61
  theme: MarkdownTheme;
62
+ invalidate(): void;
61
63
  render(width: number): string[];
62
64
  renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: unknown): string[];
63
65
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
@@ -69,26 +71,39 @@ interface UserMessageInternals {
69
71
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
70
72
  }
71
73
 
74
+ interface MarkdownState {
75
+ active: boolean;
76
+ policy?: { frame: boolean; label: boolean };
77
+ lines?: readonly string[];
78
+ regions: CopyRegion[];
79
+ descriptors: Map<number, { text: string; label: boolean }>;
80
+ nextId: number;
81
+ }
82
+
72
83
  /** 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;
84
+ let renderScope: { owner: MarkdownInternals; state: MarkdownState; active: boolean; frame: boolean; label: boolean } | undefined;
74
85
  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 }>();
86
+ const regionTargets = new WeakMap<CopyRegion, string>();
77
87
 
78
88
  /**
79
- * Markdown transformer that only records the message kind; Pi calls it during Markdown.render.
89
+ * Pi calls the transform before consulting its native block cache, so policy changes can invalidate it in this pass.
80
90
  * Thinking blocks keep Pi's fences. Streaming replies get the frame but no label: it would copy unfinished code.
81
91
  */
82
92
  export function markCopyScope(markdown: string, context: MarkdownTransformContext): string {
83
93
  if (renderScope) {
84
- renderScope.frame = context.messageType !== "assistant-thinking";
94
+ renderScope.frame = renderScope.active && context.messageType !== "assistant-thinking";
85
95
  renderScope.label = renderScope.frame && !context.isStreaming;
96
+ const previous = renderScope.state.policy;
97
+ if (previous && (previous.frame !== renderScope.frame || previous.label !== renderScope.label)) {
98
+ renderScope.owner.invalidate();
99
+ }
100
+ renderScope.state.policy = { frame: renderScope.frame, label: renderScope.label };
86
101
  }
87
102
  return markdown;
88
103
  }
89
104
 
90
105
  /**
91
- * Rounded frame with the language and the label in the top border, like pi-pretty-tui:
106
+ * Rounded frame with the language and the label in the top border:
92
107
  * `╭─ ts ───── [Copy] ╮`. It replaces the two fence rows, so the block keeps its height.
93
108
  */
94
109
  export function renderFramedCode(
@@ -98,6 +113,7 @@ export function renderFramedCode(
98
113
  width: number,
99
114
  label: string | undefined,
100
115
  border: (text: string) => string = theme.codeBlockBorder,
116
+ marker = "",
101
117
  ): string[] {
102
118
  const inner = width - 4;
103
119
  const highlighted = theme.highlightCode?.(code, lang) ?? code.split("\n").map((line) => theme.codeBlock(line));
@@ -111,8 +127,8 @@ export function renderFramedCode(
111
127
  const widest = Math.max(0, ...rows.map((row) => visibleWidth(row)));
112
128
  const frameWidth = Math.min(width, Math.max(widest + 4, visibleWidth(prefix) + 3 + visibleWidth(suffix) + 1));
113
129
  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("╮");
130
+ const top = (label === undefined ? marker : "") + border("╭─ ") + theme.codeBlockBorder(`${name} `) + border("─".repeat(fill))
131
+ + (label === undefined ? "" : ` ${marker}${label} `) + border("╮");
116
132
  const body = rows.map((row) => border("│ ") + row + " ".repeat(Math.max(0, frameWidth - 4 - visibleWidth(row))) + border(" │"));
117
133
  return [top, ...body, border(`╰${"─".repeat(frameWidth - 2)}╯`)];
118
134
  }
@@ -150,7 +166,8 @@ function cancelStalePress(owner: object, regions: readonly CopyRegion[]): void {
150
166
  if (press?.owner !== owner) return;
151
167
  const previous = press.region;
152
168
  if (!regions.some((region) => region.row === previous.row && region.col === previous.col
153
- && region.width === previous.width && region.text === previous.text)) {
169
+ && region.width === previous.width && region.text === previous.text
170
+ && regionTargets.get(region) === regionTargets.get(previous))) {
154
171
  press = undefined;
155
172
  }
156
173
  }
@@ -228,21 +245,28 @@ export function installCopyButtons(options: {
228
245
  const altScreenProto = TuiAltScreen.prototype as unknown as AltScreenInternals;
229
246
 
230
247
  const refresh = () => {
248
+ press = undefined;
231
249
  const tui = options.getTui();
232
250
  tui?.invalidate();
233
251
  tui?.requestRender();
234
252
  };
235
- if (markdownProto[PATCH_KEY] || markdownProto[PRETTY_TUI_KEY]) return { cleanup: () => {}, refresh };
253
+ if (markdownProto[PATCH_KEY]) return { cleanup: () => {}, refresh };
236
254
 
237
- if (typeof altScreenProto.handleMouseEvent !== "function" || typeof altScreenProto.createMouseEvent !== "function") {
255
+ if (typeof markdownProto.render !== "function" || typeof markdownProto.renderToken !== "function"
256
+ || typeof markdownProto.invalidate !== "function" || typeof altScreenProto.handleMouseEvent !== "function"
257
+ || typeof altScreenProto.createMouseEvent !== "function") {
238
258
  return { cleanup: () => {}, refresh };
239
259
  }
240
260
  let installed = true;
241
261
  const active = () => installed && options.isEnabled() && options.getTui()?.mode === "fullscreen";
262
+ let markdownRegions = new WeakMap<object, MarkdownState>();
263
+ let userRegions = new WeakMap<object, { region: CopyRegion; source: string; label: string; width: number; line: string }>();
264
+ const markerPrefix = `${MARKER_PREFIX}${randomUUID()}:`;
265
+ const markerPattern = new RegExp(`${markerPrefix}(\\d+):([01])\x07`, "g");
242
266
  const copy = (text: string) => {
243
267
  const tui = options.getTui();
244
268
  const flash = (message: string) => {
245
- if (tui instanceof TuiAltScreen) tui.flash(message);
269
+ if (installed && tui === options.getTui() && tui instanceof TuiAltScreen) tui.flash(message);
246
270
  };
247
271
  copyToClipboard(text).then(() => flash("Copied!"), () => flash("Copy failed"));
248
272
  };
@@ -257,7 +281,12 @@ export function installCopyButtons(options: {
257
281
 
258
282
  const restores = [
259
283
  patch(altScreenProto, "handleMouseEvent", function (this: AltScreenInternals, raw) {
260
- if (!active() || this !== (options.getTui() as unknown)) return originalAltScreenMouse.call(this, raw);
284
+ // Pi's stable TUI Proxy binds methods to the real renderer and forwards fields.
285
+ // The child list identifies that renderer, even when mode switches reuse a terminal.
286
+ if (!active() || this.children !== options.getTui()?.children) {
287
+ press = undefined;
288
+ return originalAltScreenMouse.call(this, raw);
289
+ }
261
290
  const motion = (raw.button & 32) !== 0;
262
291
  if (!raw.release && !motion) press = undefined;
263
292
  // Leave press/drag to Pi's selection; intercept only a qualifying release before copy-on-select runs.
@@ -273,63 +302,110 @@ export function installCopyButtons(options: {
273
302
  }),
274
303
  patch(markdownProto, "renderToken", function (this: MarkdownInternals, token, width, nextTokenType, styleContext) {
275
304
  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) {
305
+ if (!installed || !scope?.active || scope.owner !== this || token.type !== "code") {
279
306
  return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
280
307
  }
281
- const theme = options.getTheme();
282
308
  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("");
309
+ const id = scope.state.nextId++;
310
+ const marker = `${markerPrefix}${id}:${showLabel ? 1 : 0}\x07`;
311
+ let lines: string[];
312
+ if (scope.frame && width >= MIN_FRAME_WIDTH) {
313
+ const theme = options.getTheme();
314
+ lines = renderFramedCode(this.theme, token.text, token.lang, width, showLabel ? label() : undefined,
315
+ (text) => theme.fg("borderMuted", text), marker);
316
+ // Same spacing rule as Pi's own code-block rendering.
317
+ if (nextTokenType && nextTokenType !== "space") lines.push("");
318
+ } else {
319
+ lines = originalRenderToken.call(this, token, width, nextTokenType, styleContext);
320
+ // Unlabeled occurrences still count toward the current source ordinal.
321
+ lines[0] = marker + (lines[0] ?? "");
322
+ }
323
+ scope.state.descriptors.set(id, { text: token.text, label: showLabel });
294
324
  return lines;
295
325
  }),
296
326
  patch(markdownProto, "render", function (this: MarkdownInternals, width) {
297
327
  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;
328
+ const enabled = active();
329
+ const previous = markdownRegions.get(this);
330
+ const state: MarkdownState = previous ?? { active: enabled, regions: [], descriptors: new Map(), nextId: 0 };
331
+ if (!previous) {
332
+ markdownRegions.set(this, state);
333
+ // A component may already have cached native fences before this installation.
334
+ this.invalidate();
335
+ } else if (state.active !== enabled) {
336
+ this.invalidate();
337
+ cancelStalePress(this, []);
338
+ state.active = enabled;
339
+ state.policy = undefined;
306
340
  }
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
- });
341
+ const run = () => {
342
+ const outer = renderScope;
343
+ renderScope = { owner: this, state, active: enabled, frame: false, label: false };
344
+ try { return originalRender.call(this, width); }
345
+ finally { renderScope = outer; }
346
+ };
347
+ try {
348
+ let lines = run();
349
+ if (state.lines === lines) return lines;
350
+ // A retained native block can outlive its description after a renderer change.
351
+ const missing = lines.some((line) => [...line.matchAll(markerPattern)].some((match) => !state.descriptors.has(Number(match[1]))));
352
+ if (missing) {
353
+ this.invalidate();
354
+ lines = run();
355
+ }
356
+ const source = typeof this.text === "string" ? this.text : "";
357
+ const regions: CopyRegion[] = [];
358
+ const live = new Set<number>();
359
+ let ordinal = 0;
360
+ for (let row = 0; row < lines.length; row++) {
361
+ const line = lines[row]!;
362
+ let clean = "";
363
+ let from = 0;
364
+ for (const match of line.matchAll(markerPattern)) {
365
+ clean += line.slice(from, match.index);
366
+ from = match.index + match[0].length;
367
+ const id = Number(match[1]);
368
+ const block = state.descriptors.get(id);
369
+ const currentOrdinal = ordinal++;
370
+ if (block) live.add(id);
371
+ if (match[2] !== "1") continue;
372
+ const suffix = line.slice(from);
373
+ const labelAt = suffix.indexOf(LABEL);
374
+ if (labelAt < 0 || stripAnsi(suffix.slice(0, labelAt)) !== "") continue;
375
+ if (!block?.label || !enabled) {
376
+ // Preserve styles and width while removing an unresolved extension label.
377
+ clean += suffix.slice(0, labelAt) + " ".repeat(LABEL.length);
378
+ from += labelAt + LABEL.length;
379
+ continue;
380
+ }
381
+ const region = { row, col: visibleWidth(clean), width: LABEL.length,
382
+ text: restoreTabs(source, currentOrdinal, block.text) };
383
+ regionTargets.set(region, `${currentOrdinal}\0${source}`);
384
+ regions.push(region);
385
+ }
386
+ // Only sanitize the final array; native block lines retain their descriptor IDs.
387
+ lines[row] = clean + line.slice(from);
388
+ }
389
+ for (const id of state.descriptors.keys()) if (!live.has(id)) state.descriptors.delete(id);
390
+ cancelStalePress(this, regions);
391
+ state.lines = lines;
392
+ state.regions = regions;
393
+ return lines;
394
+ } catch (error) {
395
+ cancelStalePress(this, []);
396
+ state.lines = undefined;
397
+ state.regions = [];
398
+ state.descriptors.clear();
399
+ state.policy = undefined;
400
+ this.invalidate();
401
+ throw error;
326
402
  }
327
- cancelStalePress(this, regions);
328
- markdownRegions.set(this, { lines, regions });
329
- return lines;
330
403
  }),
331
404
  patch(markdownProto, "handleMouse", function (this: MarkdownInternals, event) {
332
- if (!active()) return originalMarkdownMouse?.call(this, event);
405
+ if (!active()) {
406
+ cancelStalePress(this, []);
407
+ return originalMarkdownMouse?.call(this, event);
408
+ }
333
409
  const regions = markdownRegions.get(this)?.regions ?? [];
334
410
  return handleCopyMouse(this, regions, event, copy) ?? originalMarkdownMouse?.call(this, event);
335
411
  }),
@@ -365,7 +441,10 @@ export function installCopyButtons(options: {
365
441
  return lines;
366
442
  }),
367
443
  patch(userProto, "handleMouse", function (this: UserMessageInternals, event) {
368
- if (!active()) return originalUserMouse?.call(this, event);
444
+ if (!active()) {
445
+ cancelStalePress(this, []);
446
+ return originalUserMouse?.call(this, event);
447
+ }
369
448
  const region = userRegions.get(this)?.region;
370
449
  return handleCopyMouse(this, region ? [region] : [], event, copy) ?? originalUserMouse?.call(this, event);
371
450
  }),
@@ -380,6 +459,8 @@ export function installCopyButtons(options: {
380
459
  for (const restore of restores) restore();
381
460
  delete markdownProto[PATCH_KEY];
382
461
  press = undefined;
462
+ markdownRegions = new WeakMap();
463
+ userRegions = new WeakMap();
383
464
  try {
384
465
  refresh();
385
466
  } 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
+ }