@badliveware/pi-footer-framework 0.1.1 → 0.2.1

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/index.ts CHANGED
@@ -1,40 +1,472 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { fileURLToPath } from "node:url";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { Type } from "@mariozechner/pi-ai";
6
6
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
7
7
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
8
8
 
9
9
  type ChecksState = "pass" | "fail" | "running" | "unknown";
10
- type FooterAnchorMode = "gap" | "left" | "center" | "right" | "spread";
11
- type FooterLine = 1 | 2;
12
- type FooterZone = "left" | "right";
10
+ export type FooterAnchorMode = "gap" | "left" | "center" | "right" | "spread";
11
+ export type FooterLine = number;
12
+ export type FooterZone = "left" | "right";
13
+ export type FooterColumn = number | "center" | "middle" | `${number}%`;
13
14
  type ConfigScope = "user" | "project";
15
+ export type ExternalFooterItemTone = "muted" | "info" | "success" | "warning" | "error" | "accent";
16
+ export type ExternalFooterItemFormat = "auto" | "value" | "label-value" | "status";
17
+ export type FooterAdapterSource = "pi" | "extensionStatus" | "sessionEntry";
14
18
 
15
- interface FooterItemPlacement {
19
+ export interface FooterItemPlacement {
16
20
  visible: boolean;
17
21
  line: FooterLine;
18
22
  zone: FooterZone;
19
23
  order: number;
20
- column?: number;
24
+ column?: FooterColumn;
21
25
  before?: string;
22
26
  after?: string;
23
27
  }
24
28
 
29
+ interface FooterLineLayout {
30
+ anchor: FooterAnchorMode;
31
+ leftWidth: number;
32
+ rightWidthOriginal: number;
33
+ rightWidthFinal: number;
34
+ padCount: number;
35
+ rightStartCol: number;
36
+ rightEndCol: number;
37
+ truncated: boolean;
38
+ }
39
+
40
+ interface FooterRenderedToken {
41
+ text: string;
42
+ style?: string;
43
+ url?: string;
44
+ width: number;
45
+ }
46
+
25
47
  interface FooterItem {
26
48
  id: string;
27
49
  text: string;
28
50
  placement: FooterItemPlacement;
51
+ tokens?: FooterRenderedToken[];
52
+ renderSource?: "template" | "function" | "external";
53
+ }
54
+
55
+ export interface FooterCell {
56
+ raw: string;
57
+ plainText: string;
58
+ itemId?: string;
59
+ continuation?: boolean;
60
+ filler?: boolean;
61
+ }
62
+
63
+ export interface FooterColumnItem {
64
+ id: string;
65
+ text: string;
66
+ placement: Pick<FooterItemPlacement, "column" | "order">;
67
+ }
68
+
69
+ export interface ComposeFooterLineOptions {
70
+ width: number;
71
+ left: string;
72
+ right?: string;
73
+ anchor: FooterAnchorMode;
74
+ minGap: number;
75
+ maxGap: number;
76
+ ellipsis?: string;
77
+ }
78
+
79
+ export function readAnsiEscape(text: string, index: number): string | undefined {
80
+ if (text.charCodeAt(index) !== 0x1b) return undefined;
81
+ const csi = text.slice(index).match(/^\u001b\[[0-?]*[ -/]*[@-~]/)?.[0];
82
+ if (csi) return csi;
83
+ return text.slice(index).match(/^\u001b\][\s\S]*?(?:\u0007|\u001b\\)/)?.[0];
84
+ }
85
+
86
+ type GraphemeSegmenter = { segment(input: string): Iterable<{ segment: string }> };
87
+ const Segmenter = (Intl as unknown as { Segmenter?: new (locale?: string | string[], options?: { granularity: "grapheme" }) => GraphemeSegmenter }).Segmenter;
88
+ const graphemeSegmenter = Segmenter ? new Segmenter(undefined, { granularity: "grapheme" }) : undefined;
89
+
90
+ function* visibleClusters(chunk: string): Iterable<string> {
91
+ if (graphemeSegmenter) {
92
+ for (const { segment } of graphemeSegmenter.segment(chunk)) yield segment;
93
+ return;
94
+ }
95
+ for (const char of Array.from(chunk)) yield char;
96
+ }
97
+
98
+ function updateAnsiState(escape: string, state: { ansi: string; osc: string }): void {
99
+ const osc8 = escape.match(/^\u001b\]8;[^;]*;([\s\S]*?)(?:\u0007|\u001b\\)$/);
100
+ if (osc8) {
101
+ state.osc = osc8[1] ? escape : "";
102
+ return;
103
+ }
104
+ if (!/^\u001b\[[0-?]*[ -/]*m$/.test(escape)) return;
105
+ const params = escape.match(/^\u001b\[([0-?]*)[ -/]*m$/)?.[1] ?? "";
106
+ if (params === "" || params === "0") state.ansi = "";
107
+ else if (params.startsWith("0;") || params.startsWith("0:")) state.ansi = escape;
108
+ else state.ansi += escape;
109
+ }
110
+
111
+ function activeCellPrefix(state: { ansi: string; osc: string }): string {
112
+ return `${state.osc}${state.ansi}`;
113
+ }
114
+
115
+ function activeCellSuffix(state: { ansi: string; osc: string }): string {
116
+ return `${state.ansi ? "\u001b[0m" : ""}${state.osc ? "\u001b]8;;\u0007" : ""}`;
117
+ }
118
+
119
+ function blankFooterCell(): FooterCell {
120
+ return { raw: " ", plainText: " ", filler: true };
121
+ }
122
+
123
+ export function createFooterCells(width: number): FooterCell[] {
124
+ return Array.from({ length: Math.max(0, width) }, blankFooterCell);
125
+ }
126
+
127
+ export function footerCellsFromText(text: string, itemId?: string): FooterCell[] {
128
+ const cells: FooterCell[] = [];
129
+ const state = { ansi: "", osc: "" };
130
+ let pendingZeroWidthRaw = "";
131
+ let pendingZeroWidthPlain = "";
132
+ const appendCluster = (cluster: string) => {
133
+ const clusterWidth = visibleWidth(cluster);
134
+ if (clusterWidth === 0) {
135
+ const raw = `${activeCellPrefix(state)}${cluster}${activeCellSuffix(state)}`;
136
+ let previous: FooterCell | undefined;
137
+ for (let cursor = cells.length - 1; cursor >= 0; cursor -= 1) {
138
+ if (!cells[cursor].continuation) {
139
+ previous = cells[cursor];
140
+ break;
141
+ }
142
+ }
143
+ if (previous) {
144
+ previous.raw += raw;
145
+ previous.plainText += cluster;
146
+ } else {
147
+ pendingZeroWidthRaw += raw;
148
+ pendingZeroWidthPlain += cluster;
149
+ }
150
+ } else {
151
+ cells.push({ raw: `${pendingZeroWidthRaw}${activeCellPrefix(state)}${cluster}${activeCellSuffix(state)}`, plainText: `${pendingZeroWidthPlain}${cluster}`, itemId });
152
+ pendingZeroWidthRaw = "";
153
+ pendingZeroWidthPlain = "";
154
+ for (let i = 1; i < clusterWidth; i += 1) cells.push({ raw: "", plainText: "", itemId, continuation: true });
155
+ }
156
+ };
157
+
158
+ let index = 0;
159
+ while (index < text.length) {
160
+ const escape = readAnsiEscape(text, index);
161
+ if (escape) {
162
+ updateAnsiState(escape, state);
163
+ index += escape.length;
164
+ continue;
165
+ }
166
+
167
+ const nextEscape = text.indexOf("\u001b", index);
168
+ const chunkEnd = nextEscape === -1 ? text.length : nextEscape;
169
+ if (chunkEnd === index) {
170
+ const codePoint = text.codePointAt(index);
171
+ if (codePoint === undefined) break;
172
+ const cluster = String.fromCodePoint(codePoint);
173
+ appendCluster(cluster);
174
+ index += cluster.length;
175
+ continue;
176
+ }
177
+
178
+ for (const cluster of visibleClusters(text.slice(index, chunkEnd))) appendCluster(cluster);
179
+ index = chunkEnd;
180
+ }
181
+ return cells;
182
+ }
183
+
184
+ export function plainFooterText(text: string): string {
185
+ return footerCellsFromText(text)
186
+ .filter((cell) => !cell.continuation)
187
+ .map((cell) => cell.plainText)
188
+ .join("");
189
+ }
190
+
191
+ function clearFooterCells(cells: FooterCell[], start: number, width: number): void {
192
+ if (width <= 0 || start >= cells.length) return;
193
+ const from = clamp(start, 0, cells.length);
194
+ const to = clamp(start + width, 0, cells.length);
195
+ const clearWideRun = (index: number) => {
196
+ let cursor = index;
197
+ while (cursor >= 0 && cells[cursor]?.continuation) cursor -= 1;
198
+ if (cursor < 0 || cursor >= cells.length) return;
199
+ cells[cursor] = blankFooterCell();
200
+ cursor += 1;
201
+ while (cursor < cells.length && cells[cursor].continuation) {
202
+ cells[cursor] = blankFooterCell();
203
+ cursor += 1;
204
+ }
205
+ };
206
+ if (cells[from]?.continuation) clearWideRun(from);
207
+ if (cells[to]?.continuation) clearWideRun(to);
208
+ for (let index = from; index < to; index += 1) cells[index] = blankFooterCell();
209
+ }
210
+
211
+ export function writeFooterText(cells: FooterCell[], start: number, text: string, itemId?: string): void {
212
+ if (start >= cells.length) return;
213
+ const textCells = footerCellsFromText(text, itemId);
214
+ clearFooterCells(cells, start, textCells.length);
215
+ for (let offset = 0; offset < textCells.length; offset += 1) {
216
+ const index = start + offset;
217
+ if (index < 0 || index >= cells.length) continue;
218
+ cells[index] = textCells[offset];
219
+ }
220
+ }
221
+
222
+ export function renderFooterCells(cells: FooterCell[]): string {
223
+ let end = cells.length;
224
+ while (end > 0) {
225
+ const cell = cells[end - 1];
226
+ if (cell.continuation || !cell.filler) break;
227
+ end -= 1;
228
+ }
229
+ return cells.slice(0, end).map((cell) => (cell.continuation ? "" : cell.raw)).join("");
230
+ }
231
+
232
+ export function resolveFooterColumn(column: FooterColumn | undefined, width: number, itemWidth: number): number | undefined {
233
+ if (column === undefined) return undefined;
234
+ if (typeof column === "number") return clamp(column, 0, Math.max(0, width - 1));
235
+ if (column === "center" || column === "middle") return clamp(Math.round((width - itemWidth) / 2), 0, Math.max(0, width - 1));
236
+ const percent = Number(column.slice(0, -1));
237
+ if (!Number.isFinite(percent)) return undefined;
238
+ const target = Math.round((width - 1) * (percent / 100));
239
+ return clamp(Math.round(target - itemWidth / 2), 0, Math.max(0, width - 1));
240
+ }
241
+
242
+ export function composeFooterLine(options: ComposeFooterLineOptions): { line: string; layout: FooterLineLayout } {
243
+ const { width, left, right, anchor, minGap, maxGap, ellipsis = "..." } = options;
244
+ const leftWidth = visibleWidth(left);
245
+ const cells = createFooterCells(width);
246
+ const compactLeft = truncateToWidth(left, width, ellipsis);
247
+ writeFooterText(cells, 0, compactLeft);
248
+
249
+ if (!right || visibleWidth(right) === 0) {
250
+ return {
251
+ line: renderFooterCells(cells),
252
+ layout: {
253
+ anchor,
254
+ leftWidth,
255
+ rightWidthOriginal: 0,
256
+ rightWidthFinal: 0,
257
+ padCount: 0,
258
+ rightStartCol: leftWidth,
259
+ rightEndCol: leftWidth,
260
+ truncated: visibleWidth(compactLeft) < leftWidth,
261
+ },
262
+ };
263
+ }
264
+
265
+ const rightWidthOriginal = visibleWidth(right);
266
+ const naturalPad = width - leftWidth - rightWidthOriginal;
267
+ let padCount = minGap;
268
+ if (anchor === "right" || anchor === "spread") {
269
+ padCount = Math.max(minGap, naturalPad);
270
+ } else if (anchor === "center") {
271
+ padCount = Math.max(minGap, Math.floor(naturalPad / 2));
272
+ padCount = Math.min(padCount, maxGap);
273
+ } else if (anchor === "gap") {
274
+ padCount = Math.max(minGap, Math.min(naturalPad, maxGap));
275
+ } else if (anchor === "left") {
276
+ padCount = minGap;
277
+ }
278
+
279
+ const availableForRight = Math.max(0, width - leftWidth - padCount);
280
+ const compactRight = truncateToWidth(right, availableForRight, ellipsis);
281
+ const rightWidthFinal = visibleWidth(compactRight);
282
+ const rightStartCol = leftWidth + padCount;
283
+ writeFooterText(cells, rightStartCol, compactRight);
284
+ const rightEndCol = Math.max(rightStartCol, rightStartCol + rightWidthFinal - 1);
285
+ return {
286
+ line: renderFooterCells(cells),
287
+ layout: {
288
+ anchor,
289
+ leftWidth,
290
+ rightWidthOriginal,
291
+ rightWidthFinal,
292
+ padCount,
293
+ rightStartCol,
294
+ rightEndCol,
295
+ truncated: rightWidthFinal < rightWidthOriginal,
296
+ },
297
+ };
298
+ }
299
+
300
+ export function overlayFooterColumnItems(width: number, line: string, items: FooterColumnItem[], ellipsis = "..."): string {
301
+ const cells = createFooterCells(width);
302
+ writeFooterText(cells, 0, line);
303
+ const sorted = items
304
+ .map((item) => ({ item, column: resolveFooterColumn(item.placement.column, width, visibleWidth(item.text)) }))
305
+ .filter((entry): entry is { item: FooterColumnItem; column: number } => entry.column !== undefined)
306
+ .sort((a, b) => a.column - b.column || a.item.placement.order - b.item.placement.order);
307
+ for (const { item, column } of sorted) {
308
+ const available = Math.max(0, width - column);
309
+ const text = truncateToWidth(item.text, available, ellipsis);
310
+ writeFooterText(cells, column, text, item.id);
311
+ }
312
+ return renderFooterCells(cells);
313
+ }
314
+
315
+ export interface FooterSpan {
316
+ text: unknown;
317
+ style?: string;
318
+ url?: string;
319
+ }
320
+
321
+ export type FooterRenderable = string | number | boolean | null | undefined | FooterSpan | FooterRenderable[];
322
+
323
+ export interface FooterRenderPiContext {
324
+ cwd: string;
325
+ model: Record<string, unknown> & { id?: string; provider?: string; thinking?: string };
326
+ stats: Record<string, unknown> & { inputText?: string; outputText?: string; costText?: string };
327
+ context?: Record<string, unknown> & { percentText?: string; tokenText?: string; tone?: ExternalFooterItemTone };
328
+ branch?: Record<string, unknown> & { name?: string; label?: string; prNumber?: number; prUrl?: string };
329
+ pr?: Record<string, unknown> & { number?: number; url?: string; checkGlyph?: string; checkTone?: ExternalFooterItemTone; commentsText?: string };
330
+ extensionStatuses: Record<string, unknown>;
331
+ }
332
+
333
+ export interface FooterRenderContext {
334
+ id: string;
335
+ value?: unknown;
336
+ label?: string;
337
+ status?: unknown;
338
+ data?: unknown;
339
+ url?: string;
340
+ source?: unknown;
341
+ pi: FooterRenderPiContext;
342
+ span(text: unknown, style?: string, options?: { url?: string }): FooterSpan;
343
+ fn: {
344
+ text(value: unknown): string;
345
+ width(value: string): number;
346
+ truncate(value: unknown, maxWidth: number, ellipsis?: string): string;
347
+ compactPath(value: unknown, maxWidth: number, tailSegments?: number): string;
348
+ };
349
+ }
350
+
351
+ export type FooterRenderFunction = (context: FooterRenderContext) => FooterRenderable;
352
+
353
+ export interface FooterItemConfig extends Partial<FooterItemPlacement> {
354
+ render?: FooterRenderFunction;
355
+ }
356
+
357
+ interface FooterItemDisplayHint {
358
+ label?: string;
359
+ icon?: string;
360
+ format?: ExternalFooterItemFormat;
361
+ tone?: ExternalFooterItemTone;
362
+ placement?: Partial<FooterItemPlacement>;
29
363
  }
30
364
 
31
365
  interface ExternalFooterItemEvent {
32
366
  id: string;
367
+ label?: string;
368
+ value?: unknown;
369
+ status?: unknown;
370
+ data?: unknown;
371
+ url?: string;
372
+ tone?: ExternalFooterItemTone;
373
+ /** Compatibility input. Prefer structured value/status/data plus optional hint. */
33
374
  text?: string;
375
+ /** Display hint only. User config always wins over this placement/formatting advice. */
376
+ hint?: FooterItemDisplayHint;
34
377
  placement?: Partial<FooterItemPlacement>;
35
378
  remove?: boolean;
36
379
  }
37
380
 
381
+ interface ExternalFooterItem {
382
+ id: string;
383
+ label?: string;
384
+ value?: unknown;
385
+ status?: unknown;
386
+ data?: unknown;
387
+ url?: string;
388
+ tone?: ExternalFooterItemTone;
389
+ text?: string;
390
+ hint: FooterItemDisplayHint;
391
+ }
392
+
393
+ interface FooterRenderDiagnostic {
394
+ itemId: string;
395
+ line: FooterLine;
396
+ severity: "warning";
397
+ message: string;
398
+ itemPlainText: string;
399
+ linePlainText?: string;
400
+ }
401
+
402
+ interface FooterSnapshot {
403
+ width: number;
404
+ lines: Array<{ line: FooterLine; text: string; plainText: string; layout: FooterLineLayout }>;
405
+ line1: string;
406
+ line2: string;
407
+ line1PlainText: string;
408
+ line2PlainText: string;
409
+ line1Layout: FooterLineLayout;
410
+ line2Layout: FooterLineLayout;
411
+ gitBranch: string | null;
412
+ renderedItems: Array<{ id: string; line: FooterLine; zone: FooterZone; order: number; column?: FooterColumn; width: number; plainText: string; renderSource?: string; tokens?: FooterRenderedToken[] }>;
413
+ renderDiagnostics: FooterRenderDiagnostic[];
414
+ extensionStatuses: Array<{ key: string; value: string }>;
415
+ model: string;
416
+ contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } | null;
417
+ thinkingLevel: string;
418
+ cwd: string;
419
+ }
420
+
421
+ export interface FooterAdapterConfig {
422
+ source: FooterAdapterSource;
423
+ /** Built-in Pi source key, extension status key, or custom entry type for sessionEntry adapters. */
424
+ key: string;
425
+ itemId?: string;
426
+ label?: string;
427
+ path?: string;
428
+ match?: string;
429
+ group?: string | number;
430
+ urlPath?: string;
431
+ tone?: ExternalFooterItemTone;
432
+ format?: ExternalFooterItemFormat;
433
+ /** Liquid-style interpolation template. Supports variables, string literals, and style filters. */
434
+ template?: string;
435
+ /** Optional fallback template when the selected source is empty. */
436
+ emptyTemplate?: string;
437
+ /** Default style applied to the full rendered adapter item. */
438
+ style?: string;
439
+ /** TS/JS config only: normal render closure returning text/spans. Not persisted to JSON. */
440
+ render?: FooterRenderFunction;
441
+ icon?: string;
442
+ placement?: Partial<FooterItemPlacement>;
443
+ hideWhenEmpty?: boolean;
444
+ }
445
+
446
+ interface FooterTemplateDiagnostic {
447
+ adapterId: string;
448
+ message: string;
449
+ token?: string;
450
+ severity: "warning" | "error";
451
+ }
452
+
453
+ interface FooterSourceInventoryOptions {
454
+ includeTools?: boolean;
455
+ includeCommands?: boolean;
456
+ includeSkills?: boolean;
457
+ includeDetails?: boolean;
458
+ }
459
+
460
+ interface FooterAdapterSourceValue {
461
+ label?: string;
462
+ value?: unknown;
463
+ status?: unknown;
464
+ data?: unknown;
465
+ url?: string;
466
+ tone?: ExternalFooterItemTone;
467
+ hint?: FooterItemDisplayHint;
468
+ }
469
+
38
470
  interface PrState {
39
471
  branch?: string;
40
472
  error?: string;
@@ -48,44 +480,38 @@ interface PrState {
48
480
  };
49
481
  }
50
482
 
483
+ export interface FooterFrameworkConfig {
484
+ enabled?: boolean;
485
+ lineAnchors?: Record<string, FooterAnchorMode>;
486
+ minGap?: number;
487
+ maxGap?: number;
488
+ items?: Record<string, FooterItemConfig>;
489
+ adapters?: Record<string, FooterAdapterConfig>;
490
+ }
491
+
51
492
  interface FooterFrameworkSettings {
52
493
  enabled: boolean;
53
- showCwd: boolean;
54
- showStats: boolean;
55
- showContext: boolean;
56
- showModel: boolean;
57
- showBranch: boolean;
58
- showPr: boolean;
59
- showExtensionStatuses: boolean;
60
- hideZeroMcp: boolean;
61
- line1Anchor: FooterAnchorMode;
62
- line2Anchor: FooterAnchorMode;
63
- branchMaxLength: number;
494
+ lineAnchors: Record<string, FooterAnchorMode>;
64
495
  minGap: number;
65
496
  maxGap: number;
66
497
  items: Record<string, Partial<FooterItemPlacement>>;
498
+ adapters: Record<string, FooterAdapterConfig>;
67
499
  }
68
500
 
501
+ const MIN_FOOTER_LINE = 1;
502
+
69
503
  const DEFAULT_SETTINGS: FooterFrameworkSettings = {
70
504
  enabled: true,
71
- showCwd: true,
72
- showStats: true,
73
- showContext: true,
74
- showModel: true,
75
- showBranch: true,
76
- showPr: true,
77
- showExtensionStatuses: true,
78
- hideZeroMcp: true,
79
- line1Anchor: "right",
80
- line2Anchor: "right",
81
- branchMaxLength: 22,
505
+ lineAnchors: { "1": "right", "2": "right" },
82
506
  minGap: 2,
83
507
  maxGap: 20,
84
508
  items: {},
509
+ adapters: {},
85
510
  };
86
511
 
87
512
  const ANCHOR_MODES: FooterAnchorMode[] = ["gap", "left", "center", "right", "spread"];
88
513
  const CONFIG_FILE_NAME = "footer-framework.json";
514
+ const CODE_CONFIG_FILE_NAMES = ["footer-framework.config.ts", "footer-framework.config.js", "footer-framework.config.mjs", "footer-framework.config.cjs"];
89
515
  const DEFAULT_ITEM_PLACEMENTS: Record<string, FooterItemPlacement> = {
90
516
  cwd: { visible: true, line: 1, zone: "left", order: 10 },
91
517
  model: { visible: true, line: 1, zone: "right", order: 10 },
@@ -96,6 +522,33 @@ const DEFAULT_ITEM_PLACEMENTS: Record<string, FooterItemPlacement> = {
96
522
  ext: { visible: true, line: 2, zone: "right", order: 20 },
97
523
  };
98
524
 
525
+ const DEFAULT_BUILT_IN_ADAPTERS: Record<string, FooterAdapterConfig> = {
526
+ cwd: { source: "pi", key: "cwd", itemId: "cwd", template: '{{ pi.cwd | style: "dim" }}', placement: DEFAULT_ITEM_PLACEMENTS.cwd },
527
+ model: {
528
+ source: "pi",
529
+ key: "model",
530
+ itemId: "model",
531
+ template: '{{ pi.model.id | style: "dim" }}{{ ":" | style: "dim" }}{{ pi.model.thinking | style: "dim" }}',
532
+ placement: DEFAULT_ITEM_PLACEMENTS.model,
533
+ },
534
+ branch: { source: "pi", key: "branch", itemId: "branch", template: '{{ pi.branch.label | truncate: 22 | style: "muted" }}', placement: DEFAULT_ITEM_PLACEMENTS.branch },
535
+ stats: {
536
+ source: "pi",
537
+ key: "stats",
538
+ itemId: "stats",
539
+ template: '{{ "↑" | style: "dim" }}{{ pi.stats.inputText | style: "dim" }} {{ "↓" | style: "dim" }}{{ pi.stats.outputText | style: "dim" }} {{ "$" | style: "dim" }}{{ pi.stats.costText | style: "dim" }}',
540
+ placement: DEFAULT_ITEM_PLACEMENTS.stats,
541
+ },
542
+ context: {
543
+ source: "pi",
544
+ key: "context",
545
+ itemId: "context",
546
+ template: '{{ "ctx" | style: pi.context.tone }} {{ pi.context.percentText | style: pi.context.tone }} {{ pi.context.tokenText | style: pi.context.tone }}',
547
+ placement: DEFAULT_ITEM_PLACEMENTS.context,
548
+ },
549
+ pr: { source: "pi", key: "pr", itemId: "pr", template: '{{ "PR " | style: "muted" }}{{ pi.pr.checkGlyph | style: pi.pr.checkTone }}{{ pi.pr.commentsText | style: "muted" }}', placement: DEFAULT_ITEM_PLACEMENTS.pr },
550
+ };
551
+
99
552
  function formatTokens(count: number): string {
100
553
  if (count < 1_000) return `${count}`;
101
554
  if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
@@ -113,6 +566,52 @@ function clamp(value: number, min: number, max: number): number {
113
566
  return Math.max(min, Math.min(max, value));
114
567
  }
115
568
 
569
+ function cloneDefaultSettings(): FooterFrameworkSettings {
570
+ return {
571
+ ...DEFAULT_SETTINGS,
572
+ lineAnchors: { ...DEFAULT_SETTINGS.lineAnchors },
573
+ items: { ...DEFAULT_SETTINGS.items },
574
+ adapters: { ...DEFAULT_SETTINGS.adapters },
575
+ };
576
+ }
577
+
578
+ function normalizeFooterLine(value: unknown): FooterLine | undefined {
579
+ const parsed = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value.trim()) : undefined;
580
+ if (!Number.isFinite(parsed)) return undefined;
581
+ const line = Math.round(parsed as number);
582
+ return line >= MIN_FOOTER_LINE ? line : undefined;
583
+ }
584
+
585
+ function parseFooterLineSelector(value: string): FooterLine | "all" | undefined {
586
+ if (value === "all") return "all";
587
+ const normalized = value.toLowerCase().startsWith("line") ? value.slice(4) : value;
588
+ return normalizeFooterLine(normalized);
589
+ }
590
+
591
+ function setLineAnchor(settings: FooterFrameworkSettings, line: FooterLine, mode: FooterAnchorMode): void {
592
+ settings.lineAnchors[String(line)] = mode;
593
+ }
594
+
595
+ function getLineAnchor(settings: FooterFrameworkSettings, line: FooterLine): FooterAnchorMode {
596
+ return settings.lineAnchors[String(line)] ?? settings.lineAnchors["2"] ?? "right";
597
+ }
598
+
599
+ function setAllLineAnchors(settings: FooterFrameworkSettings, mode: FooterAnchorMode): void {
600
+ setLineAnchor(settings, 1, mode);
601
+ setLineAnchor(settings, 2, mode);
602
+ for (const lineKey of Object.keys(settings.lineAnchors)) {
603
+ const line = normalizeFooterLine(lineKey);
604
+ if (line !== undefined) setLineAnchor(settings, line, mode);
605
+ }
606
+ }
607
+
608
+ function sortedLineAnchors(settings: FooterFrameworkSettings): string {
609
+ return Object.entries(settings.lineAnchors)
610
+ .sort(([a], [b]) => Number(a) - Number(b))
611
+ .map(([line, mode]) => `line${line}=${mode}`)
612
+ .join(", ");
613
+ }
614
+
116
615
  function agentDir(): string {
117
616
  return process.env.PI_CODING_AGENT_DIR ?? process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
118
617
  }
@@ -125,43 +624,116 @@ function projectConfigPath(ctx: ExtensionContext): string {
125
624
  return path.join(ctx.cwd, ".pi", CONFIG_FILE_NAME);
126
625
  }
127
626
 
627
+ function codeConfigPathCandidates(dir: string): string[] {
628
+ return CODE_CONFIG_FILE_NAMES.map((fileName) => path.join(dir, fileName));
629
+ }
630
+
631
+ function firstExistingPath(paths: string[]): string | undefined {
632
+ return paths.find((candidate) => fs.existsSync(candidate));
633
+ }
634
+
635
+ function userCodeConfigPath(): string | undefined {
636
+ return firstExistingPath(codeConfigPathCandidates(agentDir()));
637
+ }
638
+
639
+ function projectCodeConfigPath(ctx: ExtensionContext): string | undefined {
640
+ return firstExistingPath(codeConfigPathCandidates(path.join(ctx.cwd, ".pi")));
641
+ }
642
+
643
+ function configPaths(ctx?: ExtensionContext): Record<string, unknown> {
644
+ return {
645
+ user: userConfigPath(),
646
+ userCodeCandidates: codeConfigPathCandidates(agentDir()),
647
+ userCode: userCodeConfigPath(),
648
+ project: ctx ? projectConfigPath(ctx) : undefined,
649
+ projectCodeCandidates: ctx ? codeConfigPathCandidates(path.join(ctx.cwd, ".pi")) : undefined,
650
+ projectCode: ctx ? projectCodeConfigPath(ctx) : undefined,
651
+ };
652
+ }
653
+
654
+ function normalizeColumn(value: unknown): FooterColumn | undefined {
655
+ if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, Math.round(value));
656
+ if (typeof value !== "string") return undefined;
657
+ const trimmed = value.trim().toLowerCase();
658
+ if (trimmed === "center" || trimmed === "middle") return trimmed;
659
+ if (/^-?\d+(?:\.\d+)?%$/.test(trimmed)) return trimmed as `${number}%`;
660
+ if (/^\d+$/.test(trimmed)) return Math.max(0, Math.round(Number(trimmed)));
661
+ return undefined;
662
+ }
663
+
128
664
  function normalizePlacement(input: Partial<FooterItemPlacement>): Partial<FooterItemPlacement> {
129
665
  const placement: Partial<FooterItemPlacement> = {};
666
+ const rawInput = input as Partial<FooterItemPlacement> & { line?: unknown; column?: unknown };
667
+ const line = normalizeFooterLine(rawInput.line);
668
+ const column = normalizeColumn(rawInput.column);
130
669
  if (typeof input.visible === "boolean") placement.visible = input.visible;
131
- if (input.line === 1 || input.line === 2) placement.line = input.line;
670
+ if (line !== undefined) placement.line = line;
132
671
  if (input.zone === "left" || input.zone === "right") placement.zone = input.zone;
133
672
  if (Number.isFinite(input.order)) placement.order = Math.round(input.order as number);
134
- if (Number.isFinite(input.column)) placement.column = clamp(Math.round(input.column as number), 0, 500);
673
+ if (column !== undefined) placement.column = column;
135
674
  if (typeof input.before === "string" && input.before.trim()) placement.before = input.before.trim();
136
675
  if (typeof input.after === "string" && input.after.trim()) placement.after = input.after.trim();
137
676
  return placement;
138
677
  }
139
678
 
140
- function normalizeSettings(input: Partial<FooterFrameworkSettings>): Partial<FooterFrameworkSettings> {
679
+ function normalizeAdapter(input: unknown): FooterAdapterConfig | undefined {
680
+ if (!input || typeof input !== "object") return undefined;
681
+ const raw = input as Partial<FooterAdapterConfig>;
682
+ if (raw.source !== "pi" && raw.source !== "extensionStatus" && raw.source !== "sessionEntry") return undefined;
683
+ if (typeof raw.key !== "string" || !raw.key.trim()) return undefined;
684
+ const adapter: FooterAdapterConfig = {
685
+ source: raw.source,
686
+ key: raw.key.trim(),
687
+ };
688
+ if (typeof raw.itemId === "string" && raw.itemId.trim()) adapter.itemId = raw.itemId.trim();
689
+ if (typeof raw.label === "string" && raw.label.trim()) adapter.label = sanitizeStatusText(raw.label);
690
+ if (typeof raw.path === "string" && raw.path.trim()) adapter.path = raw.path.trim();
691
+ if (typeof raw.match === "string" && raw.match.trim()) adapter.match = raw.match;
692
+ if (typeof raw.group === "string" || typeof raw.group === "number") adapter.group = raw.group;
693
+ if (typeof raw.urlPath === "string" && raw.urlPath.trim()) adapter.urlPath = raw.urlPath.trim();
694
+ const tone = normalizeTone(raw.tone);
695
+ if (tone) adapter.tone = tone;
696
+ const format = normalizeFormat(raw.format);
697
+ if (format) adapter.format = format;
698
+ if (typeof raw.template === "string" && raw.template.trim()) adapter.template = raw.template;
699
+ if (typeof raw.emptyTemplate === "string" && raw.emptyTemplate.trim()) adapter.emptyTemplate = raw.emptyTemplate;
700
+ if (typeof raw.style === "string" && raw.style.trim()) adapter.style = raw.style.trim();
701
+ if (typeof raw.icon === "string" && raw.icon.trim()) adapter.icon = sanitizeStatusText(raw.icon);
702
+ if (raw.placement && typeof raw.placement === "object") adapter.placement = normalizePlacement(raw.placement);
703
+ if (typeof raw.hideWhenEmpty === "boolean") adapter.hideWhenEmpty = raw.hideWhenEmpty;
704
+ return adapter;
705
+ }
706
+
707
+ function normalizeSettings(input: Partial<FooterFrameworkConfig>): Partial<FooterFrameworkSettings> {
141
708
  const normalized: Partial<FooterFrameworkSettings> = {};
142
- for (const key of [
143
- "enabled",
144
- "showCwd",
145
- "showStats",
146
- "showContext",
147
- "showModel",
148
- "showBranch",
149
- "showPr",
150
- "showExtensionStatuses",
151
- "hideZeroMcp",
152
- ] as const) {
709
+ for (const key of ["enabled"] as const) {
153
710
  if (typeof input[key] === "boolean") normalized[key] = input[key];
154
711
  }
155
- if (input.line1Anchor && ANCHOR_MODES.includes(input.line1Anchor)) normalized.line1Anchor = input.line1Anchor;
156
- if (input.line2Anchor && ANCHOR_MODES.includes(input.line2Anchor)) normalized.line2Anchor = input.line2Anchor;
157
- if (Number.isFinite(input.branchMaxLength)) normalized.branchMaxLength = clamp(Math.round(input.branchMaxLength as number), 10, 64);
158
- if (Number.isFinite(input.minGap)) normalized.minGap = clamp(Math.round(input.minGap as number), 1, 12);
159
- if (Number.isFinite(input.maxGap)) normalized.maxGap = clamp(Math.round(input.maxGap as number), normalized.minGap ?? 1, 40);
712
+ const lineAnchors: Record<string, FooterAnchorMode> = {};
713
+ if (input.lineAnchors && typeof input.lineAnchors === "object") {
714
+ for (const [lineKey, mode] of Object.entries(input.lineAnchors)) {
715
+ const line = normalizeFooterLine(lineKey);
716
+ if (line !== undefined && ANCHOR_MODES.includes(mode)) lineAnchors[String(line)] = mode;
717
+ }
718
+ }
719
+ if (Object.keys(lineAnchors).length > 0) normalized.lineAnchors = lineAnchors;
720
+ if (Number.isFinite(input.minGap)) normalized.minGap = Math.max(0, Math.round(input.minGap as number));
721
+ if (Number.isFinite(input.maxGap)) normalized.maxGap = Math.max(normalized.minGap ?? 0, Math.round(input.maxGap as number));
160
722
  if (input.items && typeof input.items === "object") {
161
723
  normalized.items = {};
162
724
  for (const [id, placement] of Object.entries(input.items)) {
163
725
  if (!id.trim() || !placement || typeof placement !== "object") continue;
164
- normalized.items[id] = normalizePlacement(placement as Partial<FooterItemPlacement>);
726
+ const normalizedPlacement = normalizePlacement(placement as Partial<FooterItemPlacement>);
727
+ if (Object.keys(normalizedPlacement).length > 0) normalized.items[id] = normalizedPlacement;
728
+ }
729
+ if (Object.keys(normalized.items).length === 0) delete normalized.items;
730
+ }
731
+ if (input.adapters && typeof input.adapters === "object") {
732
+ normalized.adapters = {};
733
+ for (const [id, adapterInput] of Object.entries(input.adapters)) {
734
+ if (!id.trim()) continue;
735
+ const adapter = normalizeAdapter(adapterInput);
736
+ if (adapter) normalized.adapters[id] = adapter;
165
737
  }
166
738
  }
167
739
  return normalized;
@@ -170,28 +742,37 @@ function normalizeSettings(input: Partial<FooterFrameworkSettings>): Partial<Foo
170
742
  function readConfigFile(filePath: string): Partial<FooterFrameworkSettings> | undefined {
171
743
  try {
172
744
  if (!fs.existsSync(filePath)) return undefined;
173
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Partial<FooterFrameworkSettings>;
745
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Partial<FooterFrameworkConfig>;
174
746
  return normalizeSettings(parsed);
175
747
  } catch {
176
748
  return undefined;
177
749
  }
178
750
  }
179
751
 
752
+ async function readCodeConfigFile(filePath: string): Promise<FooterFrameworkConfig | undefined> {
753
+ if (!fs.existsSync(filePath)) return undefined;
754
+ const stat = fs.statSync(filePath);
755
+ const imported = (await import(`${pathToFileURL(filePath).href}?mtime=${stat.mtimeMs}`)) as { default?: unknown };
756
+ if (!imported.default || typeof imported.default !== "object") throw new Error("default export must be a footer config object");
757
+ return imported.default as FooterFrameworkConfig;
758
+ }
759
+
180
760
  function writeConfigFile(filePath: string, settings: FooterFrameworkSettings): void {
181
761
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
182
762
  fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
183
763
  }
184
764
 
185
- function compactBranchName(branch: string, maxLength: number): string {
186
- if (branch.length <= maxLength) return branch;
187
- const keep = Math.max(8, maxLength - 1);
188
- return `${branch.slice(0, keep)}…`;
189
- }
190
-
191
765
  function osc8(label: string, url: string): string {
192
766
  return `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007`;
193
767
  }
194
768
 
769
+ function normalizeLinkUrl(value: unknown): string | undefined {
770
+ if (typeof value !== "string") return undefined;
771
+ const trimmed = value.trim();
772
+ if (!trimmed || /[\r\n]/.test(trimmed)) return undefined;
773
+ return trimmed;
774
+ }
775
+
195
776
  function sanitizeStatusText(text: string): string {
196
777
  return text
197
778
  .replace(/[\r\n\t]/g, " ")
@@ -199,6 +780,675 @@ function sanitizeStatusText(text: string): string {
199
780
  .trim();
200
781
  }
201
782
 
783
+ function normalizeTone(tone: unknown): ExternalFooterItemTone | undefined {
784
+ return tone === "muted" || tone === "info" || tone === "success" || tone === "warning" || tone === "error" || tone === "accent" ? tone : undefined;
785
+ }
786
+
787
+ function normalizeFormat(format: unknown): ExternalFooterItemFormat | undefined {
788
+ return format === "auto" || format === "value" || format === "label-value" || format === "status" ? format : undefined;
789
+ }
790
+
791
+ function valueToText(value: unknown): string | undefined {
792
+ if (value === undefined || value === null) return undefined;
793
+ if (typeof value === "string") return sanitizeStatusText(value);
794
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return String(value);
795
+ if (Array.isArray(value)) return sanitizeStatusText(value.map((entry) => valueToText(entry)).filter(Boolean).join(" "));
796
+ try {
797
+ return sanitizeStatusText(JSON.stringify(value));
798
+ } catch {
799
+ return sanitizeStatusText(String(value));
800
+ }
801
+ }
802
+
803
+ function valueToTemplateText(value: unknown): string | undefined {
804
+ if (value === undefined || value === null) return undefined;
805
+ if (typeof value === "string") return value.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ");
806
+ return valueToText(value);
807
+ }
808
+
809
+ function normalizeDisplayHint(event: ExternalFooterItemEvent): FooterItemDisplayHint {
810
+ return {
811
+ label: typeof event.hint?.label === "string" ? sanitizeStatusText(event.hint.label) : undefined,
812
+ icon: typeof event.hint?.icon === "string" ? sanitizeStatusText(event.hint.icon) : undefined,
813
+ format: normalizeFormat(event.hint?.format),
814
+ tone: normalizeTone(event.hint?.tone),
815
+ placement: normalizePlacement({ ...(event.placement ?? {}), ...(event.hint?.placement ?? {}) }),
816
+ };
817
+ }
818
+
819
+ function normalizeExternalItemEvent(event: ExternalFooterItemEvent): ExternalFooterItem | undefined {
820
+ const id = event.id?.trim();
821
+ if (!id) return undefined;
822
+ return {
823
+ id,
824
+ label: typeof event.label === "string" ? sanitizeStatusText(event.label) : undefined,
825
+ value: event.value,
826
+ status: event.status,
827
+ data: event.data,
828
+ url: typeof event.url === "string" ? event.url : undefined,
829
+ tone: normalizeTone(event.tone),
830
+ text: typeof event.text === "string" ? sanitizeStatusText(event.text) : undefined,
831
+ hint: normalizeDisplayHint(event),
832
+ };
833
+ }
834
+
835
+ function applyExternalTone(theme: ExtensionContext["ui"]["theme"], tone: ExternalFooterItemTone | undefined, text: string): string {
836
+ if (tone === "success") return theme.fg("success", text);
837
+ if (tone === "warning") return theme.fg("warning", text);
838
+ if (tone === "error") return theme.fg("error", text);
839
+ if (tone === "accent") return theme.fg("accent", text);
840
+ if (tone === "muted") return theme.fg("muted", text);
841
+ return theme.fg("dim", text);
842
+ }
843
+
844
+ const THEME_FG_COLORS = new Set([
845
+ "accent",
846
+ "border",
847
+ "borderAccent",
848
+ "borderMuted",
849
+ "success",
850
+ "error",
851
+ "warning",
852
+ "muted",
853
+ "dim",
854
+ "text",
855
+ "thinkingText",
856
+ "userMessageText",
857
+ "customMessageText",
858
+ "customMessageLabel",
859
+ "toolTitle",
860
+ "toolOutput",
861
+ "mdHeading",
862
+ "mdLink",
863
+ "mdLinkUrl",
864
+ "mdCode",
865
+ "mdCodeBlock",
866
+ "mdCodeBlockBorder",
867
+ "mdQuote",
868
+ "mdQuoteBorder",
869
+ "mdHr",
870
+ "mdListBullet",
871
+ "toolDiffAdded",
872
+ "toolDiffRemoved",
873
+ "toolDiffContext",
874
+ "syntaxComment",
875
+ "syntaxKeyword",
876
+ "syntaxFunction",
877
+ "syntaxVariable",
878
+ "syntaxString",
879
+ "syntaxNumber",
880
+ "syntaxType",
881
+ "syntaxOperator",
882
+ "syntaxPunctuation",
883
+ "thinkingOff",
884
+ "thinkingMinimal",
885
+ "thinkingLow",
886
+ "thinkingMedium",
887
+ "thinkingHigh",
888
+ "thinkingXhigh",
889
+ "bashMode",
890
+ ]);
891
+
892
+ const THEME_BG_COLORS = new Set(["selectedBg", "userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]);
893
+ const THEME_TEXT_ATTRIBUTES = new Set(["bold", "italic", "underline", "inverse", "strikethrough"]);
894
+
895
+ function applyStyleSpec(
896
+ theme: ExtensionContext["ui"]["theme"],
897
+ text: string,
898
+ styleSpec: unknown,
899
+ diagnostics: FooterTemplateDiagnostic[],
900
+ adapterId: string,
901
+ token?: string,
902
+ ): string {
903
+ const spec = valueToText(styleSpec);
904
+ if (!spec) return text;
905
+ let out = text;
906
+ for (const rawPart of spec.split(",")) {
907
+ const part = rawPart.trim();
908
+ if (!part) continue;
909
+ const [prefix, value] = part.includes(":") ? (part.split(/:(.*)/s).filter(Boolean) as [string, string]) : [undefined, part];
910
+ if ((prefix === "fg" || prefix === "color") && THEME_FG_COLORS.has(value)) out = theme.fg(value as never, out);
911
+ else if ((prefix === "bg" || prefix === "background") && THEME_BG_COLORS.has(value)) out = theme.bg(value as never, out);
912
+ else if (!prefix && THEME_FG_COLORS.has(value)) out = theme.fg(value as never, out);
913
+ else if (!prefix && value === "bold") out = theme.bold(out);
914
+ else if (!prefix && value === "italic") out = theme.italic(out);
915
+ else if (!prefix && value === "underline") out = theme.underline(out);
916
+ else if (!prefix && value === "inverse") out = theme.inverse(out);
917
+ else if (!prefix && value === "strikethrough") out = theme.strikethrough(out);
918
+ else if (prefix === "attr" && THEME_TEXT_ATTRIBUTES.has(value)) out = applyStyleSpec(theme, out, value, diagnostics, adapterId, token);
919
+ else {
920
+ diagnostics.push({ adapterId, token, severity: "warning", message: `Unknown style token: ${part}` });
921
+ out = theme.fg("warning", out);
922
+ }
923
+ }
924
+ return out;
925
+ }
926
+
927
+ function renderExternalItem(theme: ExtensionContext["ui"]["theme"], item: ExternalFooterItem): string | undefined {
928
+ const hint = item.hint;
929
+ const label = hint.label ?? item.label;
930
+ const value = valueToText(item.value) ?? valueToText(item.status) ?? valueToText(item.data) ?? item.text;
931
+ const format = hint.format ?? (item.text && !label && item.value === undefined && item.status === undefined && item.data === undefined ? "value" : "auto");
932
+ const renderedValue = value ? applyExternalTone(theme, item.tone ?? hint.tone, value) : undefined;
933
+ const prefix = hint.icon ? `${hint.icon} ` : "";
934
+ let text: string | undefined;
935
+ if (format === "value") text = renderedValue ? `${prefix}${renderedValue}` : undefined;
936
+ else if (format === "status" && label && renderedValue) text = `${prefix}${theme.fg("muted", label)} ${renderedValue}`;
937
+ else if (label && renderedValue) text = `${prefix}${theme.fg("muted", label)}: ${renderedValue}`;
938
+ else if (renderedValue) text = `${prefix}${renderedValue}`;
939
+ else if (label) text = `${prefix}${theme.fg("muted", label)}`;
940
+ if (!text) return undefined;
941
+ return item.url ? osc8(text, item.url) : text;
942
+ }
943
+
944
+ function isFooterSpan(value: unknown): value is FooterSpan {
945
+ return Boolean(value && typeof value === "object" && "text" in value);
946
+ }
947
+
948
+ function renderValueText(value: unknown): string {
949
+ return valueToTemplateText(value) ?? "";
950
+ }
951
+
952
+ function renderSpan(
953
+ theme: ExtensionContext["ui"]["theme"],
954
+ span: FooterSpan,
955
+ diagnostics: FooterTemplateDiagnostic[],
956
+ adapterId: string,
957
+ ): { text: string; tokens: FooterRenderedToken[] } {
958
+ const plain = renderValueText(span.text);
959
+ let styled = span.style ? applyStyleSpec(theme, plain, span.style, diagnostics, adapterId) : plain;
960
+ if (span.url) styled = osc8(styled, span.url);
961
+ return { text: styled, tokens: [{ text: plain, style: span.style, url: span.url, width: visibleWidth(plain) }] };
962
+ }
963
+
964
+ function renderRenderable(
965
+ theme: ExtensionContext["ui"]["theme"],
966
+ value: FooterRenderable,
967
+ diagnostics: FooterTemplateDiagnostic[],
968
+ adapterId: string,
969
+ ): { text: string; tokens: FooterRenderedToken[] } {
970
+ if (value === undefined || value === null || value === false) return { text: "", tokens: [] };
971
+ if (Array.isArray(value)) {
972
+ const parts = value.map((entry) => renderRenderable(theme, entry, diagnostics, adapterId));
973
+ return { text: parts.map((part) => part.text).join(""), tokens: parts.flatMap((part) => part.tokens) };
974
+ }
975
+ if (isFooterSpan(value)) return renderSpan(theme, value, diagnostics, adapterId);
976
+ const text = renderValueText(value);
977
+ return { text, tokens: text ? [{ text, width: visibleWidth(text) }] : [] };
978
+ }
979
+
980
+ function footerRenderFunctions(): FooterRenderContext["fn"] {
981
+ return {
982
+ text(value: unknown) {
983
+ return renderValueText(value);
984
+ },
985
+ width(value: string) {
986
+ return visibleWidth(value);
987
+ },
988
+ truncate(value: unknown, maxWidth: number, ellipsis = "…") {
989
+ return truncateToWidth(renderValueText(value), Math.max(1, Math.round(maxWidth)), ellipsis);
990
+ },
991
+ compactPath(value: unknown, maxWidth: number, tailSegments = 2) {
992
+ return compactPathText(renderValueText(value), Math.max(4, Math.round(maxWidth)), Math.max(1, Math.round(tailSegments)));
993
+ },
994
+ };
995
+ }
996
+
997
+ function parsePath(pathExpression: string): string[] {
998
+ return pathExpression
999
+ .replace(/^\$\.?/, "")
1000
+ .replace(/\[(\d+)\]/g, ".$1")
1001
+ .split(".")
1002
+ .map((part) => part.trim())
1003
+ .filter(Boolean);
1004
+ }
1005
+
1006
+ function selectPath(value: unknown, pathExpression?: string): unknown {
1007
+ if (!pathExpression?.trim()) return value;
1008
+ let current = value;
1009
+ for (const part of parsePath(pathExpression)) {
1010
+ if (current === undefined || current === null) return undefined;
1011
+ if (Array.isArray(current)) {
1012
+ const index = Number(part);
1013
+ current = Number.isInteger(index) ? current[index] : undefined;
1014
+ } else if (typeof current === "object") {
1015
+ current = (current as Record<string, unknown>)[part];
1016
+ } else {
1017
+ return undefined;
1018
+ }
1019
+ }
1020
+ return current;
1021
+ }
1022
+
1023
+ function splitTemplatePipes(expression: string): string[] {
1024
+ const parts: string[] = [];
1025
+ let current = "";
1026
+ let quote: string | undefined;
1027
+ let escaped = false;
1028
+ for (const char of expression) {
1029
+ if (escaped) {
1030
+ current += char;
1031
+ escaped = false;
1032
+ continue;
1033
+ }
1034
+ if (char === "\\") {
1035
+ current += char;
1036
+ escaped = true;
1037
+ continue;
1038
+ }
1039
+ if (quote) {
1040
+ current += char;
1041
+ if (char === quote) quote = undefined;
1042
+ continue;
1043
+ }
1044
+ if (char === '"' || char === "'") {
1045
+ quote = char;
1046
+ current += char;
1047
+ continue;
1048
+ }
1049
+ if (char === "|") {
1050
+ parts.push(current.trim());
1051
+ current = "";
1052
+ continue;
1053
+ }
1054
+ current += char;
1055
+ }
1056
+ parts.push(current.trim());
1057
+ return parts;
1058
+ }
1059
+
1060
+ function parseFilter(filterExpression: string): { name: string; arg?: string } {
1061
+ let quote: string | undefined;
1062
+ let escaped = false;
1063
+ for (let index = 0; index < filterExpression.length; index++) {
1064
+ const char = filterExpression[index];
1065
+ if (escaped) {
1066
+ escaped = false;
1067
+ continue;
1068
+ }
1069
+ if (char === "\\") {
1070
+ escaped = true;
1071
+ continue;
1072
+ }
1073
+ if (quote) {
1074
+ if (char === quote) quote = undefined;
1075
+ continue;
1076
+ }
1077
+ if (char === '"' || char === "'") {
1078
+ quote = char;
1079
+ continue;
1080
+ }
1081
+ if (char === ":") {
1082
+ return { name: filterExpression.slice(0, index).trim(), arg: filterExpression.slice(index + 1).trim() };
1083
+ }
1084
+ }
1085
+ return { name: filterExpression.trim() };
1086
+ }
1087
+
1088
+ function splitFilterArgs(expression: string | undefined): string[] {
1089
+ if (!expression?.trim()) return [];
1090
+ const args: string[] = [];
1091
+ let current = "";
1092
+ let quote: string | undefined;
1093
+ let escaped = false;
1094
+ for (const char of expression) {
1095
+ if (escaped) {
1096
+ current += char;
1097
+ escaped = false;
1098
+ continue;
1099
+ }
1100
+ if (char === "\\") {
1101
+ current += char;
1102
+ escaped = true;
1103
+ continue;
1104
+ }
1105
+ if (quote) {
1106
+ current += char;
1107
+ if (char === quote) quote = undefined;
1108
+ continue;
1109
+ }
1110
+ if (char === '"' || char === "'") {
1111
+ quote = char;
1112
+ current += char;
1113
+ continue;
1114
+ }
1115
+ if (char === ",") {
1116
+ args.push(current.trim());
1117
+ current = "";
1118
+ continue;
1119
+ }
1120
+ current += char;
1121
+ }
1122
+ args.push(current.trim());
1123
+ return args.filter(Boolean);
1124
+ }
1125
+
1126
+ function parseQuotedString(expression: string): string | undefined {
1127
+ const trimmed = expression.trim();
1128
+ if (trimmed.length < 2) return undefined;
1129
+ const quote = trimmed[0];
1130
+ if ((quote !== '"' && quote !== "'") || trimmed[trimmed.length - 1] !== quote) return undefined;
1131
+ try {
1132
+ return quote === '"' ? JSON.parse(trimmed) : trimmed.slice(1, -1).replace(/\\'/g, "'").replace(/\\\\/g, "\\");
1133
+ } catch {
1134
+ return undefined;
1135
+ }
1136
+ }
1137
+
1138
+ function evaluateTemplateTerm(
1139
+ term: string | undefined,
1140
+ context: Record<string, unknown>,
1141
+ diagnostics: FooterTemplateDiagnostic[],
1142
+ adapterId: string,
1143
+ token: string,
1144
+ reportMissing = true,
1145
+ ): unknown {
1146
+ if (!term) return undefined;
1147
+ const trimmed = term.trim();
1148
+ const literal = parseQuotedString(trimmed);
1149
+ if (literal !== undefined) return literal;
1150
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
1151
+ if (trimmed === "true") return true;
1152
+ if (trimmed === "false") return false;
1153
+ const value = selectPath(context, trimmed);
1154
+ if (value === undefined) {
1155
+ if (reportMissing) diagnostics.push({ adapterId, token, severity: "error", message: `Missing template variable: ${trimmed}` });
1156
+ return reportMissing ? `[missing:${trimmed}]` : undefined;
1157
+ }
1158
+ return value;
1159
+ }
1160
+
1161
+ function numberFilterArg(value: unknown, fallback: number, min: number): number {
1162
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : Number.NaN;
1163
+ return Number.isFinite(parsed) ? Math.max(min, Math.round(parsed)) : fallback;
1164
+ }
1165
+
1166
+ function truncateStartToWidth(text: string, maxWidth: number, ellipsis = "…"): string {
1167
+ if (visibleWidth(text) <= maxWidth) return text;
1168
+ if (maxWidth <= visibleWidth(ellipsis)) return ellipsis;
1169
+ let suffix = "";
1170
+ for (const char of Array.from(text).reverse()) {
1171
+ const next = `${char}${suffix}`;
1172
+ if (visibleWidth(next) + visibleWidth(ellipsis) > maxWidth) break;
1173
+ suffix = next;
1174
+ }
1175
+ return `${ellipsis}${suffix}`;
1176
+ }
1177
+
1178
+ function compactPathText(input: string, maxWidth: number, tailSegments: number): string {
1179
+ if (!input) return input;
1180
+ const home = os.homedir();
1181
+ let display = input;
1182
+ if (home && (display === home || display.startsWith(`${home}/`) || display.startsWith(`${home}\\`))) display = `~${display.slice(home.length)}`;
1183
+ if (visibleWidth(display) <= maxWidth) return display;
1184
+
1185
+ const normalized = display.replace(/\\/g, "/").replace(/\/+/g, "/");
1186
+ const driveMatch = normalized.match(/^[A-Za-z]:\//);
1187
+ const prefix = normalized.startsWith("~/") ? "~/" : driveMatch ? driveMatch[0] : normalized.startsWith("/") ? "/" : "";
1188
+ const body = prefix ? normalized.slice(prefix.length) : normalized;
1189
+ const segments = body.split("/").filter(Boolean);
1190
+ const tailCount = Math.max(1, tailSegments);
1191
+ if (segments.length <= tailCount) return truncateStartToWidth(normalized, maxWidth);
1192
+ const compact = `${prefix}…/${segments.slice(-tailCount).join("/")}`;
1193
+ return truncateStartToWidth(compact, maxWidth);
1194
+ }
1195
+
1196
+ function evaluateFilterArgs(
1197
+ arg: string | undefined,
1198
+ context: Record<string, unknown>,
1199
+ diagnostics: FooterTemplateDiagnostic[],
1200
+ adapterId: string,
1201
+ token: string,
1202
+ ): unknown[] {
1203
+ return splitFilterArgs(arg).map((part) => evaluateTemplateTerm(part, context, diagnostics, adapterId, token));
1204
+ }
1205
+
1206
+ function applyTemplateFilter(
1207
+ theme: ExtensionContext["ui"]["theme"],
1208
+ value: string,
1209
+ filterExpression: string,
1210
+ context: Record<string, unknown>,
1211
+ diagnostics: FooterTemplateDiagnostic[],
1212
+ adapterId: string,
1213
+ token: string,
1214
+ ): string {
1215
+ const { name, arg } = parseFilter(filterExpression);
1216
+ if (name === "style" || name === "color") {
1217
+ return applyStyleSpec(theme, value, evaluateTemplateTerm(arg, context, diagnostics, adapterId, token), diagnostics, adapterId, token);
1218
+ }
1219
+ if (name === "bg" || name === "background") {
1220
+ const bg = valueToText(evaluateTemplateTerm(arg, context, diagnostics, adapterId, token));
1221
+ return bg && THEME_BG_COLORS.has(bg) ? theme.bg(bg as never, value) : applyStyleSpec(theme, value, `bg:${bg}`, diagnostics, adapterId, token);
1222
+ }
1223
+ if (name === "bold" || name === "italic" || name === "underline" || name === "inverse" || name === "strikethrough") {
1224
+ return applyStyleSpec(theme, value, name, diagnostics, adapterId, token);
1225
+ }
1226
+ if (name === "link") {
1227
+ const url = valueToText(evaluateTemplateTerm(arg, context, diagnostics, adapterId, token));
1228
+ return url ? osc8(value, url) : value;
1229
+ }
1230
+ if (name === "truncate") {
1231
+ const [maxWidthArg, ellipsisArg] = evaluateFilterArgs(arg, context, diagnostics, adapterId, token);
1232
+ const maxWidth = numberFilterArg(maxWidthArg, 40, 1);
1233
+ const ellipsis = valueToText(ellipsisArg) || "…";
1234
+ return truncateToWidth(value, maxWidth, ellipsis);
1235
+ }
1236
+ if (name === "compactPath") {
1237
+ const [maxWidthArg, tailSegmentsArg] = evaluateFilterArgs(arg, context, diagnostics, adapterId, token);
1238
+ const maxWidth = numberFilterArg(maxWidthArg, 40, 4);
1239
+ const tailSegments = numberFilterArg(tailSegmentsArg, 2, 1);
1240
+ return compactPathText(value, maxWidth, tailSegments);
1241
+ }
1242
+ if (name === "default") {
1243
+ return value.length > 0 && !value.startsWith("[missing:") ? value : (valueToTemplateText(evaluateTemplateTerm(arg, context, diagnostics, adapterId, token)) ?? "");
1244
+ }
1245
+ diagnostics.push({ adapterId, token, severity: "warning", message: `Unknown template filter: ${name}` });
1246
+ return theme.fg("warning", value);
1247
+ }
1248
+
1249
+ function renderTemplate(
1250
+ template: string,
1251
+ context: Record<string, unknown>,
1252
+ theme: ExtensionContext["ui"]["theme"],
1253
+ adapterId: string,
1254
+ diagnostics: FooterTemplateDiagnostic[],
1255
+ ): string {
1256
+ let output = "";
1257
+ let cursor = 0;
1258
+ const tokenPattern = /{{([\s\S]*?)}}/g;
1259
+ for (let match = tokenPattern.exec(template); match; match = tokenPattern.exec(template)) {
1260
+ output += template.slice(cursor, match.index);
1261
+ const token = match[0];
1262
+ const expression = match[1]?.trim() ?? "";
1263
+ if (!expression) {
1264
+ diagnostics.push({ adapterId, token, severity: "error", message: "Empty template token" });
1265
+ output += theme.fg("error", "[empty-token]");
1266
+ } else {
1267
+ const [head, ...filters] = splitTemplatePipes(expression);
1268
+ const hasDefaultFilter = filters.some((filter) => parseFilter(filter).name === "default");
1269
+ let rendered = valueToTemplateText(evaluateTemplateTerm(head, context, diagnostics, adapterId, token, !hasDefaultFilter)) ?? "";
1270
+ for (const filter of filters) rendered = applyTemplateFilter(theme, rendered, filter, context, diagnostics, adapterId, token);
1271
+ output += rendered;
1272
+ }
1273
+ cursor = match.index + token.length;
1274
+ }
1275
+ output += template.slice(cursor);
1276
+ const literalRemainder = template.replace(/{{[\s\S]*?}}/g, "");
1277
+ if (literalRemainder.includes("{{") || literalRemainder.includes("}}")) {
1278
+ diagnostics.push({ adapterId, severity: "error", message: "Unbalanced template braces" });
1279
+ return `${output}${theme.fg("error", "[template braces]")}`;
1280
+ }
1281
+ return output;
1282
+ }
1283
+
1284
+ function extractMatchedValue(text: string, adapter: FooterAdapterConfig): string | undefined {
1285
+ if (!adapter.match) return text;
1286
+ let match: RegExpMatchArray | null;
1287
+ try {
1288
+ match = text.match(new RegExp(adapter.match));
1289
+ } catch {
1290
+ return undefined;
1291
+ }
1292
+ if (!match) return undefined;
1293
+ if (typeof adapter.group === "number") return match[adapter.group];
1294
+ if (typeof adapter.group === "string" && adapter.group) {
1295
+ if (/^\d+$/.test(adapter.group)) return match[Number(adapter.group)];
1296
+ return match.groups?.[adapter.group];
1297
+ }
1298
+ return match[1] ?? match[0];
1299
+ }
1300
+
1301
+ function sourceValueObject(sourceValue: unknown): FooterAdapterSourceValue {
1302
+ return sourceValue && typeof sourceValue === "object" && !Array.isArray(sourceValue) ? (sourceValue as FooterAdapterSourceValue) : { value: sourceValue };
1303
+ }
1304
+
1305
+ function adapterItemFromSource(adapterId: string, adapter: FooterAdapterConfig, sourceValue: unknown): ExternalFooterItem | undefined {
1306
+ const sourceObject = sourceValueObject(sourceValue);
1307
+ const defaultPath = adapter.source === "sessionEntry" ? "data" : "value";
1308
+ const selected = selectPath(sourceValue, adapter.path ?? defaultPath);
1309
+ const selectedText = valueToText(selected);
1310
+ const allowEmpty = adapter.hideWhenEmpty === false || Boolean(adapter.emptyTemplate);
1311
+ if (!selectedText && !allowEmpty) return undefined;
1312
+ const matched = selectedText ? extractMatchedValue(selectedText, adapter) : undefined;
1313
+ if (!matched && !allowEmpty) return undefined;
1314
+ const url = (adapter.urlPath ? normalizeLinkUrl(selectPath(sourceValue, adapter.urlPath)) : undefined) ?? normalizeLinkUrl(sourceObject.url);
1315
+ return {
1316
+ id: adapter.itemId ?? adapterId,
1317
+ label: adapter.label ?? sourceObject.label ?? adapterId,
1318
+ value: matched ?? selectedText ?? "",
1319
+ status: sourceObject.status,
1320
+ data: sourceObject.data,
1321
+ url,
1322
+ tone: adapter.tone ?? sourceObject.tone,
1323
+ hint: {
1324
+ ...sourceObject.hint,
1325
+ format: adapter.format ?? sourceObject.hint?.format ?? "label-value",
1326
+ icon: adapter.icon ?? sourceObject.hint?.icon,
1327
+ placement: normalizePlacement({ ...(sourceObject.hint?.placement ?? {}), ...(adapter.placement ?? {}) }),
1328
+ },
1329
+ };
1330
+ }
1331
+
1332
+ function templatePiContext(piSources: Record<string, FooterAdapterSourceValue>): Record<string, unknown> {
1333
+ return {
1334
+ cwd: piSources.cwd?.value,
1335
+ model: piSources.model?.data ?? {},
1336
+ stats: piSources.stats?.data ?? {},
1337
+ context: piSources.context?.data ?? {},
1338
+ branch: piSources.branch?.data ?? {},
1339
+ pr: piSources.pr?.data ?? {},
1340
+ extensionStatuses: piSources.extensionStatuses?.data ?? {},
1341
+ };
1342
+ }
1343
+
1344
+ function renderPiContext(piSources: Record<string, FooterAdapterSourceValue>): FooterRenderPiContext {
1345
+ return {
1346
+ cwd: renderValueText(piSources.cwd?.value),
1347
+ model: (piSources.model?.data ?? {}) as FooterRenderPiContext["model"],
1348
+ stats: (piSources.stats?.data ?? {}) as FooterRenderPiContext["stats"],
1349
+ context: piSources.context?.data as FooterRenderPiContext["context"],
1350
+ branch: piSources.branch?.data as FooterRenderPiContext["branch"],
1351
+ pr: piSources.pr?.data as FooterRenderPiContext["pr"],
1352
+ extensionStatuses: (piSources.extensionStatuses?.data ?? {}) as Record<string, unknown>,
1353
+ };
1354
+ }
1355
+
1356
+ function renderContextForAdapter(
1357
+ id: string,
1358
+ external: ExternalFooterItem | undefined,
1359
+ sourceValue: unknown,
1360
+ piSources: Record<string, FooterAdapterSourceValue>,
1361
+ ): FooterRenderContext {
1362
+ const sourceObject = sourceValueObject(sourceValue);
1363
+ return {
1364
+ id,
1365
+ label: external?.label ?? sourceObject.label,
1366
+ value: external?.value ?? sourceObject.value,
1367
+ status: external?.status ?? sourceObject.status,
1368
+ data: external?.data ?? sourceObject.data ?? sourceValue,
1369
+ url: external?.url ?? sourceObject.url,
1370
+ source: sourceObject,
1371
+ pi: renderPiContext(piSources),
1372
+ span: (text, style, options) => ({ text, style, url: options?.url }),
1373
+ fn: footerRenderFunctions(),
1374
+ };
1375
+ }
1376
+
1377
+ function templateContextForAdapter(external: ExternalFooterItem, sourceValue: unknown, piSources: Record<string, FooterAdapterSourceValue>): Record<string, unknown> {
1378
+ const sourceObject = sourceValueObject(sourceValue);
1379
+ return {
1380
+ label: external.label ?? sourceObject.label,
1381
+ value: external.value ?? sourceObject.value,
1382
+ status: external.status ?? sourceObject.status,
1383
+ data: external.data ?? sourceObject.data ?? sourceValue,
1384
+ url: external.url ?? sourceObject.url,
1385
+ source: sourceObject,
1386
+ pi: templatePiContext(piSources),
1387
+ };
1388
+ }
1389
+
1390
+ function renderFunctionOutput(
1391
+ theme: ExtensionContext["ui"]["theme"],
1392
+ id: string,
1393
+ render: FooterRenderFunction,
1394
+ context: FooterRenderContext,
1395
+ diagnostics: FooterTemplateDiagnostic[],
1396
+ ): { text: string; tokens: FooterRenderedToken[] } | undefined {
1397
+ try {
1398
+ const output = render(context);
1399
+ if (output && typeof output === "object" && "then" in output && typeof (output as { then?: unknown }).then === "function") {
1400
+ diagnostics.push({ adapterId: id, severity: "error", message: "Render functions must be synchronous" });
1401
+ return undefined;
1402
+ }
1403
+ const rendered = renderRenderable(theme, output, diagnostics, id);
1404
+ return rendered.text ? rendered : undefined;
1405
+ } catch (error) {
1406
+ diagnostics.push({ adapterId: id, severity: "error", message: `Render function failed: ${error instanceof Error ? error.message : String(error)}` });
1407
+ return undefined;
1408
+ }
1409
+ }
1410
+
1411
+ function renderAdapterText(
1412
+ theme: ExtensionContext["ui"]["theme"],
1413
+ adapterId: string,
1414
+ adapter: FooterAdapterConfig,
1415
+ external: ExternalFooterItem,
1416
+ sourceValue: unknown,
1417
+ piSources: Record<string, FooterAdapterSourceValue>,
1418
+ diagnostics: FooterTemplateDiagnostic[],
1419
+ renderOverride?: FooterRenderFunction,
1420
+ ): { text: string; tokens?: FooterRenderedToken[]; renderSource: "template" | "function" | "external" } | undefined {
1421
+ if (renderOverride) {
1422
+ const rendered = renderFunctionOutput(theme, adapterId, renderOverride, renderContextForAdapter(adapterId, external, sourceValue, piSources), diagnostics);
1423
+ if (!rendered) return undefined;
1424
+ let text = rendered.text;
1425
+ if (adapter.style) text = applyStyleSpec(theme, text, adapter.style, diagnostics, adapterId);
1426
+ return { text, tokens: rendered.tokens, renderSource: "function" };
1427
+ }
1428
+ const template = !external.value && adapter.emptyTemplate ? adapter.emptyTemplate : adapter.template;
1429
+ let text = template ? renderTemplate(template, templateContextForAdapter(external, sourceValue, piSources), theme, adapterId, diagnostics) : renderExternalItem(theme, external);
1430
+ if (text && adapter.style) text = applyStyleSpec(theme, text, adapter.style, diagnostics, adapterId);
1431
+ if (text && template && external.url) text = osc8(text, external.url);
1432
+ return text ? { text, renderSource: template ? "template" : "external" } : undefined;
1433
+ }
1434
+
1435
+ function redactSensitive(value: unknown, depth = 0): unknown {
1436
+ if (depth > 4) return "…";
1437
+ if (Array.isArray(value)) return value.slice(0, 8).map((entry) => redactSensitive(entry, depth + 1));
1438
+ if (!value || typeof value !== "object") return value;
1439
+ const out: Record<string, unknown> = {};
1440
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>).slice(0, 30)) {
1441
+ out[key] = /(token|secret|password|credential|authorization|api[_-]?key)/i.test(key) ? "[redacted]" : redactSensitive(entry, depth + 1);
1442
+ }
1443
+ return out;
1444
+ }
1445
+
1446
+ function previewValue(value: unknown, maxLength = 1200): unknown {
1447
+ const redacted = redactSensitive(value);
1448
+ const text = valueToText(redacted) ?? "";
1449
+ return text.length > maxLength ? `${text.slice(0, maxLength)}…` : redacted;
1450
+ }
1451
+
202
1452
  function parseSettingsInput(settings: FooterFrameworkSettings, args: string): string | undefined {
203
1453
  const tokens = args
204
1454
  .trim()
@@ -216,82 +1466,45 @@ function parseSettingsInput(settings: FooterFrameworkSettings, args: string): st
216
1466
  return "Footer framework disabled (default footer restored).";
217
1467
  }
218
1468
  if (command === "reset") {
219
- Object.assign(settings, { ...DEFAULT_SETTINGS, items: { ...DEFAULT_SETTINGS.items } });
1469
+ Object.assign(settings, cloneDefaultSettings());
220
1470
  return "Footer framework reset to defaults.";
221
1471
  }
222
1472
  if (command === "section") {
223
1473
  if (!key || !value) return "Usage: /footerfx section <cwd|stats|context|model|branch|pr|ext> <on|off>";
224
1474
  const enabled = value === "on" || value === "enable" || value === "true";
225
- if (!["on", "off", "enable", "disable", "true", "false"].includes(value)) {
226
- return "Section value must be on/off.";
227
- }
228
- switch (key) {
229
- case "cwd":
230
- settings.showCwd = enabled;
231
- return `Section cwd ${enabled ? "enabled" : "disabled"}.`;
232
- case "stats":
233
- settings.showStats = enabled;
234
- return `Section stats ${enabled ? "enabled" : "disabled"}.`;
235
- case "context":
236
- settings.showContext = enabled;
237
- return `Section context ${enabled ? "enabled" : "disabled"}.`;
238
- case "model":
239
- settings.showModel = enabled;
240
- return `Section model ${enabled ? "enabled" : "disabled"}.`;
241
- case "branch":
242
- settings.showBranch = enabled;
243
- return `Section branch ${enabled ? "enabled" : "disabled"}.`;
244
- case "pr":
245
- settings.showPr = enabled;
246
- return `Section pr ${enabled ? "enabled" : "disabled"}.`;
247
- case "ext":
248
- settings.showExtensionStatuses = enabled;
249
- return `Section ext ${enabled ? "enabled" : "disabled"}.`;
250
- default:
251
- return "Unknown section. Use: cwd|stats|context|model|branch|pr|ext";
252
- }
1475
+ if (!["on", "off", "enable", "disable", "true", "false"].includes(value)) return "Section value must be on/off.";
1476
+ if (!["cwd", "stats", "context", "model", "branch", "pr", "ext"].includes(key)) return "Unknown section. Use: cwd|stats|context|model|branch|pr|ext";
1477
+ (settings.items[key] ??= {}).visible = enabled;
1478
+ return `Section ${key} ${enabled ? "enabled" : "disabled"}.`;
253
1479
  }
254
1480
  if (command === "gap") {
255
1481
  if (!key || !value) return "Usage: /footerfx gap <min> <max>";
256
1482
  const min = Number(key);
257
1483
  const max = Number(value);
258
1484
  if (!Number.isFinite(min) || !Number.isFinite(max)) return "gap values must be numbers.";
259
- settings.minGap = clamp(Math.round(min), 1, 12);
260
- settings.maxGap = clamp(Math.round(max), settings.minGap, 40);
1485
+ settings.minGap = Math.max(0, Math.round(min));
1486
+ settings.maxGap = Math.max(settings.minGap, Math.round(max));
261
1487
  return `Gap updated (min=${settings.minGap}, max=${settings.maxGap}).`;
262
1488
  }
263
1489
  if (command === "anchor") {
264
- if (!key || !value) return "Usage: /footerfx anchor <line1|line2|all> <gap|left|center|right|spread>";
1490
+ if (!key || !value) return "Usage: /footerfx anchor <line|all> <gap|left|center|right|spread>";
265
1491
  if (!ANCHOR_MODES.includes(value as FooterAnchorMode)) {
266
1492
  return "Anchor must be one of: gap, left, center, right, spread.";
267
1493
  }
268
1494
  const mode = value as FooterAnchorMode;
269
- if (key === "line1") settings.line1Anchor = mode;
270
- else if (key === "line2") settings.line2Anchor = mode;
271
- else if (key === "all") {
272
- settings.line1Anchor = mode;
273
- settings.line2Anchor = mode;
274
- } else {
275
- return "Anchor target must be one of: line1, line2, all.";
1495
+ const target = parseFooterLineSelector(key);
1496
+ if (target === "all") {
1497
+ setAllLineAnchors(settings, mode);
1498
+ return `Anchor all lines set to ${mode}.`;
276
1499
  }
277
- return `Anchor ${key} set to ${mode}.`;
278
- }
279
- if (command === "branch-width") {
280
- if (!key) return "Usage: /footerfx branch-width <n>";
281
- const maxLength = Number(key);
282
- if (!Number.isFinite(maxLength)) return "branch-width must be a number.";
283
- settings.branchMaxLength = clamp(Math.round(maxLength), 10, 64);
284
- return `Branch width max set to ${settings.branchMaxLength}.`;
285
- }
286
- if (command === "mcp-zero") {
287
- if (!key || !["hide", "show"].includes(key)) return "Usage: /footerfx mcp-zero <hide|show>";
288
- settings.hideZeroMcp = key === "hide";
289
- return `MCP 0/x server line ${settings.hideZeroMcp ? "hidden" : "shown"}.`;
1500
+ if (target === undefined) return "Anchor target must be all or a positive line number.";
1501
+ setLineAnchor(settings, target, mode);
1502
+ return `Anchor line ${target} set to ${mode}.`;
290
1503
  }
291
1504
  if (command === "item") {
292
1505
  const [id, action, arg] = tokens.slice(1);
293
1506
  if (!id || !action) {
294
- return "Usage: /footerfx item <id> <show|hide|line|zone|order|column|before|after|reset> [value]";
1507
+ return "Usage: /footerfx item <id> <show|hide|line|row|zone|order|column|before|after|reset> [value]";
295
1508
  }
296
1509
  const item = (settings.items[id] ??= {});
297
1510
  if (action === "show") {
@@ -306,9 +1519,9 @@ function parseSettingsInput(settings: FooterFrameworkSettings, args: string): st
306
1519
  delete settings.items[id];
307
1520
  return `Item ${id} reset.`;
308
1521
  }
309
- if (action === "line") {
310
- const line = Number(arg);
311
- if (line !== 1 && line !== 2) return "Item line must be 1 or 2.";
1522
+ if (action === "line" || action === "row") {
1523
+ const line = normalizeFooterLine(arg);
1524
+ if (line === undefined) return "Item line must be a positive number.";
312
1525
  item.line = line;
313
1526
  return `Item ${id} moved to line ${line}.`;
314
1527
  }
@@ -328,11 +1541,11 @@ function parseSettingsInput(settings: FooterFrameworkSettings, args: string): st
328
1541
  if (action === "column") {
329
1542
  if (arg === "off" || arg === "auto") {
330
1543
  delete item.column;
331
- return `Item ${id} absolute column disabled.`;
1544
+ return `Item ${id} column disabled.`;
332
1545
  }
333
- const column = Number(arg);
334
- if (!Number.isFinite(column)) return "Item column must be a number, off, or auto.";
335
- item.column = clamp(Math.round(column), 0, 500);
1546
+ const column = normalizeColumn(arg);
1547
+ if (column === undefined) return "Item column must be a number, center, middle, percent like 50%, off, or auto.";
1548
+ item.column = column;
336
1549
  return `Item ${id} column set to ${item.column}.`;
337
1550
  }
338
1551
  if (action === "before" || action === "after") {
@@ -342,23 +1555,88 @@ function parseSettingsInput(settings: FooterFrameworkSettings, args: string): st
342
1555
  item[action] = arg;
343
1556
  return `Item ${id} positioned ${action} ${arg}.`;
344
1557
  }
345
- return "Unknown item action. Use show|hide|line|zone|order|column|before|after|reset.";
1558
+ return "Unknown item action. Use show|hide|line|row|zone|order|column|before|after|reset.";
1559
+ }
1560
+ if (command === "adapter") {
1561
+ const [id, action, sourceKey, label] = tokens.slice(1);
1562
+ if (!id) {
1563
+ const ids = Object.keys(settings.adapters).sort();
1564
+ return ids.length ? `Adapters: ${ids.join(", ")}` : "No footer adapters configured.";
1565
+ }
1566
+ if (action === "remove" || action === "delete") {
1567
+ delete settings.adapters[id];
1568
+ if (DEFAULT_BUILT_IN_ADAPTERS[id]) {
1569
+ (settings.items[id] ??= {}).visible = false;
1570
+ return `Built-in item ${id} hidden.`;
1571
+ }
1572
+ return `Adapter ${id} removed.`;
1573
+ }
1574
+ if (action === "template" || action === "empty-template" || action === "style") {
1575
+ const existing = settings.adapters[id] ?? DEFAULT_BUILT_IN_ADAPTERS[id];
1576
+ if (!existing) return `Adapter ${id} does not exist yet. Create it with pi/status/custom or footer_framework_adapter_config first.`;
1577
+ const match = args.match(new RegExp(`^adapter\\s+${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+${action}\\s+([\\s\\S]+)$`));
1578
+ const body = match?.[1]?.trim();
1579
+ if (!body) return `Usage: /footerfx adapter ${id} ${action} <value>`;
1580
+ settings.adapters[id] = { ...existing };
1581
+ if (action === "template") settings.adapters[id].template = body;
1582
+ else if (action === "empty-template") settings.adapters[id].emptyTemplate = body;
1583
+ else settings.adapters[id].style = body;
1584
+ return `Adapter ${id} ${action} updated.`;
1585
+ }
1586
+ if (action === "pi") {
1587
+ if (!sourceKey) return `Usage: /footerfx adapter ${id} pi <source-key> [label]`;
1588
+ settings.adapters[id] = {
1589
+ source: "pi",
1590
+ key: sourceKey,
1591
+ label: label ?? id,
1592
+ format: "label-value",
1593
+ placement: { visible: true, line: 2, zone: "right", order: 100 },
1594
+ };
1595
+ return `Adapter ${id} maps Pi source ${sourceKey}.`;
1596
+ }
1597
+ if (action === "status") {
1598
+ if (!sourceKey) return `Usage: /footerfx adapter ${id} status <status-key> [label]`;
1599
+ settings.adapters[id] = {
1600
+ source: "extensionStatus",
1601
+ key: sourceKey,
1602
+ label: label ?? id,
1603
+ format: "label-value",
1604
+ placement: { visible: true, line: 2, zone: "right", order: 100 },
1605
+ };
1606
+ return `Adapter ${id} maps extension status ${sourceKey}.`;
1607
+ }
1608
+ if (action === "custom") {
1609
+ const pathExpression = tokens[4];
1610
+ const customLabel = tokens[5];
1611
+ if (!sourceKey || !pathExpression) return `Usage: /footerfx adapter ${id} custom <custom-type> <path> [label]`;
1612
+ const dataPath = pathExpression.startsWith("data.") || pathExpression.startsWith("$.data.") ? pathExpression : `data.${pathExpression.replace(/^\$\.?/, "")}`;
1613
+ settings.adapters[id] = {
1614
+ source: "sessionEntry",
1615
+ key: sourceKey,
1616
+ path: dataPath,
1617
+ label: customLabel ?? id,
1618
+ format: "label-value",
1619
+ placement: { visible: true, line: 2, zone: "right", order: 100 },
1620
+ };
1621
+ return `Adapter ${id} maps latest custom entry ${sourceKey}.${pathExpression}.`;
1622
+ }
1623
+ return "Usage: /footerfx adapter [id] <pi|status|custom|remove> ...";
346
1624
  }
347
1625
 
348
1626
  return `Unknown command: ${command}`;
349
1627
  }
350
1628
 
351
- function settingsSummary(settings: FooterFrameworkSettings, loadedConfig?: string): string {
1629
+ function settingsSummary(settings: FooterFrameworkSettings, loadedConfig?: string, configDiagnostics: string[] = []): string {
352
1630
  const customizedItems = Object.keys(settings.items).sort();
1631
+ const adapters = Object.keys(settings.adapters).sort();
353
1632
  return [
354
1633
  loadedConfig ? `loaded=${loadedConfig}` : undefined,
355
1634
  `enabled=${settings.enabled}`,
356
- `sections: cwd=${settings.showCwd}, stats=${settings.showStats}, context=${settings.showContext}, model=${settings.showModel}, branch=${settings.showBranch}, pr=${settings.showPr}, ext=${settings.showExtensionStatuses}`,
357
- `anchor: line1=${settings.line1Anchor}, line2=${settings.line2Anchor}`,
1635
+ `anchors: ${sortedLineAnchors(settings)}`,
358
1636
  `gap: min=${settings.minGap}, max=${settings.maxGap}`,
359
- `branchMaxLength=${settings.branchMaxLength}`,
360
- `hideZeroMcp=${settings.hideZeroMcp}`,
361
1637
  customizedItems.length ? `customizedItems=${customizedItems.join(",")}` : undefined,
1638
+ adapters.length ? `adapters=${adapters.join(",")}` : undefined,
1639
+ configDiagnostics.length ? `configDiagnostics=${configDiagnostics.length}` : undefined,
362
1640
  ]
363
1641
  .filter(Boolean)
364
1642
  .join("\n");
@@ -367,52 +1645,37 @@ function settingsSummary(settings: FooterFrameworkSettings, loadedConfig?: strin
367
1645
  const extensionDir = path.dirname(fileURLToPath(import.meta.url));
368
1646
 
369
1647
  export default function footerFramework(pi: ExtensionAPI): void {
370
- const settings: FooterFrameworkSettings = { ...DEFAULT_SETTINGS, items: { ...DEFAULT_SETTINGS.items } };
1648
+ const settings: FooterFrameworkSettings = cloneDefaultSettings();
371
1649
  let prState: PrState | undefined;
372
1650
  let currentCtx: ExtensionContext | undefined;
373
1651
  let requestRender: (() => void) | undefined;
374
- const externalItems = new Map<string, { text: string; placement: Partial<FooterItemPlacement> }>();
1652
+ const externalItems = new Map<string, ExternalFooterItem>();
1653
+ let configuredItemRenderers: Record<string, { render: FooterRenderFunction; source: string }> = {};
1654
+ let configuredAdapterRenderers: Record<string, { render: FooterRenderFunction; source: string }> = {};
375
1655
  let lastLoadedConfig = "defaults";
376
- let lastFooterSnapshot:
377
- | {
378
- width: number;
379
- line1: string;
380
- line2: string;
381
- line1Layout: {
382
- anchor: FooterAnchorMode;
383
- leftWidth: number;
384
- rightWidthOriginal: number;
385
- rightWidthFinal: number;
386
- padCount: number;
387
- rightStartCol: number;
388
- rightEndCol: number;
389
- truncated: boolean;
390
- };
391
- line2Layout: {
392
- anchor: FooterAnchorMode;
393
- leftWidth: number;
394
- rightWidthOriginal: number;
395
- rightWidthFinal: number;
396
- padCount: number;
397
- rightStartCol: number;
398
- rightEndCol: number;
399
- truncated: boolean;
400
- };
401
- gitBranch: string | null;
402
- renderedItems: Array<{ id: string; line: FooterLine; zone: FooterZone; order: number; column?: number; width: number }>;
403
- extensionStatuses: Array<{ key: string; value: string }>;
404
- model: string;
405
- contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } | null;
406
- thinkingLevel: string;
407
- cwd: string;
408
- }
409
- | undefined;
1656
+ let lastConfigDiagnostics: string[] = [];
1657
+ let lastFooterSnapshot: FooterSnapshot | undefined;
1658
+ let lastPiSources: Record<string, FooterAdapterSourceValue> = {};
1659
+ let lastTemplateDiagnostics: FooterTemplateDiagnostic[] = [];
410
1660
 
411
- function applyValidatedSettings(input: Partial<FooterFrameworkSettings>): void {
1661
+ function applyValidatedSettings(input: Partial<FooterFrameworkConfig>): void {
412
1662
  Object.assign(settings, normalizeSettings(input));
413
- settings.minGap = clamp(settings.minGap, 1, 12);
414
- settings.maxGap = clamp(settings.maxGap, settings.minGap, 40);
415
- settings.branchMaxLength = clamp(settings.branchMaxLength, 10, 64);
1663
+ settings.minGap = Math.max(0, Math.round(settings.minGap));
1664
+ settings.maxGap = Math.max(settings.minGap, Math.round(settings.maxGap));
1665
+ }
1666
+
1667
+ function applyCodeConfig(input: FooterFrameworkConfig, source: string): void {
1668
+ applyValidatedSettings(input);
1669
+ if (input.items && typeof input.items === "object") {
1670
+ for (const [id, item] of Object.entries(input.items)) {
1671
+ if (typeof item?.render === "function") configuredItemRenderers[id] = { render: item.render, source };
1672
+ }
1673
+ }
1674
+ if (input.adapters && typeof input.adapters === "object") {
1675
+ for (const [id, adapter] of Object.entries(input.adapters)) {
1676
+ if (typeof adapter?.render === "function") configuredAdapterRenderers[id] = { render: adapter.render, source };
1677
+ }
1678
+ }
416
1679
  }
417
1680
 
418
1681
  function saveSettings(scope: ConfigScope, ctx?: ExtensionContext): string {
@@ -434,27 +1697,69 @@ export default function footerFramework(pi: ExtensionAPI): void {
434
1697
  pi.appendEntry("footer-framework-state", settings);
435
1698
  }
436
1699
 
437
- function loadSettings(ctx: ExtensionContext): string {
438
- Object.assign(settings, { ...DEFAULT_SETTINGS, items: { ...DEFAULT_SETTINGS.items } });
1700
+ async function loadSettings(ctx: ExtensionContext): Promise<string> {
1701
+ Object.assign(settings, cloneDefaultSettings());
1702
+ configuredItemRenderers = {};
1703
+ configuredAdapterRenderers = {};
1704
+ lastConfigDiagnostics = [];
1705
+ const loaded: string[] = [];
439
1706
  const userPath = userConfigPath();
440
1707
  const projectPath = projectConfigPath(ctx);
1708
+ const userCodePath = userCodeConfigPath();
1709
+ const projectCodePath = projectCodeConfigPath(ctx);
1710
+
1711
+ if (userCodePath) {
1712
+ try {
1713
+ const config = await readCodeConfigFile(userCodePath);
1714
+ if (config) {
1715
+ applyCodeConfig(config, `user-code:${userCodePath}`);
1716
+ loaded.push(`user-code:${userCodePath}`);
1717
+ }
1718
+ } catch (error) {
1719
+ lastConfigDiagnostics.push(`Failed to load ${userCodePath}: ${error instanceof Error ? error.message : String(error)}`);
1720
+ }
1721
+ }
1722
+
441
1723
  const userConfig = readConfigFile(userPath);
442
- const projectConfig = readConfigFile(projectPath);
1724
+ if (userConfig) {
1725
+ applyValidatedSettings(userConfig);
1726
+ loaded.push(`user:${userPath}`);
1727
+ }
443
1728
 
444
- if (userConfig) applyValidatedSettings(userConfig);
445
- if (projectConfig) applyValidatedSettings(projectConfig);
1729
+ if (projectCodePath) {
1730
+ try {
1731
+ const config = await readCodeConfigFile(projectCodePath);
1732
+ if (config) {
1733
+ applyCodeConfig(config, `project-code:${projectCodePath}`);
1734
+ loaded.push(`project-code:${projectCodePath}`);
1735
+ }
1736
+ } catch (error) {
1737
+ lastConfigDiagnostics.push(`Failed to load ${projectCodePath}: ${error instanceof Error ? error.message : String(error)}`);
1738
+ }
1739
+ }
446
1740
 
447
- if (projectConfig) lastLoadedConfig = `project:${projectPath}`;
448
- else if (userConfig) lastLoadedConfig = `user:${userPath}`;
449
- else lastLoadedConfig = "defaults";
1741
+ const projectConfig = readConfigFile(projectPath);
1742
+ if (projectConfig) {
1743
+ applyValidatedSettings(projectConfig);
1744
+ loaded.push(`project:${projectPath}`);
1745
+ }
1746
+
1747
+ lastLoadedConfig = loaded.length ? loaded.join(" -> ") : "defaults";
450
1748
  return lastLoadedConfig;
451
1749
  }
452
1750
 
453
- function renderCheck(theme: ExtensionContext["ui"]["theme"], checks: ChecksState): string {
454
- if (checks === "pass") return theme.fg("success", "✅");
455
- if (checks === "fail") return theme.fg("error", "❌");
456
- if (checks === "running") return theme.fg("warning", "⏳");
457
- return theme.fg("muted", "•");
1751
+ function checkGlyph(checks: ChecksState): string {
1752
+ if (checks === "pass") return "✅";
1753
+ if (checks === "fail") return "❌";
1754
+ if (checks === "running") return "⏳";
1755
+ return "•";
1756
+ }
1757
+
1758
+ function checkTone(checks: ChecksState): ExternalFooterItemTone {
1759
+ if (checks === "pass") return "success";
1760
+ if (checks === "fail") return "error";
1761
+ if (checks === "running") return "warning";
1762
+ return "muted";
458
1763
  }
459
1764
 
460
1765
  function composeLine(
@@ -465,83 +1770,9 @@ export default function footerFramework(pi: ExtensionAPI): void {
465
1770
  anchor: FooterAnchorMode,
466
1771
  ): {
467
1772
  line: string;
468
- layout: {
469
- anchor: FooterAnchorMode;
470
- leftWidth: number;
471
- rightWidthOriginal: number;
472
- rightWidthFinal: number;
473
- padCount: number;
474
- rightStartCol: number;
475
- rightEndCol: number;
476
- truncated: boolean;
477
- };
1773
+ layout: FooterLineLayout;
478
1774
  } {
479
- const leftWidth = visibleWidth(left);
480
- if (!right || visibleWidth(right) === 0) {
481
- return {
482
- line: truncateToWidth(left, width, theme.fg("dim", "...")),
483
- layout: {
484
- anchor,
485
- leftWidth,
486
- rightWidthOriginal: 0,
487
- rightWidthFinal: 0,
488
- padCount: 0,
489
- rightStartCol: leftWidth,
490
- rightEndCol: leftWidth,
491
- truncated: false,
492
- },
493
- };
494
- }
495
- const rightWidthOriginal = visibleWidth(right);
496
- const naturalPad = width - leftWidth - rightWidthOriginal;
497
- let padCount = settings.minGap;
498
- if (anchor === "right" || anchor === "spread") {
499
- padCount = Math.max(settings.minGap, naturalPad);
500
- } else if (anchor === "center") {
501
- padCount = Math.max(settings.minGap, Math.floor(naturalPad / 2));
502
- padCount = Math.min(padCount, settings.maxGap);
503
- } else if (anchor === "gap") {
504
- padCount = Math.max(settings.minGap, Math.min(naturalPad, settings.maxGap));
505
- } else if (anchor === "left") {
506
- padCount = settings.minGap;
507
- }
508
-
509
- const availableForRight = Math.max(0, width - leftWidth - padCount);
510
- const compactRight = truncateToWidth(right, availableForRight, theme.fg("dim", "..."));
511
- const rightWidthFinal = visibleWidth(compactRight);
512
- const line = truncateToWidth(`${left}${" ".repeat(padCount)}${compactRight}`, width, theme.fg("dim", "..."));
513
- const rightStartCol = leftWidth + padCount;
514
- const rightEndCol = Math.max(rightStartCol, rightStartCol + rightWidthFinal - 1);
515
- return {
516
- line,
517
- layout: {
518
- anchor,
519
- leftWidth,
520
- rightWidthOriginal,
521
- rightWidthFinal,
522
- padCount,
523
- rightStartCol,
524
- rightEndCol,
525
- truncated: rightWidthFinal < rightWidthOriginal,
526
- },
527
- };
528
- }
529
-
530
- function renderBranch(theme: ExtensionContext["ui"]["theme"], gitBranch: string | null): string | undefined {
531
- if (!settings.showBranch || !gitBranch) return undefined;
532
- const compact = compactBranchName(gitBranch, settings.branchMaxLength);
533
- if (!settings.showPr || !prState?.pr || prState.branch !== gitBranch) {
534
- return theme.fg("muted", `(${compact})`);
535
- }
536
- const prLabel = osc8(theme.fg("accent", `#${prState.pr.number}`), prState.pr.url);
537
- return `${theme.fg("muted", `(${compact} `)}${prLabel}${theme.fg("muted", ")")}`;
538
- }
539
-
540
- function renderPrStatus(theme: ExtensionContext["ui"]["theme"]): string | undefined {
541
- if (!settings.showPr || !prState?.pr) return undefined;
542
- const tokens = [theme.fg("muted", "PR"), renderCheck(theme, prState.pr.checks)];
543
- if (prState.pr.comments > 0) tokens.push(theme.fg("muted", `💬${prState.pr.comments}`));
544
- return tokens.join(" ");
1775
+ return composeFooterLine({ width, left, right, anchor, minGap: settings.minGap, maxGap: settings.maxGap, ellipsis: theme.fg("dim", "...") });
545
1776
  }
546
1777
 
547
1778
  function renderModelLabel(): string {
@@ -549,7 +1780,7 @@ export default function footerFramework(pi: ExtensionAPI): void {
549
1780
  return `${model}:${pi.getThinkingLevel()}`;
550
1781
  }
551
1782
 
552
- function renderContextUsage(theme: ExtensionContext["ui"]["theme"]): string | undefined {
1783
+ function contextUsageSource(): FooterAdapterSourceValue | undefined {
553
1784
  const usage = currentCtx?.getContextUsage();
554
1785
  const contextWindow = usage?.contextWindow ?? currentCtx?.model?.contextWindow;
555
1786
  if (!contextWindow) return undefined;
@@ -558,11 +1789,13 @@ export default function footerFramework(pi: ExtensionAPI): void {
558
1789
  const percent = usage?.percent ?? null;
559
1790
  const percentText = percent === null ? "?%" : `${percent.toFixed(1)}%`;
560
1791
  const tokenText = tokens === null ? `?/${formatContextTokens(contextWindow)}` : `${formatContextTokens(tokens)}/${formatContextTokens(contextWindow)}`;
561
- const text = `ctx ${percentText} ${tokenText}`;
562
-
563
- if (percent !== null && percent > 90) return theme.fg("error", text);
564
- if (percent !== null && percent > 70) return theme.fg("warning", text);
565
- return theme.fg("dim", text);
1792
+ const tone: ExternalFooterItemTone = percent !== null && percent > 90 ? "error" : percent !== null && percent > 70 ? "warning" : "muted";
1793
+ return {
1794
+ label: "ctx",
1795
+ value: `ctx ${percentText} ${tokenText}`,
1796
+ tone,
1797
+ data: { tokens, contextWindow, window: contextWindow, percent, percentText, tokenText, tone },
1798
+ };
566
1799
  }
567
1800
 
568
1801
  function placementFor(id: string, fallback: FooterItemPlacement, external?: Partial<FooterItemPlacement>): FooterItemPlacement {
@@ -573,21 +1806,6 @@ export default function footerFramework(pi: ExtensionAPI): void {
573
1806
  };
574
1807
  }
575
1808
 
576
- function applyLegacySectionVisibility(items: FooterItem[]): void {
577
- const legacy: Record<string, boolean> = {
578
- cwd: settings.showCwd,
579
- stats: settings.showStats,
580
- context: settings.showContext,
581
- model: settings.showModel,
582
- branch: settings.showBranch,
583
- pr: settings.showPr,
584
- ext: settings.showExtensionStatuses,
585
- };
586
- for (const item of items) {
587
- if (legacy[item.id] === false && settings.items[item.id]?.visible === undefined) item.placement.visible = false;
588
- }
589
- }
590
-
591
1809
  function resolveRelativeOrders(items: FooterItem[]): FooterItem[] {
592
1810
  const sorted = [...items].sort((a, b) => a.placement.order - b.placement.order || a.id.localeCompare(b.id));
593
1811
  for (const item of sorted) {
@@ -605,25 +1823,44 @@ export default function footerFramework(pi: ExtensionAPI): void {
605
1823
  return sorted.sort((a, b) => a.placement.order - b.placement.order || a.id.localeCompare(b.id));
606
1824
  }
607
1825
 
608
- function overlayAbsoluteItems(theme: ExtensionContext["ui"]["theme"], width: number, line: string, items: FooterItem[]): string {
609
- let out = line;
610
- const sorted = items
611
- .filter((item) => Number.isFinite(item.placement.column))
612
- .sort((a, b) => (a.placement.column ?? 0) - (b.placement.column ?? 0) || a.placement.order - b.placement.order);
613
- for (const item of sorted) {
614
- const column = clamp(item.placement.column ?? 0, 0, width - 1);
615
- const prefix = truncateToWidth(out, column, "");
616
- const pad = " ".repeat(Math.max(0, column - visibleWidth(prefix)));
617
- const available = Math.max(0, width - column);
618
- const text = truncateToWidth(item.text, available, theme.fg("dim", "..."));
619
- out = truncateToWidth(`${prefix}${pad}${text}`, width, theme.fg("dim", "..."));
1826
+ function renderVisibilityDiagnostics(items: FooterItem[], lines: Array<{ line: FooterLine; plainText: string }>): FooterRenderDiagnostic[] {
1827
+ const lineTextByNumber = new Map(lines.map((line) => [line.line, line.plainText]));
1828
+ const diagnostics: FooterRenderDiagnostic[] = [];
1829
+ for (const item of items) {
1830
+ const itemPlainText = plainFooterText(item.text).trim();
1831
+ if (!itemPlainText) continue;
1832
+ const linePlainText = lineTextByNumber.get(item.placement.line);
1833
+ if (linePlainText === undefined) {
1834
+ diagnostics.push({
1835
+ itemId: item.id,
1836
+ line: item.placement.line,
1837
+ severity: "warning",
1838
+ message: "Item rendered but its footer line was not produced.",
1839
+ itemPlainText,
1840
+ });
1841
+ continue;
1842
+ }
1843
+ if (!linePlainText.includes(itemPlainText)) {
1844
+ diagnostics.push({
1845
+ itemId: item.id,
1846
+ line: item.placement.line,
1847
+ severity: "warning",
1848
+ message: "Item is present in renderedItems but its text is not visible in the final rendered line; check overlap, truncation, or layout composition.",
1849
+ itemPlainText,
1850
+ linePlainText,
1851
+ });
1852
+ }
620
1853
  }
621
- return out;
1854
+ return diagnostics;
1855
+ }
1856
+
1857
+ function overlayAbsoluteItems(theme: ExtensionContext["ui"]["theme"], width: number, line: string, items: FooterItem[]): string {
1858
+ return overlayFooterColumnItems(width, line, items, theme.fg("dim", "..."));
622
1859
  }
623
1860
 
624
1861
  function renderFooterLine(theme: ExtensionContext["ui"]["theme"], width: number, items: FooterItem[], line: FooterLine, anchor: FooterAnchorMode) {
625
1862
  const lineItems = items.filter((item) => item.placement.line === line);
626
- const normalItems = lineItems.filter((item) => !Number.isFinite(item.placement.column));
1863
+ const normalItems = lineItems.filter((item) => item.placement.column === undefined);
627
1864
  const left = normalItems
628
1865
  .filter((item) => item.placement.zone === "left")
629
1866
  .sort((a, b) => a.placement.order - b.placement.order)
@@ -638,56 +1875,229 @@ export default function footerFramework(pi: ExtensionAPI): void {
638
1875
  return { ...result, line: overlayAbsoluteItems(theme, width, result.line, lineItems) };
639
1876
  }
640
1877
 
641
- function collectItems(
642
- theme: ExtensionContext["ui"]["theme"],
1878
+ function latestCustomEntry(customType: string): unknown {
1879
+ const entries = currentCtx?.sessionManager.getEntries() ?? [];
1880
+ for (let index = entries.length - 1; index >= 0; index--) {
1881
+ const entry = entries[index] as { type?: string; customType?: string };
1882
+ if (entry.type === "custom" && entry.customType === customType) return entry;
1883
+ }
1884
+ return undefined;
1885
+ }
1886
+
1887
+ function adaptedExtensionStatusKeys(): Set<string> {
1888
+ return new Set(
1889
+ Object.values(allAdapters())
1890
+ .filter((adapter) => adapter.source === "extensionStatus")
1891
+ .map((adapter) => adapter.key),
1892
+ );
1893
+ }
1894
+
1895
+ function allAdapters(): Record<string, FooterAdapterConfig> {
1896
+ return { ...DEFAULT_BUILT_IN_ADAPTERS, ...settings.adapters };
1897
+ }
1898
+
1899
+ function buildPiSources(
643
1900
  footerData: { getGitBranch(): string | null; getExtensionStatuses(): ReadonlyMap<string, string> },
644
- statsText: string,
1901
+ stats: { input: number; output: number; cost: number; inputText: string; outputText: string; costText: string; value: string },
1902
+ ): Record<string, FooterAdapterSourceValue> {
1903
+ const sources: Record<string, FooterAdapterSourceValue> = {
1904
+ cwd: { label: "cwd", value: currentCtx?.cwd ?? "", tone: "muted", data: { path: currentCtx?.cwd ?? "" } },
1905
+ model: {
1906
+ label: "model",
1907
+ value: renderModelLabel(),
1908
+ tone: "muted",
1909
+ data: { ...(currentCtx?.model ?? {}), id: currentCtx?.model?.id ?? "no-model", provider: currentCtx?.model?.provider, thinking: pi.getThinkingLevel() },
1910
+ },
1911
+ stats: { label: "stats", value: stats.value, tone: "muted", data: stats },
1912
+ extensionStatuses: { label: "ext", value: footerData.getExtensionStatuses().size, data: Object.fromEntries(footerData.getExtensionStatuses()) },
1913
+ };
1914
+ const context = contextUsageSource();
1915
+ if (context) sources.context = context;
1916
+ const gitBranch = footerData.getGitBranch();
1917
+ if (gitBranch) {
1918
+ const pr = prState?.pr && prState.branch === gitBranch ? prState.pr : undefined;
1919
+ const label = pr ? `(${gitBranch} #${pr.number})` : `(${gitBranch})`;
1920
+ sources.branch = {
1921
+ label: "branch",
1922
+ value: label,
1923
+ url: pr?.url,
1924
+ tone: "muted",
1925
+ data: { name: gitBranch, label, prNumber: pr?.number, prUrl: pr?.url, pr },
1926
+ };
1927
+ }
1928
+ if (prState?.pr) {
1929
+ const commentsText = prState.pr.comments > 0 ? ` 💬${prState.pr.comments}` : "";
1930
+ const data = {
1931
+ ...prState,
1932
+ number: prState.pr.number,
1933
+ title: prState.pr.title,
1934
+ url: prState.pr.url,
1935
+ comments: prState.pr.comments,
1936
+ commentsText,
1937
+ checks: prState.pr.checks,
1938
+ checkGlyph: checkGlyph(prState.pr.checks),
1939
+ checkTone: checkTone(prState.pr.checks),
1940
+ };
1941
+ sources.pr = { label: "PR", value: `${data.checkGlyph}${commentsText}`, url: prState.pr.url, tone: data.checkTone, data };
1942
+ }
1943
+ return sources;
1944
+ }
1945
+
1946
+ function collectConfiguredItems(
1947
+ theme: ExtensionContext["ui"]["theme"],
1948
+ piSources: Record<string, FooterAdapterSourceValue>,
1949
+ diagnostics: FooterTemplateDiagnostic[],
645
1950
  ): FooterItem[] {
646
1951
  const items: FooterItem[] = [];
647
- items.push({ id: "cwd", text: theme.fg("dim", currentCtx?.cwd ?? ""), placement: placementFor("cwd", DEFAULT_ITEM_PLACEMENTS.cwd) });
648
- items.push({ id: "model", text: theme.fg("dim", renderModelLabel()), placement: placementFor("model", DEFAULT_ITEM_PLACEMENTS.model) });
649
- items.push({ id: "stats", text: statsText, placement: placementFor("stats", DEFAULT_ITEM_PLACEMENTS.stats) });
650
- const contextUsageText = renderContextUsage(theme);
651
- if (contextUsageText) items.push({ id: "context", text: contextUsageText, placement: placementFor("context", DEFAULT_ITEM_PLACEMENTS.context) });
1952
+ for (const [id, renderer] of Object.entries(configuredItemRenderers)) {
1953
+ const rendered = renderFunctionOutput(theme, id, renderer.render, renderContextForAdapter(id, undefined, undefined, piSources), diagnostics);
1954
+ if (!rendered) continue;
1955
+ items.push({
1956
+ id,
1957
+ text: rendered.text,
1958
+ tokens: rendered.tokens,
1959
+ renderSource: "function",
1960
+ placement: placementFor(id, DEFAULT_ITEM_PLACEMENTS[id] ?? { visible: true, line: 2, zone: "right", order: 90 }),
1961
+ });
1962
+ }
1963
+ return items;
1964
+ }
652
1965
 
653
- const gitBranch = footerData.getGitBranch();
654
- if (gitBranch) {
655
- const compact = compactBranchName(gitBranch, settings.branchMaxLength);
656
- const branchText = settings.showPr && prState?.pr && prState.branch === gitBranch
657
- ? `${theme.fg("muted", `(${compact} `)}${osc8(theme.fg("accent", `#${prState.pr.number}`), prState.pr.url)}${theme.fg("muted", ")")}`
658
- : theme.fg("muted", `(${compact})`);
659
- items.push({ id: "branch", text: branchText, placement: placementFor("branch", DEFAULT_ITEM_PLACEMENTS.branch) });
1966
+ function collectAdapterItems(
1967
+ theme: ExtensionContext["ui"]["theme"],
1968
+ footerData: { getExtensionStatuses(): ReadonlyMap<string, string> },
1969
+ piSources: Record<string, FooterAdapterSourceValue>,
1970
+ diagnostics: FooterTemplateDiagnostic[],
1971
+ ): FooterItem[] {
1972
+ const items: FooterItem[] = [];
1973
+ const extensionStatuses = footerData.getExtensionStatuses();
1974
+ for (const [adapterId, adapter] of Object.entries(allAdapters())) {
1975
+ const itemId = adapter.itemId ?? adapterId;
1976
+ if (configuredItemRenderers[itemId]) continue;
1977
+ let sourceValue: unknown;
1978
+ if (adapter.source === "pi") {
1979
+ sourceValue = piSources[adapter.key];
1980
+ if (sourceValue === undefined) continue;
1981
+ } else if (adapter.source === "extensionStatus") {
1982
+ const value = extensionStatuses.get(adapter.key);
1983
+ if (value === undefined) continue;
1984
+ sourceValue = { key: adapter.key, value };
1985
+ } else if (adapter.source === "sessionEntry") {
1986
+ sourceValue = latestCustomEntry(adapter.key);
1987
+ if (sourceValue === undefined) continue;
1988
+ }
1989
+ const external = adapterItemFromSource(adapterId, adapter, sourceValue);
1990
+ if (!external) continue;
1991
+ const rendered = renderAdapterText(theme, adapterId, adapter, external, sourceValue, piSources, diagnostics, configuredAdapterRenderers[adapterId]?.render);
1992
+ if (!rendered) continue;
1993
+ items.push({
1994
+ id: external.id,
1995
+ text: rendered.text,
1996
+ tokens: rendered.tokens,
1997
+ renderSource: rendered.renderSource,
1998
+ placement: placementFor(external.id, { visible: true, line: 2, zone: "right", order: 90 }, external.hint.placement),
1999
+ });
660
2000
  }
2001
+ return items;
2002
+ }
661
2003
 
662
- const prStatus = renderPrStatus(theme);
663
- if (prStatus) items.push({ id: "pr", text: prStatus, placement: placementFor("pr", DEFAULT_ITEM_PLACEMENTS.pr) });
2004
+ function collectItems(
2005
+ theme: ExtensionContext["ui"]["theme"],
2006
+ footerData: { getGitBranch(): string | null; getExtensionStatuses(): ReadonlyMap<string, string> },
2007
+ stats: { input: number; output: number; cost: number; inputText: string; outputText: string; costText: string; value: string },
2008
+ diagnostics: FooterTemplateDiagnostic[],
2009
+ ): FooterItem[] {
2010
+ const items: FooterItem[] = [];
2011
+ const piSources = buildPiSources(footerData, stats);
2012
+ lastPiSources = piSources;
664
2013
 
2014
+ const adaptedStatusKeys = adaptedExtensionStatusKeys();
665
2015
  const extStatuses = Array.from(footerData.getExtensionStatuses().entries())
666
2016
  .sort(([a], [b]) => a.localeCompare(b))
667
2017
  .flatMap(([key, value]) => {
668
2018
  const text = sanitizeStatusText(value);
669
- if (key === "footer-framework" || key === "pr-upstream") return [];
2019
+ if (key === "footer-framework" || key === "pr-upstream" || adaptedStatusKeys.has(key)) return [];
670
2020
  if (visibleWidth(text) === 0) return [];
671
- if (settings.hideZeroMcp && /MCP:\s*0\/\d+\s+servers/.test(text)) return [];
672
2021
  return [text];
673
2022
  })
674
2023
  .join(" · ");
675
- if (extStatuses) items.push({ id: "ext", text: extStatuses, placement: placementFor("ext", DEFAULT_ITEM_PLACEMENTS.ext) });
2024
+ if (extStatuses && !configuredItemRenderers.ext) items.push({ id: "ext", text: extStatuses, placement: placementFor("ext", DEFAULT_ITEM_PLACEMENTS.ext), renderSource: "external" });
2025
+
2026
+ items.push(...collectConfiguredItems(theme, piSources, diagnostics));
2027
+ items.push(...collectAdapterItems(theme, footerData, piSources, diagnostics));
676
2028
 
677
2029
  for (const [id, external] of externalItems) {
678
- if (!external.text) continue;
2030
+ const text = renderExternalItem(theme, external);
2031
+ if (!text) continue;
679
2032
  items.push({
680
2033
  id,
681
- text: external.text,
682
- placement: placementFor(id, { visible: true, line: 2, zone: "right", order: 100 }, external.placement),
2034
+ text,
2035
+ renderSource: "external",
2036
+ placement: placementFor(id, { visible: true, line: 2, zone: "right", order: 100 }, external.hint.placement),
683
2037
  });
684
2038
  }
685
2039
 
686
- applyLegacySectionVisibility(items);
687
2040
  return resolveRelativeOrders(items).filter((item) => item.placement.visible && item.text.length > 0);
688
2041
  }
689
2042
 
690
- function applyFooterConfig(input: string, ctx?: ExtensionContext): string {
2043
+ function compactRuntimeItem(item: { name: string; description?: string; sourceInfo?: unknown }, options: FooterSourceInventoryOptions) {
2044
+ return options.includeDetails ? { name: item.name, description: item.description, sourceInfo: item.sourceInfo } : { name: item.name };
2045
+ }
2046
+
2047
+ function footerSourceInventory(options: FooterSourceInventoryOptions = {}) {
2048
+ const entries = currentCtx?.sessionManager.getEntries() ?? [];
2049
+ const customEntries = new Map<string, { customType: string; count: number; latest: unknown }>();
2050
+ for (const entry of entries as Array<{ type?: string; customType?: string; data?: unknown }>) {
2051
+ if (entry.type !== "custom" || !entry.customType) continue;
2052
+ const current = customEntries.get(entry.customType) ?? { customType: entry.customType, count: 0, latest: undefined };
2053
+ current.count += 1;
2054
+ current.latest = previewValue(entry.data);
2055
+ customEntries.set(entry.customType, current);
2056
+ }
2057
+ const payload: Record<string, unknown> = {
2058
+ builtInItems: Object.keys(DEFAULT_ITEM_PLACEMENTS).sort(),
2059
+ piSources: Object.fromEntries(Object.entries(lastPiSources).map(([key, value]) => [key, previewValue(value)])),
2060
+ stylePrimitives: {
2061
+ foregroundColors: Array.from(THEME_FG_COLORS),
2062
+ backgroundColors: Array.from(THEME_BG_COLORS),
2063
+ attributes: Array.from(THEME_TEXT_ATTRIBUTES),
2064
+ },
2065
+ templateDiagnostics: lastTemplateDiagnostics,
2066
+ defaultBuiltInAdapters: DEFAULT_BUILT_IN_ADAPTERS,
2067
+ externalItems: Array.from(externalItems.values()).map((item) => ({
2068
+ id: item.id,
2069
+ label: item.label,
2070
+ hasValue: item.value !== undefined,
2071
+ hasStatus: item.status !== undefined,
2072
+ hasData: item.data !== undefined,
2073
+ hint: item.hint,
2074
+ })),
2075
+ extensionStatuses: lastFooterSnapshot?.extensionStatuses ?? [],
2076
+ customEntries: Array.from(customEntries.values()).sort((a, b) => a.customType.localeCompare(b.customType)),
2077
+ adapters: settings.adapters,
2078
+ configuredRenderers: {
2079
+ items: Object.fromEntries(Object.entries(configuredItemRenderers).map(([id, renderer]) => [id, { source: renderer.source }])),
2080
+ adapters: Object.fromEntries(Object.entries(configuredAdapterRenderers).map(([id, renderer]) => [id, { source: renderer.source }])),
2081
+ },
2082
+ renderedItems: lastFooterSnapshot?.renderedItems ?? [],
2083
+ configDiagnostics: lastConfigDiagnostics,
2084
+ configPaths: configPaths(currentCtx),
2085
+ omitted: {
2086
+ tools: "pass includeTools: true to include registered tool names",
2087
+ commands: "pass includeCommands: true to include command names",
2088
+ details: "pass includeDetails: true with includeTools/includeCommands for descriptions and sourceInfo",
2089
+ skills: "pass includeSkills: true with includeCommands to include skill commands",
2090
+ },
2091
+ };
2092
+ if (options.includeTools) payload.tools = pi.getAllTools().map((tool) => compactRuntimeItem(tool, options));
2093
+ if (options.includeCommands) {
2094
+ const commands = options.includeSkills ? pi.getCommands() : pi.getCommands().filter((command) => !command.name.startsWith("skill:"));
2095
+ payload.commands = commands.map((command) => compactRuntimeItem(command, options));
2096
+ }
2097
+ return payload;
2098
+ }
2099
+
2100
+ async function applyFooterConfig(input: string, ctx?: ExtensionContext): Promise<string> {
691
2101
  try {
692
2102
  const trimmed = input.trim();
693
2103
  const [command, scope] = trimmed.split(/\s+/);
@@ -700,10 +2110,12 @@ export default function footerFramework(pi: ExtensionAPI): void {
700
2110
  shouldPersist = false;
701
2111
  } else if (command === "load") {
702
2112
  if (!ctx) return "Cannot load footer config before a session is active.";
703
- message = `Loaded footer config from ${loadSettings(ctx)}.`;
2113
+ message = `Loaded footer config from ${await loadSettings(ctx)}.`;
704
2114
  shouldPersist = false;
705
2115
  } else if (command === "config") {
706
- message = [`Loaded: ${lastLoadedConfig}`, `User: ${userConfigPath()}`, ctx ? `Project: ${projectConfigPath(ctx)}` : "Project: unavailable before session"].join("\n");
2116
+ message = [`Loaded: ${lastLoadedConfig}`, `Paths: ${JSON.stringify(configPaths(ctx), null, 2)}`, lastConfigDiagnostics.length ? `Diagnostics:\n${lastConfigDiagnostics.join("\n")}` : undefined]
2117
+ .filter(Boolean)
2118
+ .join("\n");
707
2119
  shouldPersist = false;
708
2120
  } else {
709
2121
  message = parseSettingsInput(settings, trimmed) ?? settingsSummary(settings, lastLoadedConfig);
@@ -736,7 +2148,7 @@ export default function footerFramework(pi: ExtensionAPI): void {
736
2148
  unsubscribe();
737
2149
  },
738
2150
  invalidate() {},
739
- render(width: number): string[] {
2151
+ render(width: number): string[] {
740
2152
  let input = 0;
741
2153
  let output = 0;
742
2154
  let cost = 0;
@@ -747,33 +2159,57 @@ export default function footerFramework(pi: ExtensionAPI): void {
747
2159
  cost += entry.message.usage.cost.total;
748
2160
  }
749
2161
 
750
- const statsText = theme.fg("dim", `↑${formatTokens(input)} ↓${formatTokens(output)} $${cost.toFixed(3)}`);
751
- const items = collectItems(theme, footerData, statsText);
752
- const line1Result = renderFooterLine(theme, width, items, 1, settings.line1Anchor);
753
- const line2Result = renderFooterLine(theme, width, items, 2, settings.line2Anchor);
2162
+ const stats = {
2163
+ input,
2164
+ output,
2165
+ cost,
2166
+ inputText: formatTokens(input),
2167
+ outputText: formatTokens(output),
2168
+ costText: cost.toFixed(3),
2169
+ value: `↑${formatTokens(input)} ↓${formatTokens(output)} $${cost.toFixed(3)}`,
2170
+ };
2171
+ const diagnostics: FooterTemplateDiagnostic[] = [];
2172
+ const items = collectItems(theme, footerData, stats, diagnostics);
2173
+ lastTemplateDiagnostics = diagnostics;
2174
+ const maxLine = Math.max(2, ...items.map((item) => item.placement.line));
2175
+ const lineResults = Array.from({ length: maxLine }, (_, index) => {
2176
+ const line = index + 1;
2177
+ const result = renderFooterLine(theme, width, items, line, getLineAnchor(settings, line));
2178
+ return { line, text: result.line, plainText: plainFooterText(result.line), layout: result.layout };
2179
+ });
2180
+ const line1Result = lineResults[0];
2181
+ const line2Result = lineResults[1];
2182
+ const renderedItems = items.map((item) => ({
2183
+ id: item.id,
2184
+ line: item.placement.line,
2185
+ zone: item.placement.zone,
2186
+ order: item.placement.order,
2187
+ column: item.placement.column,
2188
+ width: visibleWidth(item.text),
2189
+ plainText: plainFooterText(item.text),
2190
+ renderSource: item.renderSource,
2191
+ tokens: item.tokens,
2192
+ }));
754
2193
 
755
2194
  lastFooterSnapshot = {
756
2195
  width,
757
- line1: line1Result.line,
758
- line2: line2Result.line,
759
- line1Layout: line1Result.layout,
760
- line2Layout: line2Result.layout,
2196
+ lines: lineResults,
2197
+ line1: line1Result?.text ?? "",
2198
+ line2: line2Result?.text ?? "",
2199
+ line1PlainText: line1Result?.plainText ?? "",
2200
+ line2PlainText: line2Result?.plainText ?? "",
2201
+ line1Layout: line1Result?.layout ?? renderFooterLine(theme, width, [], 1, getLineAnchor(settings, 1)).layout,
2202
+ line2Layout: line2Result?.layout ?? renderFooterLine(theme, width, [], 2, getLineAnchor(settings, 2)).layout,
761
2203
  gitBranch: footerData.getGitBranch(),
762
- renderedItems: items.map((item) => ({
763
- id: item.id,
764
- line: item.placement.line,
765
- zone: item.placement.zone,
766
- order: item.placement.order,
767
- column: item.placement.column,
768
- width: visibleWidth(item.text),
769
- })),
2204
+ renderedItems,
2205
+ renderDiagnostics: renderVisibilityDiagnostics(items, lineResults),
770
2206
  extensionStatuses: Array.from(footerData.getExtensionStatuses().entries()).map(([key, value]) => ({ key, value })),
771
2207
  model: ctx.model?.id ?? "no-model",
772
2208
  contextUsage: ctx.getContextUsage() ?? null,
773
2209
  thinkingLevel: pi.getThinkingLevel(),
774
2210
  cwd: ctx.cwd,
775
2211
  };
776
- return [line1Result.line, line2Result.line];
2212
+ return lineResults.map((line) => line.text);
777
2213
  },
778
2214
  };
779
2215
  });
@@ -790,23 +2226,25 @@ export default function footerFramework(pi: ExtensionAPI): void {
790
2226
 
791
2227
  pi.events.on("footer-framework:item", (event) => {
792
2228
  const item = event as ExternalFooterItemEvent;
793
- if (!item.id?.trim()) return;
794
- if (item.remove) externalItems.delete(item.id);
795
- else if (typeof item.text === "string") {
796
- externalItems.set(item.id, { text: item.text, placement: normalizePlacement(item.placement ?? {}) });
2229
+ const id = item.id?.trim();
2230
+ if (!id) return;
2231
+ if (item.remove) externalItems.delete(id);
2232
+ else {
2233
+ const normalized = normalizeExternalItemEvent(item);
2234
+ if (normalized) externalItems.set(id, normalized);
797
2235
  }
798
2236
  requestRender?.();
799
2237
  });
800
2238
 
801
2239
  pi.registerCommand("footerfx", {
802
- description: "Footer framework controls (on/off, section, gap, branch-width, mcp-zero, reset)",
2240
+ description: "Footer framework controls (on/off, item layout, anchor, gap, reset)",
803
2241
  handler: async (args, ctx) => {
804
2242
  const trimmed = args.trim();
805
2243
  if (!trimmed) {
806
- ctx.ui.notify(settingsSummary(settings, lastLoadedConfig), "info");
2244
+ ctx.ui.notify(settingsSummary(settings, lastLoadedConfig, lastConfigDiagnostics), "info");
807
2245
  return;
808
2246
  }
809
- ctx.ui.notify(applyFooterConfig(trimmed, ctx), "info");
2247
+ ctx.ui.notify(await applyFooterConfig(trimmed, ctx), "info");
810
2248
  },
811
2249
  });
812
2250
 
@@ -816,8 +2254,14 @@ export default function footerFramework(pi: ExtensionAPI): void {
816
2254
  const payload = {
817
2255
  settings,
818
2256
  loadedConfig: lastLoadedConfig,
819
- configPaths: { user: userConfigPath(), project: projectConfigPath(ctx) },
2257
+ configDiagnostics: lastConfigDiagnostics,
2258
+ configPaths: configPaths(ctx),
2259
+ configuredRenderers: {
2260
+ items: Object.fromEntries(Object.entries(configuredItemRenderers).map(([id, renderer]) => [id, { source: renderer.source }])),
2261
+ adapters: Object.fromEntries(Object.entries(configuredAdapterRenderers).map(([id, renderer]) => [id, { source: renderer.source }])),
2262
+ },
820
2263
  prState,
2264
+ lastTemplateDiagnostics,
821
2265
  lastFooterSnapshot,
822
2266
  };
823
2267
  ctx.ui.notify(JSON.stringify(payload, null, 2), "info");
@@ -834,8 +2278,14 @@ export default function footerFramework(pi: ExtensionAPI): void {
834
2278
  const payload = {
835
2279
  settings,
836
2280
  loadedConfig: lastLoadedConfig,
837
- configPaths: currentCtx ? { user: userConfigPath(), project: projectConfigPath(currentCtx) } : { user: userConfigPath() },
2281
+ configDiagnostics: lastConfigDiagnostics,
2282
+ configPaths: configPaths(currentCtx),
2283
+ configuredRenderers: {
2284
+ items: Object.fromEntries(Object.entries(configuredItemRenderers).map(([id, renderer]) => [id, { source: renderer.source }])),
2285
+ adapters: Object.fromEntries(Object.entries(configuredAdapterRenderers).map(([id, renderer]) => [id, { source: renderer.source }])),
2286
+ },
838
2287
  prState,
2288
+ lastTemplateDiagnostics,
839
2289
  lastFooterSnapshot,
840
2290
  };
841
2291
  return {
@@ -846,6 +2296,79 @@ export default function footerFramework(pi: ExtensionAPI): void {
846
2296
  }),
847
2297
  );
848
2298
 
2299
+ pi.registerTool(
2300
+ defineTool({
2301
+ name: "footer_framework_sources",
2302
+ label: "Footer Framework Sources",
2303
+ description: "Inspect data sources the footer framework can adapt into footer items. Defaults to concise footer-relevant data; pass includeTools/includeCommands for runtime metadata.",
2304
+ parameters: Type.Object({
2305
+ includeTools: Type.Optional(Type.Boolean({ description: "Include registered tool names. Default false to avoid bloating footer discovery output." })),
2306
+ includeCommands: Type.Optional(Type.Boolean({ description: "Include command names. Default false to avoid bloating footer discovery output." })),
2307
+ includeSkills: Type.Optional(Type.Boolean({ description: "Include skill commands when includeCommands is true. Default false." })),
2308
+ includeDetails: Type.Optional(Type.Boolean({ description: "Include descriptions and sourceInfo for included tools/commands. Default false." })),
2309
+ }),
2310
+ async execute(_toolCallId, params) {
2311
+ const payload = footerSourceInventory(params as FooterSourceInventoryOptions);
2312
+ return {
2313
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
2314
+ details: payload,
2315
+ };
2316
+ },
2317
+ }),
2318
+ );
2319
+
2320
+ pi.registerTool(
2321
+ defineTool({
2322
+ name: "footer_framework_adapter_config",
2323
+ label: "Footer Framework Adapter Config",
2324
+ description: "List, set, or remove footer adapters that map existing Pi/extension data sources into framework-owned footer items",
2325
+ parameters: Type.Object({
2326
+ action: Type.String({ description: "One of: list, set, remove" }),
2327
+ id: Type.Optional(Type.String({ description: "Adapter id for set/remove" })),
2328
+ adapterJson: Type.Optional(Type.String({ description: "JSON adapter config for set. Required fields: source ('pi', 'extensionStatus', or 'sessionEntry') and key." })),
2329
+ }),
2330
+ async execute(_toolCallId, params) {
2331
+ const action = params.action.trim();
2332
+ let message: string;
2333
+ if (action === "list") {
2334
+ message = JSON.stringify(
2335
+ {
2336
+ defaultBuiltInAdapters: DEFAULT_BUILT_IN_ADAPTERS,
2337
+ adapters: settings.adapters,
2338
+ configuredAdapterRenderers: Object.fromEntries(Object.entries(configuredAdapterRenderers).map(([id, renderer]) => [id, { source: renderer.source }])),
2339
+ },
2340
+ null,
2341
+ 2,
2342
+ );
2343
+ } else if (action === "remove") {
2344
+ if (!params.id?.trim()) throw new Error("remove requires id");
2345
+ const id = params.id.trim();
2346
+ delete settings.adapters[id];
2347
+ if (DEFAULT_BUILT_IN_ADAPTERS[id]) (settings.items[id] ??= {}).visible = false;
2348
+ persistSettings();
2349
+ if (currentCtx) installFooter(currentCtx);
2350
+ message = DEFAULT_BUILT_IN_ADAPTERS[id] ? `Built-in item ${id} hidden.` : `Adapter ${id} removed.`;
2351
+ } else if (action === "set") {
2352
+ if (!params.id?.trim()) throw new Error("set requires id");
2353
+ if (!params.adapterJson?.trim()) throw new Error("set requires adapterJson");
2354
+ const adapter = normalizeAdapter(JSON.parse(params.adapterJson));
2355
+ if (!adapter) throw new Error("adapterJson must include valid source and key");
2356
+ settings.adapters[params.id.trim()] = adapter;
2357
+ persistSettings();
2358
+ if (currentCtx) installFooter(currentCtx);
2359
+ message = `Adapter ${params.id.trim()} saved.`;
2360
+ } else {
2361
+ throw new Error("action must be list, set, or remove");
2362
+ }
2363
+ const payload = { message, adapters: settings.adapters };
2364
+ return {
2365
+ content: [{ type: "text", text: message }],
2366
+ details: payload,
2367
+ };
2368
+ },
2369
+ }),
2370
+ );
2371
+
849
2372
  pi.registerTool(
850
2373
  defineTool({
851
2374
  name: "footer_framework_config",
@@ -854,11 +2377,11 @@ export default function footerFramework(pi: ExtensionAPI): void {
854
2377
  parameters: Type.Object({
855
2378
  command: Type.String({
856
2379
  description:
857
- "Same syntax as /footerfx, e.g. 'section context on', 'item context after stats', 'anchor all right', 'gap 1 10', 'save project', 'load', 'on', 'off', 'reset'",
2380
+ "Same syntax as /footerfx, e.g. 'section context on', 'item context line 3', 'item context after stats', 'anchor all right', 'gap 1 10', 'save project', 'load', 'on', 'off', 'reset'",
858
2381
  }),
859
2382
  }),
860
2383
  async execute(_toolCallId, params) {
861
- const message = applyFooterConfig(params.command, currentCtx);
2384
+ const message = await applyFooterConfig(params.command, currentCtx);
862
2385
  return {
863
2386
  content: [{ type: "text", text: message }],
864
2387
  details: { message, settings },
@@ -869,7 +2392,7 @@ export default function footerFramework(pi: ExtensionAPI): void {
869
2392
 
870
2393
  pi.on("session_start", async (_event, ctx) => {
871
2394
  currentCtx = ctx;
872
- loadSettings(ctx);
2395
+ await loadSettings(ctx);
873
2396
 
874
2397
  // Compatibility migration: if no config file exists yet, seed from the last
875
2398
  // session entry and immediately persist it as the user default.