@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 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
- const leftWidth = visibleWidth(left);
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 resolveOverlayColumn(column: FooterColumn | undefined, width: number, itemWidth: number): number | undefined {
1612
- if (column === undefined) return undefined;
1613
- if (typeof column === "number") return clamp(column, 0, Math.max(0, width - 1));
1614
- if (column === "center" || column === "middle") return clamp(Math.round((width - itemWidth) / 2), 0, Math.max(0, width - 1));
1615
- const percent = Number(column.slice(0, -1));
1616
- if (!Number.isFinite(percent)) return undefined;
1617
- const target = Math.round((width - 1) * (percent / 100));
1618
- return clamp(Math.round(target - itemWidth / 2), 0, Math.max(0, width - 1));
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
- let out = line;
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: items.map((item) => ({
1968
- id: item.id,
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.0",
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. Apply one focused change at a time (adapter, template/render closure, item placement, section, anchor, or gap).
68
- 8. 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.
69
- 9. Prefer minimal-density defaults:
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
- 10. If the user dislikes custom-footer behavior, run `/footerfx off`.
74
+ 11. If the user dislikes custom-footer behavior, run `/footerfx off`.
74
75
 
75
76
  ## Presets
76
77
  ### Compact