@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 +21 -0
- package/README.md +105 -0
- package/index.ts +895 -0
- package/package.json +49 -0
- package/skills/footer-framework-config/SKILL.md +67 -0
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`
|