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