@badliveware/pi-footer-framework 0.1.0 → 0.2.0

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