@badliveware/pi-footer-framework 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BadLiveware
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # pi-footer-framework
2
+
3
+ A configurable footer framework extension that intentionally owns/hijacks the footer and lets users control layout sections.
4
+
5
+ This is designed to pair with primitive-emitting extensions (for example `pr-upstream-status` via `pr-upstream:state`).
6
+
7
+ It ships with a bundled skill (`footer-framework-config`) and advertises it via package metadata + `resources_discover`, so Pi can apply footer tuning commands automatically when this extension is active.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pi install npm:@badliveware/pi-footer-framework
13
+ ```
14
+
15
+ For local testing from this repository:
16
+
17
+ ```bash
18
+ pi -e /path/to/pi/agent/extensions/public/footer-framework
19
+ ```
20
+
21
+ ## Behavior
22
+
23
+ - Replaces the default footer when enabled.
24
+ - Keeps a stable 2-line footer layout.
25
+ - Composes built-in footer items:
26
+ - `cwd`, `model`, `branch`, `stats`, `context`, `pr`, `ext`
27
+ - the `model` item includes the active thinking level as `<model-id>:<thinking-level>`
28
+ - the `context` item shows current context-window usage as percent plus humanized `tokens/max` counts, for example `ctx 52.2% 142K/272K`
29
+ - Supports extension-provided dynamic items via the event bus.
30
+ - Lets users position each item independently by line, left/right zone, relative order, or absolute column.
31
+
32
+ ## Persistence
33
+
34
+ Footer settings automatically persist to the user config file by default:
35
+
36
+ ```text
37
+ ~/.pi/agent/footer-framework.json
38
+ ```
39
+
40
+ If a project config exists, it overrides user settings for that project:
41
+
42
+ ```text
43
+ <project>/.pi/footer-framework.json
44
+ ```
45
+
46
+ Use `/footerfx save project` to intentionally create/update the project config.
47
+
48
+ ## Commands
49
+
50
+ - `/footerfx` — show current config and source
51
+ - `/footerfx config` — show user/project config paths and loaded source
52
+ - `/footerfx load` — reload user/project config files
53
+ - `/footerfx save user` — save current settings to user config
54
+ - `/footerfx save project` — save current settings to project config
55
+ - `/footerfx on` — enable framework footer
56
+ - `/footerfx off` — disable and restore default footer
57
+ - `/footerfx reset` — restore defaults and persist to user config
58
+ - `/footerfx section <cwd|stats|context|model|branch|pr|ext> <on|off>` — legacy section toggles
59
+ - `/footerfx item <id> <show|hide|reset>`
60
+ - `/footerfx item <id> line <1|2>`
61
+ - `/footerfx item <id> zone <left|right>`
62
+ - `/footerfx item <id> order <n>`
63
+ - `/footerfx item <id> before <other-id>` / `/footerfx item <id> after <other-id>`
64
+ - `/footerfx item <id> column <n|off>` — absolute column placement
65
+ - `/footerfx anchor <line1|line2|all> <gap|left|center|right|spread>` — line-level right-zone anchoring
66
+ - `/footerfx gap <min> <max>` — spacing controls used by `gap`/`center`/`left` modes
67
+ - `/footerfx branch-width <n>` — max branch label width
68
+ - `/footerfx mcp-zero <hide|show>` — hide/show `MCP: 0/x servers`
69
+ - `/footerfx-debug` — dump latest footer snapshot and settings
70
+ - includes per-line layout telemetry: left/right widths, pad width, right start/end columns, truncation
71
+
72
+ ## Extension item API
73
+
74
+ Other extensions can contribute footer items by emitting:
75
+
76
+ ```ts
77
+ pi.events.emit("footer-framework:item", {
78
+ id: "my-extension:status",
79
+ text: "cache warm",
80
+ placement: { line: 2, zone: "right", order: 50 }
81
+ });
82
+ ```
83
+
84
+ Remove an item:
85
+
86
+ ```ts
87
+ pi.events.emit("footer-framework:item", { id: "my-extension:status", remove: true });
88
+ ```
89
+
90
+ Users can then reposition the item with `/footerfx item my-extension:status ...` and those overrides persist automatically.
91
+
92
+ ## Agent automation primitives
93
+
94
+ The extension exposes tools so the agent can introspect and tune the footer without asking the user to run commands:
95
+
96
+ - `footer_framework_state` — returns settings + latest rendered footer snapshot + layout telemetry
97
+ - `footer_framework_config` — applies the same syntax as `/footerfx ...`
98
+
99
+ ## Notes
100
+
101
+ - The extension stores latest settings in session custom entries (`footer-framework-state`).
102
+ - It listens to event bus topic `pr-upstream:state` for PR primitives.
103
+ - Extension statuses with empty rendered text are ignored so transient or
104
+ intentionally-cleared status providers do not leave phantom separators in the
105
+ footer.
package/index.ts ADDED
@@ -0,0 +1,895 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Type } from "@mariozechner/pi-ai";
6
+ import { defineTool, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
7
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
8
+
9
+ type ChecksState = "pass" | "fail" | "running" | "unknown";
10
+ type FooterAnchorMode = "gap" | "left" | "center" | "right" | "spread";
11
+ type FooterLine = 1 | 2;
12
+ type FooterZone = "left" | "right";
13
+ type ConfigScope = "user" | "project";
14
+
15
+ interface FooterItemPlacement {
16
+ visible: boolean;
17
+ line: FooterLine;
18
+ zone: FooterZone;
19
+ order: number;
20
+ column?: number;
21
+ before?: string;
22
+ after?: string;
23
+ }
24
+
25
+ interface FooterItem {
26
+ id: string;
27
+ text: string;
28
+ placement: FooterItemPlacement;
29
+ }
30
+
31
+ interface ExternalFooterItemEvent {
32
+ id: string;
33
+ text?: string;
34
+ placement?: Partial<FooterItemPlacement>;
35
+ remove?: boolean;
36
+ }
37
+
38
+ interface PrState {
39
+ branch?: string;
40
+ error?: string;
41
+ autoSolveEnabled?: boolean;
42
+ pr?: {
43
+ number: number;
44
+ title: string;
45
+ url: string;
46
+ comments: number;
47
+ checks: ChecksState;
48
+ };
49
+ }
50
+
51
+ interface FooterFrameworkSettings {
52
+ enabled: boolean;
53
+ showCwd: boolean;
54
+ showStats: boolean;
55
+ showContext: boolean;
56
+ showModel: boolean;
57
+ showBranch: boolean;
58
+ showPr: boolean;
59
+ showExtensionStatuses: boolean;
60
+ hideZeroMcp: boolean;
61
+ line1Anchor: FooterAnchorMode;
62
+ line2Anchor: FooterAnchorMode;
63
+ branchMaxLength: number;
64
+ minGap: number;
65
+ maxGap: number;
66
+ items: Record<string, Partial<FooterItemPlacement>>;
67
+ }
68
+
69
+ const DEFAULT_SETTINGS: FooterFrameworkSettings = {
70
+ enabled: true,
71
+ showCwd: true,
72
+ showStats: true,
73
+ showContext: true,
74
+ showModel: true,
75
+ showBranch: true,
76
+ showPr: true,
77
+ showExtensionStatuses: true,
78
+ hideZeroMcp: true,
79
+ line1Anchor: "right",
80
+ line2Anchor: "right",
81
+ branchMaxLength: 22,
82
+ minGap: 2,
83
+ maxGap: 20,
84
+ items: {},
85
+ };
86
+
87
+ const ANCHOR_MODES: FooterAnchorMode[] = ["gap", "left", "center", "right", "spread"];
88
+ const CONFIG_FILE_NAME = "footer-framework.json";
89
+ const DEFAULT_ITEM_PLACEMENTS: Record<string, FooterItemPlacement> = {
90
+ cwd: { visible: true, line: 1, zone: "left", order: 10 },
91
+ model: { visible: true, line: 1, zone: "right", order: 10 },
92
+ branch: { visible: true, line: 1, zone: "right", order: 20 },
93
+ stats: { visible: true, line: 2, zone: "left", order: 10 },
94
+ context: { visible: true, line: 2, zone: "left", order: 20 },
95
+ pr: { visible: true, line: 2, zone: "right", order: 10 },
96
+ ext: { visible: true, line: 2, zone: "right", order: 20 },
97
+ };
98
+
99
+ function formatTokens(count: number): string {
100
+ if (count < 1_000) return `${count}`;
101
+ if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
102
+ if (count < 1_000_000) return `${Math.round(count / 1_000)}k`;
103
+ return `${(count / 1_000_000).toFixed(1)}M`;
104
+ }
105
+
106
+ function formatContextTokens(count: number): string {
107
+ if (count < 1_000) return `${Math.round(count)}`;
108
+ if (count < 1_000_000) return `${Math.round(count / 1_000)}K`;
109
+ return `${Math.round(count / 1_000_000)}M`;
110
+ }
111
+
112
+ function clamp(value: number, min: number, max: number): number {
113
+ return Math.max(min, Math.min(max, value));
114
+ }
115
+
116
+ function agentDir(): string {
117
+ return process.env.PI_CODING_AGENT_DIR ?? process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
118
+ }
119
+
120
+ function userConfigPath(): string {
121
+ return path.join(agentDir(), CONFIG_FILE_NAME);
122
+ }
123
+
124
+ function projectConfigPath(ctx: ExtensionContext): string {
125
+ return path.join(ctx.cwd, ".pi", CONFIG_FILE_NAME);
126
+ }
127
+
128
+ function normalizePlacement(input: Partial<FooterItemPlacement>): Partial<FooterItemPlacement> {
129
+ const placement: Partial<FooterItemPlacement> = {};
130
+ if (typeof input.visible === "boolean") placement.visible = input.visible;
131
+ if (input.line === 1 || input.line === 2) placement.line = input.line;
132
+ if (input.zone === "left" || input.zone === "right") placement.zone = input.zone;
133
+ if (Number.isFinite(input.order)) placement.order = Math.round(input.order as number);
134
+ if (Number.isFinite(input.column)) placement.column = clamp(Math.round(input.column as number), 0, 500);
135
+ if (typeof input.before === "string" && input.before.trim()) placement.before = input.before.trim();
136
+ if (typeof input.after === "string" && input.after.trim()) placement.after = input.after.trim();
137
+ return placement;
138
+ }
139
+
140
+ function normalizeSettings(input: Partial<FooterFrameworkSettings>): Partial<FooterFrameworkSettings> {
141
+ 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) {
153
+ if (typeof input[key] === "boolean") normalized[key] = input[key];
154
+ }
155
+ if (input.line1Anchor && ANCHOR_MODES.includes(input.line1Anchor)) normalized.line1Anchor = input.line1Anchor;
156
+ if (input.line2Anchor && ANCHOR_MODES.includes(input.line2Anchor)) normalized.line2Anchor = input.line2Anchor;
157
+ if (Number.isFinite(input.branchMaxLength)) normalized.branchMaxLength = clamp(Math.round(input.branchMaxLength as number), 10, 64);
158
+ if (Number.isFinite(input.minGap)) normalized.minGap = clamp(Math.round(input.minGap as number), 1, 12);
159
+ if (Number.isFinite(input.maxGap)) normalized.maxGap = clamp(Math.round(input.maxGap as number), normalized.minGap ?? 1, 40);
160
+ if (input.items && typeof input.items === "object") {
161
+ normalized.items = {};
162
+ for (const [id, placement] of Object.entries(input.items)) {
163
+ if (!id.trim() || !placement || typeof placement !== "object") continue;
164
+ normalized.items[id] = normalizePlacement(placement as Partial<FooterItemPlacement>);
165
+ }
166
+ }
167
+ return normalized;
168
+ }
169
+
170
+ function readConfigFile(filePath: string): Partial<FooterFrameworkSettings> | undefined {
171
+ try {
172
+ if (!fs.existsSync(filePath)) return undefined;
173
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Partial<FooterFrameworkSettings>;
174
+ return normalizeSettings(parsed);
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ function writeConfigFile(filePath: string, settings: FooterFrameworkSettings): void {
181
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
182
+ fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
183
+ }
184
+
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
+ function osc8(label: string, url: string): string {
192
+ return `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007`;
193
+ }
194
+
195
+ function sanitizeStatusText(text: string): string {
196
+ return text
197
+ .replace(/[\r\n\t]/g, " ")
198
+ .replace(/ +/g, " ")
199
+ .trim();
200
+ }
201
+
202
+ function parseSettingsInput(settings: FooterFrameworkSettings, args: string): string | undefined {
203
+ const tokens = args
204
+ .trim()
205
+ .split(/\s+/)
206
+ .filter(Boolean);
207
+ const [command, key, value] = tokens;
208
+
209
+ if (!command) return undefined;
210
+ if (command === "on") {
211
+ settings.enabled = true;
212
+ return "Footer framework enabled.";
213
+ }
214
+ if (command === "off") {
215
+ settings.enabled = false;
216
+ return "Footer framework disabled (default footer restored).";
217
+ }
218
+ if (command === "reset") {
219
+ Object.assign(settings, { ...DEFAULT_SETTINGS, items: { ...DEFAULT_SETTINGS.items } });
220
+ return "Footer framework reset to defaults.";
221
+ }
222
+ if (command === "section") {
223
+ if (!key || !value) return "Usage: /footerfx section <cwd|stats|context|model|branch|pr|ext> <on|off>";
224
+ const enabled = value === "on" || value === "enable" || value === "true";
225
+ if (!["on", "off", "enable", "disable", "true", "false"].includes(value)) {
226
+ return "Section value must be on/off.";
227
+ }
228
+ switch (key) {
229
+ case "cwd":
230
+ settings.showCwd = enabled;
231
+ return `Section cwd ${enabled ? "enabled" : "disabled"}.`;
232
+ case "stats":
233
+ settings.showStats = enabled;
234
+ return `Section stats ${enabled ? "enabled" : "disabled"}.`;
235
+ case "context":
236
+ settings.showContext = enabled;
237
+ return `Section context ${enabled ? "enabled" : "disabled"}.`;
238
+ case "model":
239
+ settings.showModel = enabled;
240
+ return `Section model ${enabled ? "enabled" : "disabled"}.`;
241
+ case "branch":
242
+ settings.showBranch = enabled;
243
+ return `Section branch ${enabled ? "enabled" : "disabled"}.`;
244
+ case "pr":
245
+ settings.showPr = enabled;
246
+ return `Section pr ${enabled ? "enabled" : "disabled"}.`;
247
+ case "ext":
248
+ settings.showExtensionStatuses = enabled;
249
+ return `Section ext ${enabled ? "enabled" : "disabled"}.`;
250
+ default:
251
+ return "Unknown section. Use: cwd|stats|context|model|branch|pr|ext";
252
+ }
253
+ }
254
+ if (command === "gap") {
255
+ if (!key || !value) return "Usage: /footerfx gap <min> <max>";
256
+ const min = Number(key);
257
+ const max = Number(value);
258
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return "gap values must be numbers.";
259
+ settings.minGap = clamp(Math.round(min), 1, 12);
260
+ settings.maxGap = clamp(Math.round(max), settings.minGap, 40);
261
+ return `Gap updated (min=${settings.minGap}, max=${settings.maxGap}).`;
262
+ }
263
+ if (command === "anchor") {
264
+ if (!key || !value) return "Usage: /footerfx anchor <line1|line2|all> <gap|left|center|right|spread>";
265
+ if (!ANCHOR_MODES.includes(value as FooterAnchorMode)) {
266
+ return "Anchor must be one of: gap, left, center, right, spread.";
267
+ }
268
+ const mode = value as FooterAnchorMode;
269
+ if (key === "line1") settings.line1Anchor = mode;
270
+ else if (key === "line2") settings.line2Anchor = mode;
271
+ else if (key === "all") {
272
+ settings.line1Anchor = mode;
273
+ settings.line2Anchor = mode;
274
+ } else {
275
+ return "Anchor target must be one of: line1, line2, all.";
276
+ }
277
+ return `Anchor ${key} set to ${mode}.`;
278
+ }
279
+ if (command === "branch-width") {
280
+ if (!key) return "Usage: /footerfx branch-width <n>";
281
+ const maxLength = Number(key);
282
+ if (!Number.isFinite(maxLength)) return "branch-width must be a number.";
283
+ settings.branchMaxLength = clamp(Math.round(maxLength), 10, 64);
284
+ return `Branch width max set to ${settings.branchMaxLength}.`;
285
+ }
286
+ if (command === "mcp-zero") {
287
+ if (!key || !["hide", "show"].includes(key)) return "Usage: /footerfx mcp-zero <hide|show>";
288
+ settings.hideZeroMcp = key === "hide";
289
+ return `MCP 0/x server line ${settings.hideZeroMcp ? "hidden" : "shown"}.`;
290
+ }
291
+ if (command === "item") {
292
+ const [id, action, arg] = tokens.slice(1);
293
+ if (!id || !action) {
294
+ return "Usage: /footerfx item <id> <show|hide|line|zone|order|column|before|after|reset> [value]";
295
+ }
296
+ const item = (settings.items[id] ??= {});
297
+ if (action === "show") {
298
+ item.visible = true;
299
+ return `Item ${id} shown.`;
300
+ }
301
+ if (action === "hide") {
302
+ item.visible = false;
303
+ return `Item ${id} hidden.`;
304
+ }
305
+ if (action === "reset") {
306
+ delete settings.items[id];
307
+ return `Item ${id} reset.`;
308
+ }
309
+ if (action === "line") {
310
+ const line = Number(arg);
311
+ if (line !== 1 && line !== 2) return "Item line must be 1 or 2.";
312
+ item.line = line;
313
+ return `Item ${id} moved to line ${line}.`;
314
+ }
315
+ if (action === "zone") {
316
+ if (arg !== "left" && arg !== "right") return "Item zone must be left or right.";
317
+ item.zone = arg;
318
+ return `Item ${id} moved to ${arg} zone.`;
319
+ }
320
+ if (action === "order") {
321
+ const order = Number(arg);
322
+ if (!Number.isFinite(order)) return "Item order must be a number.";
323
+ item.order = Math.round(order);
324
+ delete item.before;
325
+ delete item.after;
326
+ return `Item ${id} order set to ${item.order}.`;
327
+ }
328
+ if (action === "column") {
329
+ if (arg === "off" || arg === "auto") {
330
+ delete item.column;
331
+ return `Item ${id} absolute column disabled.`;
332
+ }
333
+ const column = Number(arg);
334
+ if (!Number.isFinite(column)) return "Item column must be a number, off, or auto.";
335
+ item.column = clamp(Math.round(column), 0, 500);
336
+ return `Item ${id} column set to ${item.column}.`;
337
+ }
338
+ if (action === "before" || action === "after") {
339
+ if (!arg) return `Usage: /footerfx item ${id} ${action} <other-item-id>`;
340
+ delete item.before;
341
+ delete item.after;
342
+ item[action] = arg;
343
+ return `Item ${id} positioned ${action} ${arg}.`;
344
+ }
345
+ return "Unknown item action. Use show|hide|line|zone|order|column|before|after|reset.";
346
+ }
347
+
348
+ return `Unknown command: ${command}`;
349
+ }
350
+
351
+ function settingsSummary(settings: FooterFrameworkSettings, loadedConfig?: string): string {
352
+ const customizedItems = Object.keys(settings.items).sort();
353
+ return [
354
+ loadedConfig ? `loaded=${loadedConfig}` : undefined,
355
+ `enabled=${settings.enabled}`,
356
+ `sections: cwd=${settings.showCwd}, stats=${settings.showStats}, context=${settings.showContext}, model=${settings.showModel}, branch=${settings.showBranch}, pr=${settings.showPr}, ext=${settings.showExtensionStatuses}`,
357
+ `anchor: line1=${settings.line1Anchor}, line2=${settings.line2Anchor}`,
358
+ `gap: min=${settings.minGap}, max=${settings.maxGap}`,
359
+ `branchMaxLength=${settings.branchMaxLength}`,
360
+ `hideZeroMcp=${settings.hideZeroMcp}`,
361
+ customizedItems.length ? `customizedItems=${customizedItems.join(",")}` : undefined,
362
+ ]
363
+ .filter(Boolean)
364
+ .join("\n");
365
+ }
366
+
367
+ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
368
+
369
+ export default function footerFramework(pi: ExtensionAPI): void {
370
+ const settings: FooterFrameworkSettings = { ...DEFAULT_SETTINGS, items: { ...DEFAULT_SETTINGS.items } };
371
+ let prState: PrState | undefined;
372
+ let currentCtx: ExtensionContext | undefined;
373
+ let requestRender: (() => void) | undefined;
374
+ const externalItems = new Map<string, { text: string; placement: Partial<FooterItemPlacement> }>();
375
+ let lastLoadedConfig = "defaults";
376
+ let lastFooterSnapshot:
377
+ | {
378
+ width: number;
379
+ line1: string;
380
+ line2: string;
381
+ line1Layout: {
382
+ anchor: FooterAnchorMode;
383
+ leftWidth: number;
384
+ rightWidthOriginal: number;
385
+ rightWidthFinal: number;
386
+ padCount: number;
387
+ rightStartCol: number;
388
+ rightEndCol: number;
389
+ truncated: boolean;
390
+ };
391
+ line2Layout: {
392
+ anchor: FooterAnchorMode;
393
+ leftWidth: number;
394
+ rightWidthOriginal: number;
395
+ rightWidthFinal: number;
396
+ padCount: number;
397
+ rightStartCol: number;
398
+ rightEndCol: number;
399
+ truncated: boolean;
400
+ };
401
+ gitBranch: string | null;
402
+ renderedItems: Array<{ id: string; line: FooterLine; zone: FooterZone; order: number; column?: number; width: number }>;
403
+ extensionStatuses: Array<{ key: string; value: string }>;
404
+ model: string;
405
+ contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } | null;
406
+ thinkingLevel: string;
407
+ cwd: string;
408
+ }
409
+ | undefined;
410
+
411
+ function applyValidatedSettings(input: Partial<FooterFrameworkSettings>): void {
412
+ Object.assign(settings, normalizeSettings(input));
413
+ settings.minGap = clamp(settings.minGap, 1, 12);
414
+ settings.maxGap = clamp(settings.maxGap, settings.minGap, 40);
415
+ settings.branchMaxLength = clamp(settings.branchMaxLength, 10, 64);
416
+ }
417
+
418
+ function saveSettings(scope: ConfigScope, ctx?: ExtensionContext): string {
419
+ if (scope === "project") {
420
+ if (!ctx) return "Cannot save project config before a session is active.";
421
+ const filePath = projectConfigPath(ctx);
422
+ writeConfigFile(filePath, settings);
423
+ lastLoadedConfig = `project:${filePath}`;
424
+ return `Saved project footer config: ${filePath}`;
425
+ }
426
+ const filePath = userConfigPath();
427
+ writeConfigFile(filePath, settings);
428
+ lastLoadedConfig = `user:${filePath}`;
429
+ return `Saved user footer config: ${filePath}`;
430
+ }
431
+
432
+ function persistSettings(): void {
433
+ saveSettings("user", currentCtx);
434
+ pi.appendEntry("footer-framework-state", settings);
435
+ }
436
+
437
+ function loadSettings(ctx: ExtensionContext): string {
438
+ Object.assign(settings, { ...DEFAULT_SETTINGS, items: { ...DEFAULT_SETTINGS.items } });
439
+ const userPath = userConfigPath();
440
+ const projectPath = projectConfigPath(ctx);
441
+ const userConfig = readConfigFile(userPath);
442
+ const projectConfig = readConfigFile(projectPath);
443
+
444
+ if (userConfig) applyValidatedSettings(userConfig);
445
+ if (projectConfig) applyValidatedSettings(projectConfig);
446
+
447
+ if (projectConfig) lastLoadedConfig = `project:${projectPath}`;
448
+ else if (userConfig) lastLoadedConfig = `user:${userPath}`;
449
+ else lastLoadedConfig = "defaults";
450
+ return lastLoadedConfig;
451
+ }
452
+
453
+ function renderCheck(theme: ExtensionContext["ui"]["theme"], checks: ChecksState): string {
454
+ if (checks === "pass") return theme.fg("success", "✅");
455
+ if (checks === "fail") return theme.fg("error", "❌");
456
+ if (checks === "running") return theme.fg("warning", "⏳");
457
+ return theme.fg("muted", "•");
458
+ }
459
+
460
+ function composeLine(
461
+ theme: ExtensionContext["ui"]["theme"],
462
+ width: number,
463
+ left: string,
464
+ right: string | undefined,
465
+ anchor: FooterAnchorMode,
466
+ ): {
467
+ 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
+ };
478
+ } {
479
+ const leftWidth = visibleWidth(left);
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(" ");
545
+ }
546
+
547
+ function renderModelLabel(): string {
548
+ const model = currentCtx?.model?.id ?? "no-model";
549
+ return `${model}:${pi.getThinkingLevel()}`;
550
+ }
551
+
552
+ function renderContextUsage(theme: ExtensionContext["ui"]["theme"]): string | undefined {
553
+ const usage = currentCtx?.getContextUsage();
554
+ const contextWindow = usage?.contextWindow ?? currentCtx?.model?.contextWindow;
555
+ if (!contextWindow) return undefined;
556
+
557
+ const tokens = usage?.tokens ?? null;
558
+ const percent = usage?.percent ?? null;
559
+ const percentText = percent === null ? "?%" : `${percent.toFixed(1)}%`;
560
+ const tokenText = tokens === null ? `?/${formatContextTokens(contextWindow)}` : `${formatContextTokens(tokens)}/${formatContextTokens(contextWindow)}`;
561
+ const text = `ctx ${percentText} ${tokenText}`;
562
+
563
+ if (percent !== null && percent > 90) return theme.fg("error", text);
564
+ if (percent !== null && percent > 70) return theme.fg("warning", text);
565
+ return theme.fg("dim", text);
566
+ }
567
+
568
+ function placementFor(id: string, fallback: FooterItemPlacement, external?: Partial<FooterItemPlacement>): FooterItemPlacement {
569
+ return {
570
+ ...fallback,
571
+ ...normalizePlacement(external ?? {}),
572
+ ...normalizePlacement(settings.items[id] ?? {}),
573
+ };
574
+ }
575
+
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
+ function resolveRelativeOrders(items: FooterItem[]): FooterItem[] {
592
+ const sorted = [...items].sort((a, b) => a.placement.order - b.placement.order || a.id.localeCompare(b.id));
593
+ for (const item of sorted) {
594
+ const before = item.placement.before;
595
+ const after = item.placement.after;
596
+ if (before) {
597
+ const target = sorted.find((candidate) => candidate.id === before);
598
+ if (target) item.placement.order = target.placement.order - 0.1;
599
+ }
600
+ if (after) {
601
+ const target = sorted.find((candidate) => candidate.id === after);
602
+ if (target) item.placement.order = target.placement.order + 0.1;
603
+ }
604
+ }
605
+ return sorted.sort((a, b) => a.placement.order - b.placement.order || a.id.localeCompare(b.id));
606
+ }
607
+
608
+ function overlayAbsoluteItems(theme: ExtensionContext["ui"]["theme"], width: number, line: string, items: FooterItem[]): string {
609
+ let out = line;
610
+ const sorted = items
611
+ .filter((item) => Number.isFinite(item.placement.column))
612
+ .sort((a, b) => (a.placement.column ?? 0) - (b.placement.column ?? 0) || a.placement.order - b.placement.order);
613
+ for (const item of sorted) {
614
+ const column = clamp(item.placement.column ?? 0, 0, width - 1);
615
+ const prefix = truncateToWidth(out, column, "");
616
+ const pad = " ".repeat(Math.max(0, column - visibleWidth(prefix)));
617
+ const available = Math.max(0, width - column);
618
+ const text = truncateToWidth(item.text, available, theme.fg("dim", "..."));
619
+ out = truncateToWidth(`${prefix}${pad}${text}`, width, theme.fg("dim", "..."));
620
+ }
621
+ return out;
622
+ }
623
+
624
+ function renderFooterLine(theme: ExtensionContext["ui"]["theme"], width: number, items: FooterItem[], line: FooterLine, anchor: FooterAnchorMode) {
625
+ const lineItems = items.filter((item) => item.placement.line === line);
626
+ const normalItems = lineItems.filter((item) => !Number.isFinite(item.placement.column));
627
+ const left = normalItems
628
+ .filter((item) => item.placement.zone === "left")
629
+ .sort((a, b) => a.placement.order - b.placement.order)
630
+ .map((item) => item.text)
631
+ .join(" · ");
632
+ const right = normalItems
633
+ .filter((item) => item.placement.zone === "right")
634
+ .sort((a, b) => a.placement.order - b.placement.order)
635
+ .map((item) => item.text)
636
+ .join(" · ");
637
+ const result = composeLine(theme, width, left || " ", right, anchor);
638
+ return { ...result, line: overlayAbsoluteItems(theme, width, result.line, lineItems) };
639
+ }
640
+
641
+ function collectItems(
642
+ theme: ExtensionContext["ui"]["theme"],
643
+ footerData: { getGitBranch(): string | null; getExtensionStatuses(): ReadonlyMap<string, string> },
644
+ statsText: string,
645
+ ): FooterItem[] {
646
+ const items: FooterItem[] = [];
647
+ items.push({ id: "cwd", text: theme.fg("dim", currentCtx?.cwd ?? ""), placement: placementFor("cwd", DEFAULT_ITEM_PLACEMENTS.cwd) });
648
+ items.push({ id: "model", text: theme.fg("dim", renderModelLabel()), placement: placementFor("model", DEFAULT_ITEM_PLACEMENTS.model) });
649
+ items.push({ id: "stats", text: statsText, placement: placementFor("stats", DEFAULT_ITEM_PLACEMENTS.stats) });
650
+ const contextUsageText = renderContextUsage(theme);
651
+ if (contextUsageText) items.push({ id: "context", text: contextUsageText, placement: placementFor("context", DEFAULT_ITEM_PLACEMENTS.context) });
652
+
653
+ const gitBranch = footerData.getGitBranch();
654
+ if (gitBranch) {
655
+ const compact = compactBranchName(gitBranch, settings.branchMaxLength);
656
+ const branchText = settings.showPr && prState?.pr && prState.branch === gitBranch
657
+ ? `${theme.fg("muted", `(${compact} `)}${osc8(theme.fg("accent", `#${prState.pr.number}`), prState.pr.url)}${theme.fg("muted", ")")}`
658
+ : theme.fg("muted", `(${compact})`);
659
+ items.push({ id: "branch", text: branchText, placement: placementFor("branch", DEFAULT_ITEM_PLACEMENTS.branch) });
660
+ }
661
+
662
+ const prStatus = renderPrStatus(theme);
663
+ if (prStatus) items.push({ id: "pr", text: prStatus, placement: placementFor("pr", DEFAULT_ITEM_PLACEMENTS.pr) });
664
+
665
+ const extStatuses = Array.from(footerData.getExtensionStatuses().entries())
666
+ .sort(([a], [b]) => a.localeCompare(b))
667
+ .flatMap(([key, value]) => {
668
+ const text = sanitizeStatusText(value);
669
+ if (key === "footer-framework" || key === "pr-upstream") return [];
670
+ if (visibleWidth(text) === 0) return [];
671
+ if (settings.hideZeroMcp && /MCP:\s*0\/\d+\s+servers/.test(text)) return [];
672
+ return [text];
673
+ })
674
+ .join(" · ");
675
+ if (extStatuses) items.push({ id: "ext", text: extStatuses, placement: placementFor("ext", DEFAULT_ITEM_PLACEMENTS.ext) });
676
+
677
+ for (const [id, external] of externalItems) {
678
+ if (!external.text) continue;
679
+ items.push({
680
+ id,
681
+ text: external.text,
682
+ placement: placementFor(id, { visible: true, line: 2, zone: "right", order: 100 }, external.placement),
683
+ });
684
+ }
685
+
686
+ applyLegacySectionVisibility(items);
687
+ return resolveRelativeOrders(items).filter((item) => item.placement.visible && item.text.length > 0);
688
+ }
689
+
690
+ function applyFooterConfig(input: string, ctx?: ExtensionContext): string {
691
+ try {
692
+ const trimmed = input.trim();
693
+ const [command, scope] = trimmed.split(/\s+/);
694
+ let message: string;
695
+ let shouldPersist = true;
696
+
697
+ if (command === "save") {
698
+ if (scope !== "user" && scope !== "project") return "Usage: /footerfx save <user|project>";
699
+ message = saveSettings(scope, ctx);
700
+ shouldPersist = false;
701
+ } else if (command === "load") {
702
+ if (!ctx) return "Cannot load footer config before a session is active.";
703
+ message = `Loaded footer config from ${loadSettings(ctx)}.`;
704
+ shouldPersist = false;
705
+ } else if (command === "config") {
706
+ message = [`Loaded: ${lastLoadedConfig}`, `User: ${userConfigPath()}`, ctx ? `Project: ${projectConfigPath(ctx)}` : "Project: unavailable before session"].join("\n");
707
+ shouldPersist = false;
708
+ } else {
709
+ message = parseSettingsInput(settings, trimmed) ?? settingsSummary(settings, lastLoadedConfig);
710
+ }
711
+
712
+ if (shouldPersist) persistSettings();
713
+ if (ctx) {
714
+ installFooter(ctx);
715
+ ctx.ui.setStatus("footer-framework", settings.enabled ? ctx.ui.theme.fg("muted", "footerfx:on") : undefined);
716
+ }
717
+ return message;
718
+ } catch (error) {
719
+ return `Footer config error: ${error instanceof Error ? error.message : "unknown error"}`;
720
+ }
721
+ }
722
+
723
+ function installFooter(ctx: ExtensionContext): void {
724
+ if (!settings.enabled) {
725
+ ctx.ui.setFooter(undefined);
726
+ return;
727
+ }
728
+
729
+ ctx.ui.setFooter((tui, theme, footerData) => {
730
+ requestRender = () => tui.requestRender();
731
+ const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
732
+
733
+ return {
734
+ dispose() {
735
+ requestRender = undefined;
736
+ unsubscribe();
737
+ },
738
+ invalidate() {},
739
+ render(width: number): string[] {
740
+ let input = 0;
741
+ let output = 0;
742
+ let cost = 0;
743
+ for (const entry of ctx.sessionManager.getEntries()) {
744
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
745
+ input += entry.message.usage.input;
746
+ output += entry.message.usage.output;
747
+ cost += entry.message.usage.cost.total;
748
+ }
749
+
750
+ const statsText = theme.fg("dim", `↑${formatTokens(input)} ↓${formatTokens(output)} $${cost.toFixed(3)}`);
751
+ const items = collectItems(theme, footerData, statsText);
752
+ const line1Result = renderFooterLine(theme, width, items, 1, settings.line1Anchor);
753
+ const line2Result = renderFooterLine(theme, width, items, 2, settings.line2Anchor);
754
+
755
+ lastFooterSnapshot = {
756
+ width,
757
+ line1: line1Result.line,
758
+ line2: line2Result.line,
759
+ line1Layout: line1Result.layout,
760
+ line2Layout: line2Result.layout,
761
+ gitBranch: footerData.getGitBranch(),
762
+ renderedItems: items.map((item) => ({
763
+ id: item.id,
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
+ })),
770
+ extensionStatuses: Array.from(footerData.getExtensionStatuses().entries()).map(([key, value]) => ({ key, value })),
771
+ model: ctx.model?.id ?? "no-model",
772
+ contextUsage: ctx.getContextUsage() ?? null,
773
+ thinkingLevel: pi.getThinkingLevel(),
774
+ cwd: ctx.cwd,
775
+ };
776
+ return [line1Result.line, line2Result.line];
777
+ },
778
+ };
779
+ });
780
+ }
781
+
782
+ pi.on("resources_discover", async () => {
783
+ return { skillPaths: [path.join(extensionDir, "skills")] };
784
+ });
785
+
786
+ pi.events.on("pr-upstream:state", (event) => {
787
+ prState = event as PrState;
788
+ requestRender?.();
789
+ });
790
+
791
+ pi.events.on("footer-framework:item", (event) => {
792
+ const item = event as ExternalFooterItemEvent;
793
+ if (!item.id?.trim()) return;
794
+ if (item.remove) externalItems.delete(item.id);
795
+ else if (typeof item.text === "string") {
796
+ externalItems.set(item.id, { text: item.text, placement: normalizePlacement(item.placement ?? {}) });
797
+ }
798
+ requestRender?.();
799
+ });
800
+
801
+ pi.registerCommand("footerfx", {
802
+ description: "Footer framework controls (on/off, section, gap, branch-width, mcp-zero, reset)",
803
+ handler: async (args, ctx) => {
804
+ const trimmed = args.trim();
805
+ if (!trimmed) {
806
+ ctx.ui.notify(settingsSummary(settings, lastLoadedConfig), "info");
807
+ return;
808
+ }
809
+ ctx.ui.notify(applyFooterConfig(trimmed, ctx), "info");
810
+ },
811
+ });
812
+
813
+ pi.registerCommand("footerfx-debug", {
814
+ description: "Show latest footer render snapshot and framework state",
815
+ handler: async (_args, ctx) => {
816
+ const payload = {
817
+ settings,
818
+ loadedConfig: lastLoadedConfig,
819
+ configPaths: { user: userConfigPath(), project: projectConfigPath(ctx) },
820
+ prState,
821
+ lastFooterSnapshot,
822
+ };
823
+ ctx.ui.notify(JSON.stringify(payload, null, 2), "info");
824
+ },
825
+ });
826
+
827
+ pi.registerTool(
828
+ defineTool({
829
+ name: "footer_framework_state",
830
+ label: "Footer Framework State",
831
+ description: "Get footer framework settings and latest rendered footer snapshot for autonomous tuning",
832
+ parameters: Type.Object({}),
833
+ async execute() {
834
+ const payload = {
835
+ settings,
836
+ loadedConfig: lastLoadedConfig,
837
+ configPaths: currentCtx ? { user: userConfigPath(), project: projectConfigPath(currentCtx) } : { user: userConfigPath() },
838
+ prState,
839
+ lastFooterSnapshot,
840
+ };
841
+ return {
842
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
843
+ details: payload,
844
+ };
845
+ },
846
+ }),
847
+ );
848
+
849
+ pi.registerTool(
850
+ defineTool({
851
+ name: "footer_framework_config",
852
+ label: "Footer Framework Config",
853
+ description: "Adjust footer framework settings without user command loop",
854
+ parameters: Type.Object({
855
+ command: Type.String({
856
+ 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'",
858
+ }),
859
+ }),
860
+ async execute(_toolCallId, params) {
861
+ const message = applyFooterConfig(params.command, currentCtx);
862
+ return {
863
+ content: [{ type: "text", text: message }],
864
+ details: { message, settings },
865
+ };
866
+ },
867
+ }),
868
+ );
869
+
870
+ pi.on("session_start", async (_event, ctx) => {
871
+ currentCtx = ctx;
872
+ loadSettings(ctx);
873
+
874
+ // Compatibility migration: if no config file exists yet, seed from the last
875
+ // session entry and immediately persist it as the user default.
876
+ if (lastLoadedConfig === "defaults") {
877
+ const persisted = ctx.sessionManager
878
+ .getEntries()
879
+ .filter((entry) => entry.type === "custom" && entry.customType === "footer-framework-state")
880
+ .pop() as { data?: Partial<FooterFrameworkSettings> } | undefined;
881
+ if (persisted?.data) {
882
+ applyValidatedSettings(persisted.data);
883
+ persistSettings();
884
+ }
885
+ }
886
+
887
+ installFooter(ctx);
888
+ ctx.ui.setStatus("footer-framework", settings.enabled ? ctx.ui.theme.fg("muted", "footerfx:on") : undefined);
889
+ });
890
+
891
+ pi.on("session_shutdown", async () => {
892
+ requestRender = undefined;
893
+ currentCtx = undefined;
894
+ });
895
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@badliveware/pi-footer-framework",
3
+ "version": "0.1.0",
4
+ "description": "Configurable footer framework extension for Pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "footer",
10
+ "ui"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "BadLiveware",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/BadLiveware/pi.git",
17
+ "directory": "agent/extensions/public/footer-framework"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/BadLiveware/pi/issues"
21
+ },
22
+ "homepage": "https://github.com/BadLiveware/pi/tree/main/agent/extensions/public/footer-framework#readme",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "files": [
27
+ "README.md",
28
+ "LICENSE",
29
+ "index.ts",
30
+ "skills",
31
+ "package.json"
32
+ ],
33
+ "pi": {
34
+ "extensions": [
35
+ "./index.ts"
36
+ ],
37
+ "skills": [
38
+ "./skills"
39
+ ]
40
+ },
41
+ "peerDependencies": {
42
+ "@mariozechner/pi-ai": "*",
43
+ "@mariozechner/pi-coding-agent": "*",
44
+ "@mariozechner/pi-tui": "*"
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ }
49
+ }
@@ -0,0 +1,67 @@
1
+ ---
2
+ name: footer-framework-config
3
+ description: Use when the user wants to configure, tune, or troubleshoot the footer-framework extension layout/spacing/sections.
4
+ ---
5
+
6
+ # Footer Framework Config
7
+
8
+ Use this skill when a user wants footer layout changes without editing extension source.
9
+
10
+ ## Reach for This Skill When
11
+ - the user asks to reduce footer clutter, spacing, or jitter
12
+ - the user wants specific footer sections on/off
13
+ - the user wants model/branch/PR placement tuned
14
+ - the user wants default Pi footer restored quickly
15
+
16
+ ## Commands Reference
17
+ - `/footerfx` — show current config
18
+ - `/footerfx on` — enable footer framework
19
+ - `/footerfx off` — restore default footer
20
+ - `/footerfx reset` — reset to defaults and persist to user config
21
+ - `/footerfx config` — show loaded source and config paths
22
+ - `/footerfx load` — reload user/project config files
23
+ - `/footerfx save user` — save current settings as user default
24
+ - `/footerfx save project` — save current settings for the current project
25
+ - `/footerfx section <cwd|stats|context|model|branch|pr|ext> <on|off>`
26
+ - `/footerfx item <id> <show|hide|reset>`
27
+ - `/footerfx item <id> line <1|2>`
28
+ - `/footerfx item <id> zone <left|right>`
29
+ - `/footerfx item <id> order <n>`
30
+ - `/footerfx item <id> before <other-id>` / `/footerfx item <id> after <other-id>`
31
+ - `/footerfx item <id> column <n|off>`
32
+ - `/footerfx anchor <line1|line2|all> <gap|left|center|right|spread>`
33
+ - `/footerfx gap <min> <max>`
34
+ - `/footerfx branch-width <n>`
35
+ - `/footerfx mcp-zero <hide|show>`
36
+
37
+ ## Workflow
38
+ 1. Read current state with `/footerfx`.
39
+ 2. Apply one focused change at a time (item placement, section, anchor, gap, branch width).
40
+ 3. Changes persist automatically to the user config; use `/footerfx save project` only when the user explicitly wants project-specific layout.
41
+ 4. Prefer minimal-density defaults:
42
+ - keep `cwd`, `stats`, `context`, `model`, `branch` on
43
+ - show `pr` when relevant
44
+ - hide noisy zero-state indicators (`mcp-zero hide`)
45
+ 5. If the user dislikes custom-footer behavior, run `/footerfx off`.
46
+
47
+ ## Presets
48
+ ### Compact
49
+ - `/footerfx anchor all left`
50
+ - `/footerfx gap 1 8`
51
+ - `/footerfx branch-width 18`
52
+ - `/footerfx section ext off`
53
+
54
+ ### Balanced
55
+ - `/footerfx anchor line1 right`
56
+ - `/footerfx anchor line2 right`
57
+ - `/footerfx item model line 1`
58
+ - `/footerfx item model zone right`
59
+ - `/footerfx item model before branch`
60
+ - `/footerfx item ext line 2`
61
+ - `/footerfx item ext zone right`
62
+ - `/footerfx gap 2 16`
63
+ - `/footerfx branch-width 22`
64
+ - `/footerfx section ext on`
65
+
66
+ ### Default Pi feel
67
+ - `/footerfx off`