@badliveware/pi-footer-framework 0.2.0 → 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/index.ts +322 -93
- package/package.json +2 -1
- package/skills/footer-framework-config/SKILL.md +5 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
- Fixed footer rendering with cell-buffer composition so ANSI styling, OSC8 hyperlinks, grapheme clusters, wide characters, and overlays preserve terminal cell alignment.
|
|
6
|
+
- Improved diagnostics for rendered footer layout and right/center overlay behavior.
|
|
7
|
+
- Hardened footer line clearing so overwriting wide-character runs does not leave stale continuation cells.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
- Added TypeScript render config support.
|
|
12
|
+
- Simplified footer framework configuration and generalized framework-owned layout behavior.
|
|
13
|
+
- Added adapter templates and built-in footer data source adaptation.
|
|
14
|
+
|
|
15
|
+
## 0.1.1
|
|
16
|
+
|
|
17
|
+
- Initial public package release.
|
package/index.ts
CHANGED
|
@@ -52,6 +52,266 @@ interface FooterItem {
|
|
|
52
52
|
renderSource?: "template" | "function" | "external";
|
|
53
53
|
}
|
|
54
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
|
+
|
|
55
315
|
export interface FooterSpan {
|
|
56
316
|
text: unknown;
|
|
57
317
|
style?: string;
|
|
@@ -130,15 +390,27 @@ interface ExternalFooterItem {
|
|
|
130
390
|
hint: FooterItemDisplayHint;
|
|
131
391
|
}
|
|
132
392
|
|
|
393
|
+
interface FooterRenderDiagnostic {
|
|
394
|
+
itemId: string;
|
|
395
|
+
line: FooterLine;
|
|
396
|
+
severity: "warning";
|
|
397
|
+
message: string;
|
|
398
|
+
itemPlainText: string;
|
|
399
|
+
linePlainText?: string;
|
|
400
|
+
}
|
|
401
|
+
|
|
133
402
|
interface FooterSnapshot {
|
|
134
403
|
width: number;
|
|
135
|
-
lines: Array<{ line: FooterLine; text: string; layout: FooterLineLayout }>;
|
|
404
|
+
lines: Array<{ line: FooterLine; text: string; plainText: string; layout: FooterLineLayout }>;
|
|
136
405
|
line1: string;
|
|
137
406
|
line2: string;
|
|
407
|
+
line1PlainText: string;
|
|
408
|
+
line2PlainText: string;
|
|
138
409
|
line1Layout: FooterLineLayout;
|
|
139
410
|
line2Layout: FooterLineLayout;
|
|
140
411
|
gitBranch: string | null;
|
|
141
|
-
renderedItems: Array<{ id: string; line: FooterLine; zone: FooterZone; order: number; column?: FooterColumn; width: number; renderSource?: string; tokens?: FooterRenderedToken[] }>;
|
|
412
|
+
renderedItems: Array<{ id: string; line: FooterLine; zone: FooterZone; order: number; column?: FooterColumn; width: number; plainText: string; renderSource?: string; tokens?: FooterRenderedToken[] }>;
|
|
413
|
+
renderDiagnostics: FooterRenderDiagnostic[];
|
|
142
414
|
extensionStatuses: Array<{ key: string; value: string }>;
|
|
143
415
|
model: string;
|
|
144
416
|
contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } | null;
|
|
@@ -1498,66 +1770,9 @@ export default function footerFramework(pi: ExtensionAPI): void {
|
|
|
1498
1770
|
anchor: FooterAnchorMode,
|
|
1499
1771
|
): {
|
|
1500
1772
|
line: string;
|
|
1501
|
-
layout:
|
|
1502
|
-
anchor: FooterAnchorMode;
|
|
1503
|
-
leftWidth: number;
|
|
1504
|
-
rightWidthOriginal: number;
|
|
1505
|
-
rightWidthFinal: number;
|
|
1506
|
-
padCount: number;
|
|
1507
|
-
rightStartCol: number;
|
|
1508
|
-
rightEndCol: number;
|
|
1509
|
-
truncated: boolean;
|
|
1510
|
-
};
|
|
1773
|
+
layout: FooterLineLayout;
|
|
1511
1774
|
} {
|
|
1512
|
-
|
|
1513
|
-
if (!right || visibleWidth(right) === 0) {
|
|
1514
|
-
return {
|
|
1515
|
-
line: truncateToWidth(left, width, theme.fg("dim", "...")),
|
|
1516
|
-
layout: {
|
|
1517
|
-
anchor,
|
|
1518
|
-
leftWidth,
|
|
1519
|
-
rightWidthOriginal: 0,
|
|
1520
|
-
rightWidthFinal: 0,
|
|
1521
|
-
padCount: 0,
|
|
1522
|
-
rightStartCol: leftWidth,
|
|
1523
|
-
rightEndCol: leftWidth,
|
|
1524
|
-
truncated: false,
|
|
1525
|
-
},
|
|
1526
|
-
};
|
|
1527
|
-
}
|
|
1528
|
-
const rightWidthOriginal = visibleWidth(right);
|
|
1529
|
-
const naturalPad = width - leftWidth - rightWidthOriginal;
|
|
1530
|
-
let padCount = settings.minGap;
|
|
1531
|
-
if (anchor === "right" || anchor === "spread") {
|
|
1532
|
-
padCount = Math.max(settings.minGap, naturalPad);
|
|
1533
|
-
} else if (anchor === "center") {
|
|
1534
|
-
padCount = Math.max(settings.minGap, Math.floor(naturalPad / 2));
|
|
1535
|
-
padCount = Math.min(padCount, settings.maxGap);
|
|
1536
|
-
} else if (anchor === "gap") {
|
|
1537
|
-
padCount = Math.max(settings.minGap, Math.min(naturalPad, settings.maxGap));
|
|
1538
|
-
} else if (anchor === "left") {
|
|
1539
|
-
padCount = settings.minGap;
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
const availableForRight = Math.max(0, width - leftWidth - padCount);
|
|
1543
|
-
const compactRight = truncateToWidth(right, availableForRight, theme.fg("dim", "..."));
|
|
1544
|
-
const rightWidthFinal = visibleWidth(compactRight);
|
|
1545
|
-
const line = truncateToWidth(`${left}${" ".repeat(padCount)}${compactRight}`, width, theme.fg("dim", "..."));
|
|
1546
|
-
const rightStartCol = leftWidth + padCount;
|
|
1547
|
-
const rightEndCol = Math.max(rightStartCol, rightStartCol + rightWidthFinal - 1);
|
|
1548
|
-
return {
|
|
1549
|
-
line,
|
|
1550
|
-
layout: {
|
|
1551
|
-
anchor,
|
|
1552
|
-
leftWidth,
|
|
1553
|
-
rightWidthOriginal,
|
|
1554
|
-
rightWidthFinal,
|
|
1555
|
-
padCount,
|
|
1556
|
-
rightStartCol,
|
|
1557
|
-
rightEndCol,
|
|
1558
|
-
truncated: rightWidthFinal < rightWidthOriginal,
|
|
1559
|
-
},
|
|
1560
|
-
};
|
|
1775
|
+
return composeFooterLine({ width, left, right, anchor, minGap: settings.minGap, maxGap: settings.maxGap, ellipsis: theme.fg("dim", "...") });
|
|
1561
1776
|
}
|
|
1562
1777
|
|
|
1563
1778
|
function renderModelLabel(): string {
|
|
@@ -1608,30 +1823,39 @@ export default function footerFramework(pi: ExtensionAPI): void {
|
|
|
1608
1823
|
return sorted.sort((a, b) => a.placement.order - b.placement.order || a.id.localeCompare(b.id));
|
|
1609
1824
|
}
|
|
1610
1825
|
|
|
1611
|
-
function
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
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
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
return diagnostics;
|
|
1619
1855
|
}
|
|
1620
1856
|
|
|
1621
1857
|
function overlayAbsoluteItems(theme: ExtensionContext["ui"]["theme"], width: number, line: string, items: FooterItem[]): string {
|
|
1622
|
-
|
|
1623
|
-
const sorted = items
|
|
1624
|
-
.map((item) => ({ item, column: resolveOverlayColumn(item.placement.column, width, visibleWidth(item.text)) }))
|
|
1625
|
-
.filter((entry): entry is { item: FooterItem; column: number } => entry.column !== undefined)
|
|
1626
|
-
.sort((a, b) => a.column - b.column || a.item.placement.order - b.item.placement.order);
|
|
1627
|
-
for (const { item, column } of sorted) {
|
|
1628
|
-
const prefix = truncateToWidth(out, column, "");
|
|
1629
|
-
const pad = " ".repeat(Math.max(0, column - visibleWidth(prefix)));
|
|
1630
|
-
const available = Math.max(0, width - column);
|
|
1631
|
-
const text = truncateToWidth(item.text, available, theme.fg("dim", "..."));
|
|
1632
|
-
out = truncateToWidth(`${prefix}${pad}${text}`, width, theme.fg("dim", "..."));
|
|
1633
|
-
}
|
|
1634
|
-
return out;
|
|
1858
|
+
return overlayFooterColumnItems(width, line, items, theme.fg("dim", "..."));
|
|
1635
1859
|
}
|
|
1636
1860
|
|
|
1637
1861
|
function renderFooterLine(theme: ExtensionContext["ui"]["theme"], width: number, items: FooterItem[], line: FooterLine, anchor: FooterAnchorMode) {
|
|
@@ -1951,29 +2175,34 @@ export default function footerFramework(pi: ExtensionAPI): void {
|
|
|
1951
2175
|
const lineResults = Array.from({ length: maxLine }, (_, index) => {
|
|
1952
2176
|
const line = index + 1;
|
|
1953
2177
|
const result = renderFooterLine(theme, width, items, line, getLineAnchor(settings, line));
|
|
1954
|
-
return { line, text: result.line, layout: result.layout };
|
|
2178
|
+
return { line, text: result.line, plainText: plainFooterText(result.line), layout: result.layout };
|
|
1955
2179
|
});
|
|
1956
2180
|
const line1Result = lineResults[0];
|
|
1957
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
|
+
}));
|
|
1958
2193
|
|
|
1959
2194
|
lastFooterSnapshot = {
|
|
1960
2195
|
width,
|
|
1961
2196
|
lines: lineResults,
|
|
1962
2197
|
line1: line1Result?.text ?? "",
|
|
1963
2198
|
line2: line2Result?.text ?? "",
|
|
2199
|
+
line1PlainText: line1Result?.plainText ?? "",
|
|
2200
|
+
line2PlainText: line2Result?.plainText ?? "",
|
|
1964
2201
|
line1Layout: line1Result?.layout ?? renderFooterLine(theme, width, [], 1, getLineAnchor(settings, 1)).layout,
|
|
1965
2202
|
line2Layout: line2Result?.layout ?? renderFooterLine(theme, width, [], 2, getLineAnchor(settings, 2)).layout,
|
|
1966
2203
|
gitBranch: footerData.getGitBranch(),
|
|
1967
|
-
renderedItems
|
|
1968
|
-
|
|
1969
|
-
line: item.placement.line,
|
|
1970
|
-
zone: item.placement.zone,
|
|
1971
|
-
order: item.placement.order,
|
|
1972
|
-
column: item.placement.column,
|
|
1973
|
-
width: visibleWidth(item.text),
|
|
1974
|
-
renderSource: item.renderSource,
|
|
1975
|
-
tokens: item.tokens,
|
|
1976
|
-
})),
|
|
2204
|
+
renderedItems,
|
|
2205
|
+
renderDiagnostics: renderVisibilityDiagnostics(items, lineResults),
|
|
1977
2206
|
extensionStatuses: Array.from(footerData.getExtensionStatuses().entries()).map(([key, value]) => ({ key, value })),
|
|
1978
2207
|
model: ctx.model?.id ?? "no-model",
|
|
1979
2208
|
contextUsage: ctx.getContextUsage() ?? null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@badliveware/pi-footer-framework",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Configurable footer framework extension for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.ts",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"README.md",
|
|
29
|
+
"CHANGELOG.md",
|
|
29
30
|
"LICENSE",
|
|
30
31
|
"assets",
|
|
31
32
|
"examples",
|
|
@@ -64,13 +64,14 @@ Use this skill when a user wants footer layout changes without editing extension
|
|
|
64
64
|
4. Use templates when the user wants portable JSON config or simple token-level styling, such as `{{ pi.cwd | compactPath: 48, 2 | style: "dim" }} {{ pi.branch.label | default: "" | truncate: 22 | style: "accent" }}`.
|
|
65
65
|
5. Use `~/.pi/agent/footer-framework.config.ts` or `<project>/.pi/footer-framework.config.ts` when normal TS helper functions are clearer; commands and tools should keep writing JSON overrides rather than rewriting TS source unless the user explicitly asks.
|
|
66
66
|
6. Check `footer_framework_state` or `/footerfx-debug` for template/render diagnostics after changing templates or TS render closures.
|
|
67
|
-
7.
|
|
68
|
-
8.
|
|
69
|
-
9.
|
|
67
|
+
7. After any layout or visibility change, compare `lastFooterSnapshot.renderedItems[].plainText` with `lastFooterSnapshot.lines[].plainText` and read `lastFooterSnapshot.renderDiagnostics`. If an item exists in `renderedItems` but its text is missing from the final line, treat it as a layout/rendering bug or overlap/truncation issue, not as proof that more placement tweaks are needed.
|
|
68
|
+
8. Apply one focused change at a time (adapter, template/render closure, item placement, section, anchor, or gap).
|
|
69
|
+
9. Changes made through commands/tools persist automatically to the user JSON config; use `/footerfx save project` only when the user explicitly wants project-specific layout.
|
|
70
|
+
10. Prefer minimal-density defaults:
|
|
70
71
|
- keep `cwd`, `stats`, `context`, `model`, `branch` on
|
|
71
72
|
- show `pr` when relevant
|
|
72
73
|
- adapt only the extension statuses the user wants instead of relying on the generic `ext` bucket
|
|
73
|
-
|
|
74
|
+
11. If the user dislikes custom-footer behavior, run `/footerfx off`.
|
|
74
75
|
|
|
75
76
|
## Presets
|
|
76
77
|
### Compact
|