@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/README.md +297 -63
- package/assets/footer-framework-showcase.png +0 -0
- package/examples/footer-framework.config.ts +115 -0
- package/index.ts +1560 -266
- package/package.json +4 -1
- package/skills/footer-framework-config/SKILL.md +45 -15
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 =
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
156
|
-
if (input.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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<
|
|
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,
|
|
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
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
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 =
|
|
260
|
-
settings.maxGap =
|
|
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 <
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
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 =
|
|
311
|
-
if (line
|
|
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}
|
|
1272
|
+
return `Item ${id} column disabled.`;
|
|
332
1273
|
}
|
|
333
|
-
const column =
|
|
334
|
-
if (
|
|
335
|
-
item.column =
|
|
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
|
-
`
|
|
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 =
|
|
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,
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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<
|
|
1389
|
+
function applyValidatedSettings(input: Partial<FooterFrameworkConfig>): void {
|
|
412
1390
|
Object.assign(settings, normalizeSettings(input));
|
|
413
|
-
settings.minGap =
|
|
414
|
-
settings.maxGap =
|
|
415
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
if (projectConfig)
|
|
1469
|
+
const projectConfig = readConfigFile(projectPath);
|
|
1470
|
+
if (projectConfig) {
|
|
1471
|
+
applyValidatedSettings(projectConfig);
|
|
1472
|
+
loaded.push(`project:${projectPath}`);
|
|
1473
|
+
}
|
|
446
1474
|
|
|
447
|
-
|
|
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
|
|
454
|
-
if (checks === "pass") return
|
|
455
|
-
if (checks === "fail") return
|
|
456
|
-
if (checks === "running") return
|
|
457
|
-
return
|
|
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
|
|
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
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
.
|
|
612
|
-
.
|
|
613
|
-
|
|
614
|
-
|
|
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) =>
|
|
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
|
|
642
|
-
|
|
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
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
663
|
-
|
|
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
|
-
|
|
1806
|
+
const text = renderExternalItem(theme, external);
|
|
1807
|
+
if (!text) continue;
|
|
679
1808
|
items.push({
|
|
680
1809
|
id,
|
|
681
|
-
text
|
|
682
|
-
|
|
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
|
|
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}`, `
|
|
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
|
-
|
|
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
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
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
|
-
|
|
794
|
-
if (
|
|
795
|
-
|
|
796
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|