@neilurk12/pi-clean-footer 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/README.md +140 -0
- package/docs/superpowers/plans/2026-05-09-clean-footer-extension.md +546 -0
- package/docs/superpowers/specs/2026-05-09-clean-footer-extension-design.md +149 -0
- package/example.png +0 -0
- package/package.json +23 -0
- package/src/index.ts +595 -0
- package/tok +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# pi-clean-footer
|
|
2
|
+
|
|
3
|
+
Clean adaptive footer extension for [pi](https://pi.dev).
|
|
4
|
+
|
|
5
|
+
Shows a compact split footer:
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Smart short model names, plus thinking effort (`low`, `med`, `high`, `xhigh`)
|
|
12
|
+
- Current directory basename only
|
|
13
|
+
- Git branch + dirty file count, including untracked files
|
|
14
|
+
- Event-driven git refresh after file-changing tools and user bash commands
|
|
15
|
+
- Context usage as `used/max`
|
|
16
|
+
- Cumulative active-branch token totals: input, output, total, cache read, cache write
|
|
17
|
+
- Adaptive width tiers for narrow terminals
|
|
18
|
+
- `/footer` toggle
|
|
19
|
+
- `/footer refresh` force refresh
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
From local checkout:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install /absolute/path/to/pi-clean-footer
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For project-local install, run from your project:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi install -l /absolute/path/to/pi-clean-footer
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For quick testing without installing:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi -e /absolute/path/to/pi-clean-footer
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then reload pi resources:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
/reload
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
Toggle footer:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
/footer
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Force git refresh:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
/footer refresh
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Show active config paths and resolved config:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
/footer config
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Reload config after editing JSON:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
/footer reload
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Config is optional. Defaults match the built-in package behavior.
|
|
76
|
+
|
|
77
|
+
Load order:
|
|
78
|
+
|
|
79
|
+
1. Global: `~/.pi/agent/clean-footer.json`
|
|
80
|
+
2. Project: `.pi/clean-footer.json`
|
|
81
|
+
|
|
82
|
+
Project config overrides global config. Nested `modelAliases` and `colors` are merged.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"enabled": true,
|
|
89
|
+
"showGit": true,
|
|
90
|
+
"showTokens": true,
|
|
91
|
+
"showCache": false,
|
|
92
|
+
"showContext": true,
|
|
93
|
+
"showDirectory": true,
|
|
94
|
+
"showEffort": true,
|
|
95
|
+
"gitRefreshDebounceMs": 500,
|
|
96
|
+
"contextWarningPercent": 70,
|
|
97
|
+
"contextDangerPercent": 85,
|
|
98
|
+
"modelAliases": {
|
|
99
|
+
"claude-sonnet-4-5-20250929": "sonnet-4.5",
|
|
100
|
+
"gpt-5.5-codex": "gpt-5.5"
|
|
101
|
+
},
|
|
102
|
+
"colors": {
|
|
103
|
+
"model": "accent",
|
|
104
|
+
"directory": "dim",
|
|
105
|
+
"git": "success",
|
|
106
|
+
"gitDirty": "warning",
|
|
107
|
+
"contextNormal": "success",
|
|
108
|
+
"contextWarning": "warning",
|
|
109
|
+
"contextDanger": "error",
|
|
110
|
+
"tokens": "muted",
|
|
111
|
+
"separator": "dim"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Malformed JSON keeps defaults/last loaded behavior and reports an error through `/footer config` or at startup.
|
|
117
|
+
|
|
118
|
+
## Package manifest
|
|
119
|
+
|
|
120
|
+
This package declares its extension through `package.json`:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"pi": {
|
|
125
|
+
"extensions": ["./src/index.ts"]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
Type-check extension:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npx tsc --noEmit --skipLibCheck --moduleResolution Node16 --module Node16 --target ES2022 --types node src/index.ts
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Notes
|
|
139
|
+
|
|
140
|
+
Extensions run with full system permissions. Review code before installing any pi package.
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
# Clean Footer Extension Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a project-local pi extension that replaces the built-in footer with a cleaner adaptive footer showing model/effort, directory, git state, context usage, and cumulative token/cache telemetry.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Implement one self-contained TypeScript extension at `.pi/extensions/clean-footer/index.ts`. Keep git state outside render and refresh it through debounced event handlers. Render computes lightweight session totals and adaptive layout from current width.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** pi extension API, TypeScript, Node built-ins, `@earendil-works/pi-tui` width helpers, no external runtime deps.
|
|
10
|
+
|
|
11
|
+
**Note:** Do not commit changes. Keep all work in workspace.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Structure
|
|
16
|
+
|
|
17
|
+
- Create `.pi/extensions/clean-footer/index.ts`
|
|
18
|
+
- Registers footer command.
|
|
19
|
+
- Installs/removes custom footer.
|
|
20
|
+
- Tracks enabled state, git state, debounced refresh, thinking level.
|
|
21
|
+
- Formats model, effort, cwd basename, context, tokens, git state.
|
|
22
|
+
- Renders adaptive footer tiers.
|
|
23
|
+
- Use existing `docs/superpowers/specs/2026-05-09-clean-footer-extension-design.md` as requirements reference.
|
|
24
|
+
- No tests file initially; verification is manual because extension footer behavior depends on interactive pi TUI.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
### Task 1: Create extension skeleton and default-on footer
|
|
29
|
+
|
|
30
|
+
**Files:**
|
|
31
|
+
|
|
32
|
+
- Create: `.pi/extensions/clean-footer/index.ts`
|
|
33
|
+
|
|
34
|
+
- [ ] **Step 1: Create extension with command and lifecycle wiring**
|
|
35
|
+
|
|
36
|
+
Create `.pi/extensions/clean-footer/index.ts` with:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
40
|
+
import type {
|
|
41
|
+
ExtensionAPI,
|
|
42
|
+
ExtensionContext,
|
|
43
|
+
} from "@earendil-works/pi-coding-agent";
|
|
44
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
45
|
+
import { execFile } from "node:child_process";
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
import { promisify } from "node:util";
|
|
48
|
+
|
|
49
|
+
const execFileAsync = promisify(execFile);
|
|
50
|
+
|
|
51
|
+
type GitState = {
|
|
52
|
+
inRepo: boolean;
|
|
53
|
+
branch?: string;
|
|
54
|
+
dirtyCount: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type Totals = {
|
|
58
|
+
input: number;
|
|
59
|
+
output: number;
|
|
60
|
+
cacheRead: number;
|
|
61
|
+
cacheWrite: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type FooterRuntime = {
|
|
65
|
+
enabled: boolean;
|
|
66
|
+
git: GitState;
|
|
67
|
+
thinkingLevel?: string;
|
|
68
|
+
refreshTimer?: NodeJS.Timeout;
|
|
69
|
+
requestRender?: () => void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const runtime: FooterRuntime = {
|
|
73
|
+
enabled: true,
|
|
74
|
+
git: { inRepo: false, dirtyCount: 0 },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export default function (pi: ExtensionAPI) {
|
|
78
|
+
pi.registerCommand("footer", {
|
|
79
|
+
description: "Toggle or refresh the clean footer",
|
|
80
|
+
handler: async (args, ctx) => {
|
|
81
|
+
const command = args.trim();
|
|
82
|
+
if (command === "refresh") {
|
|
83
|
+
await refreshGit(ctx, true);
|
|
84
|
+
runtime.requestRender?.();
|
|
85
|
+
if (ctx.hasUI) ctx.ui.notify("Footer refreshed", "info");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
runtime.enabled = !runtime.enabled;
|
|
90
|
+
if (!ctx.hasUI) return;
|
|
91
|
+
|
|
92
|
+
if (runtime.enabled) {
|
|
93
|
+
installFooter(ctx);
|
|
94
|
+
await refreshGit(ctx, true);
|
|
95
|
+
ctx.ui.notify("Clean footer enabled", "info");
|
|
96
|
+
} else {
|
|
97
|
+
ctx.ui.setFooter(undefined);
|
|
98
|
+
ctx.ui.notify("Default footer restored", "info");
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
104
|
+
runtime.thinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel?.());
|
|
105
|
+
if (!ctx.hasUI || !runtime.enabled) return;
|
|
106
|
+
installFooter(ctx);
|
|
107
|
+
await refreshGit(ctx, true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
111
|
+
if (runtime.refreshTimer) clearTimeout(runtime.refreshTimer);
|
|
112
|
+
runtime.refreshTimer = undefined;
|
|
113
|
+
runtime.requestRender = undefined;
|
|
114
|
+
if (ctx.hasUI) ctx.ui.setFooter(undefined);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
pi.on("thinking_level_select", (event) => {
|
|
118
|
+
runtime.thinkingLevel = normalizeThinkingLevel(event.level);
|
|
119
|
+
runtime.requestRender?.();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
pi.on("model_select", () => {
|
|
123
|
+
runtime.requestRender?.();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.on("message_end", (event) => {
|
|
127
|
+
if (event.message.role === "assistant") runtime.requestRender?.();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
131
|
+
if (["bash", "edit", "write"].includes(event.toolName))
|
|
132
|
+
scheduleGitRefresh(ctx);
|
|
133
|
+
runtime.requestRender?.();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
pi.on("user_bash", (_event, ctx) => {
|
|
137
|
+
scheduleGitRefresh(ctx);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- [ ] **Step 2: Add no-op helper stubs so TypeScript names resolve**
|
|
143
|
+
|
|
144
|
+
Append below the default export:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
function installFooter(ctx: ExtensionContext) {
|
|
148
|
+
if (!ctx.hasUI) return;
|
|
149
|
+
|
|
150
|
+
ctx.ui.setFooter((tui, theme) => {
|
|
151
|
+
runtime.requestRender = () => tui.requestRender();
|
|
152
|
+
return {
|
|
153
|
+
invalidate() {},
|
|
154
|
+
render(width: number): string[] {
|
|
155
|
+
return [
|
|
156
|
+
truncateToWidth(theme.fg("dim", "clean footer loading"), width),
|
|
157
|
+
];
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeThinkingLevel(level: unknown): string | undefined {
|
|
164
|
+
if (typeof level !== "string") return undefined;
|
|
165
|
+
const normalized = level.toLowerCase();
|
|
166
|
+
if (
|
|
167
|
+
["low", "med", "medium", "high", "xhigh", "extra-high"].includes(normalized)
|
|
168
|
+
) {
|
|
169
|
+
if (normalized === "medium") return "med";
|
|
170
|
+
if (normalized === "extra-high") return "xhigh";
|
|
171
|
+
return normalized;
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function refreshGit(ctx: ExtensionContext, immediate = false) {
|
|
177
|
+
runtime.git = { inRepo: false, dirtyCount: 0 };
|
|
178
|
+
if (immediate) runtime.requestRender?.();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function scheduleGitRefresh(ctx: ExtensionContext) {
|
|
182
|
+
if (runtime.refreshTimer) clearTimeout(runtime.refreshTimer);
|
|
183
|
+
runtime.refreshTimer = setTimeout(() => {
|
|
184
|
+
runtime.refreshTimer = undefined;
|
|
185
|
+
void refreshGit(ctx, true);
|
|
186
|
+
}, 500);
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- [ ] **Step 3: Verify skeleton loads**
|
|
191
|
+
|
|
192
|
+
Run pi command manually from interactive session:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
/reload
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Expected: no extension load error; footer changes to `clean footer loading`. `/footer` toggles back to built-in footer and again to clean footer.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
### Task 2: Implement formatting helpers and telemetry totals
|
|
203
|
+
|
|
204
|
+
**Files:**
|
|
205
|
+
|
|
206
|
+
- Modify: `.pi/extensions/clean-footer/index.ts`
|
|
207
|
+
|
|
208
|
+
- [ ] **Step 1: Replace loading render with computed segments**
|
|
209
|
+
|
|
210
|
+
Replace `installFooter` with:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
function installFooter(ctx: ExtensionContext) {
|
|
214
|
+
if (!ctx.hasUI) return;
|
|
215
|
+
|
|
216
|
+
ctx.ui.setFooter((tui, theme) => {
|
|
217
|
+
runtime.requestRender = () => tui.requestRender();
|
|
218
|
+
return {
|
|
219
|
+
invalidate() {},
|
|
220
|
+
render(width: number): string[] {
|
|
221
|
+
const model = formatModelName(ctx.model?.id ?? "no-model");
|
|
222
|
+
const effort = runtime.thinkingLevel
|
|
223
|
+
? ` • ${runtime.thinkingLevel}`
|
|
224
|
+
: "";
|
|
225
|
+
const modelSegment = theme.fg("accent", `${model}${effort}`);
|
|
226
|
+
const dirSegment = theme.fg("dim", path.basename(ctx.cwd));
|
|
227
|
+
const gitSegment = formatGitSegment(theme);
|
|
228
|
+
const ctxSegment = formatContextSegment(ctx, theme);
|
|
229
|
+
const tokenSegment = theme.fg(
|
|
230
|
+
"muted",
|
|
231
|
+
formatTokenSegment(getTotals(ctx), "full"),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const leftParts = [modelSegment, dirSegment, gitSegment].filter(
|
|
235
|
+
Boolean,
|
|
236
|
+
);
|
|
237
|
+
const left = leftParts.join(theme.fg("dim", " | "));
|
|
238
|
+
const right = [ctxSegment, tokenSegment]
|
|
239
|
+
.filter(Boolean)
|
|
240
|
+
.join(theme.fg("dim", " | "));
|
|
241
|
+
return [joinLeftRight(left, right, width)];
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- [ ] **Step 2: Add model/context/token helpers**
|
|
249
|
+
|
|
250
|
+
Append helper functions:
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
function formatModelName(modelId: string): string {
|
|
254
|
+
const lower = modelId.toLowerCase();
|
|
255
|
+
const withoutProvider = lower.includes("/") ? lower.split("/").pop()! : lower;
|
|
256
|
+
|
|
257
|
+
if (
|
|
258
|
+
withoutProvider.includes("claude") &&
|
|
259
|
+
withoutProvider.includes("sonnet")
|
|
260
|
+
) {
|
|
261
|
+
if (withoutProvider.includes("4-5") || withoutProvider.includes("4.5"))
|
|
262
|
+
return "sonnet-4.5";
|
|
263
|
+
if (withoutProvider.includes("4")) return "sonnet-4";
|
|
264
|
+
return "sonnet";
|
|
265
|
+
}
|
|
266
|
+
if (withoutProvider.includes("claude") && withoutProvider.includes("opus"))
|
|
267
|
+
return "opus";
|
|
268
|
+
if (withoutProvider.includes("gpt-5"))
|
|
269
|
+
return withoutProvider.match(/gpt-5(?:[.-][a-z0-9]+)*/)?.[0] ?? "gpt-5";
|
|
270
|
+
if (withoutProvider.includes("gpt-4"))
|
|
271
|
+
return withoutProvider.match(/gpt-4(?:[.-][a-z0-9]+)*/)?.[0] ?? "gpt-4";
|
|
272
|
+
if (withoutProvider.includes("gemini"))
|
|
273
|
+
return withoutProvider.match(/gemini-[a-z0-9.-]+/)?.[0] ?? "gemini";
|
|
274
|
+
|
|
275
|
+
return withoutProvider.length > 24
|
|
276
|
+
? `${withoutProvider.slice(0, 21)}…`
|
|
277
|
+
: withoutProvider;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getTotals(ctx: ExtensionContext): Totals {
|
|
281
|
+
const totals: Totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
282
|
+
|
|
283
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
284
|
+
if (entry.type !== "message" || entry.message.role !== "assistant")
|
|
285
|
+
continue;
|
|
286
|
+
const message = entry.message as AssistantMessage;
|
|
287
|
+
totals.input += message.usage?.input ?? 0;
|
|
288
|
+
totals.output += message.usage?.output ?? 0;
|
|
289
|
+
totals.cacheRead += message.usage?.cacheRead ?? 0;
|
|
290
|
+
totals.cacheWrite += message.usage?.cacheWrite ?? 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return totals;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function formatTokenSegment(
|
|
297
|
+
totals: Totals,
|
|
298
|
+
mode: "full" | "no-cache" | "total-only",
|
|
299
|
+
): string {
|
|
300
|
+
const total = totals.input + totals.output;
|
|
301
|
+
if (mode === "total-only") return `Σ${formatCount(total)}`;
|
|
302
|
+
const base = `↑${formatCount(totals.input)} ↓${formatCount(totals.output)} Σ${formatCount(total)}`;
|
|
303
|
+
if (mode === "no-cache") return base;
|
|
304
|
+
return `${base} ↯${formatCount(totals.cacheRead)} ↥${formatCount(totals.cacheWrite)}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function formatContextSegment(
|
|
308
|
+
ctx: ExtensionContext,
|
|
309
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
310
|
+
): string {
|
|
311
|
+
const usage = ctx.getContextUsage?.();
|
|
312
|
+
const used = usage?.tokens ?? 0;
|
|
313
|
+
const max = ctx.model?.contextWindow;
|
|
314
|
+
const text = `ctx ${formatCount(used)}/${max ? formatCount(max) : "--"}`;
|
|
315
|
+
|
|
316
|
+
if (!max || max <= 0) return theme.fg("dim", text);
|
|
317
|
+
const ratio = used / max;
|
|
318
|
+
if (ratio >= 0.85) return theme.fg("error", text);
|
|
319
|
+
if (ratio >= 0.7) return theme.fg("warning", text);
|
|
320
|
+
return theme.fg("success", text);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function formatCount(value: number): string {
|
|
324
|
+
if (!Number.isFinite(value) || value <= 0) return "0";
|
|
325
|
+
if (value < 1_000) return `${Math.round(value)}`;
|
|
326
|
+
if (value < 1_000_000)
|
|
327
|
+
return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)}k`;
|
|
328
|
+
return `${(value / 1_000_000).toFixed(1)}m`;
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- [ ] **Step 3: Add join helper**
|
|
333
|
+
|
|
334
|
+
Append:
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
function joinLeftRight(left: string, right: string, width: number): string {
|
|
338
|
+
if (!right) return truncateToWidth(left, width);
|
|
339
|
+
if (!left) return truncateToWidth(right, width);
|
|
340
|
+
|
|
341
|
+
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
342
|
+
if (gap >= 1) return truncateToWidth(left + " ".repeat(gap) + right, width);
|
|
343
|
+
|
|
344
|
+
const half = Math.max(1, Math.floor((width - 1) / 2));
|
|
345
|
+
return (
|
|
346
|
+
truncateToWidth(left, half) + " " + truncateToWidth(right, width - half - 1)
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
- [ ] **Step 4: Verify model/dir/context/tokens render**
|
|
352
|
+
|
|
353
|
+
Run:
|
|
354
|
+
|
|
355
|
+
```text
|
|
356
|
+
/reload
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Expected: footer shows smart model name, effort when known, `footer` as dir basename, `ctx used/max`, and token segment. No git yet.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
### Task 3: Implement git refresh and git rendering
|
|
364
|
+
|
|
365
|
+
**Files:**
|
|
366
|
+
|
|
367
|
+
- Modify: `.pi/extensions/clean-footer/index.ts`
|
|
368
|
+
|
|
369
|
+
- [ ] **Step 1: Replace git stubs with real git commands**
|
|
370
|
+
|
|
371
|
+
Replace `refreshGit` with:
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
async function refreshGit(ctx: ExtensionContext, immediate = false) {
|
|
375
|
+
try {
|
|
376
|
+
const branchResult = await execFileAsync(
|
|
377
|
+
"git",
|
|
378
|
+
["branch", "--show-current"],
|
|
379
|
+
{
|
|
380
|
+
cwd: ctx.cwd,
|
|
381
|
+
timeout: 2_000,
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
const statusResult = await execFileAsync("git", ["status", "--porcelain"], {
|
|
385
|
+
cwd: ctx.cwd,
|
|
386
|
+
timeout: 2_000,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const branch = branchResult.stdout.trim() || "detached";
|
|
390
|
+
const dirtyCount = statusResult.stdout.split("\n").filter(Boolean).length;
|
|
391
|
+
runtime.git = { inRepo: true, branch, dirtyCount };
|
|
392
|
+
} catch {
|
|
393
|
+
runtime.git = { inRepo: false, dirtyCount: 0 };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (immediate) runtime.requestRender?.();
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
- [ ] **Step 2: Add git segment formatter**
|
|
401
|
+
|
|
402
|
+
Append:
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
function formatGitSegment(
|
|
406
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
407
|
+
): string | undefined {
|
|
408
|
+
if (!runtime.git.inRepo || !runtime.git.branch) return undefined;
|
|
409
|
+
const branch = theme.fg("success", runtime.git.branch);
|
|
410
|
+
if (runtime.git.dirtyCount <= 0) return branch;
|
|
411
|
+
return `${branch} ${theme.fg("warning", `●${runtime.git.dirtyCount}`)}`;
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
- [ ] **Step 3: Verify git hidden/visible behavior**
|
|
416
|
+
|
|
417
|
+
Manual checks:
|
|
418
|
+
|
|
419
|
+
```text
|
|
420
|
+
/reload
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Expected inside repo: footer shows `main` or current branch. Modify any file, then wait after tool/write or run `/footer refresh`; expected dirty count updates, including untracked files. Outside repo, expected git segment absent.
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
### Task 4: Implement adaptive width tiers
|
|
428
|
+
|
|
429
|
+
**Files:**
|
|
430
|
+
|
|
431
|
+
- Modify: `.pi/extensions/clean-footer/index.ts`
|
|
432
|
+
|
|
433
|
+
- [ ] **Step 1: Replace render body with tiered render**
|
|
434
|
+
|
|
435
|
+
Inside `installFooter` render, replace current segment assembly with:
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
const model = formatModelName(ctx.model?.id ?? "no-model");
|
|
439
|
+
const effort = runtime.thinkingLevel ? ` • ${runtime.thinkingLevel}` : "";
|
|
440
|
+
const modelSegment = theme.fg("accent", `${model}${effort}`);
|
|
441
|
+
const dirSegment = theme.fg("dim", path.basename(ctx.cwd));
|
|
442
|
+
const gitSegment = formatGitSegment(theme);
|
|
443
|
+
const ctxSegment = formatContextSegment(ctx, theme);
|
|
444
|
+
const totals = getTotals(ctx);
|
|
445
|
+
|
|
446
|
+
const leftFull = [modelSegment, dirSegment, gitSegment]
|
|
447
|
+
.filter(Boolean)
|
|
448
|
+
.join(theme.fg("dim", " | "));
|
|
449
|
+
const leftMin = modelSegment;
|
|
450
|
+
|
|
451
|
+
const full = joinLeftRight(
|
|
452
|
+
leftFull,
|
|
453
|
+
[ctxSegment, theme.fg("muted", formatTokenSegment(totals, "full"))].join(
|
|
454
|
+
theme.fg("dim", " | "),
|
|
455
|
+
),
|
|
456
|
+
width,
|
|
457
|
+
);
|
|
458
|
+
if (visibleWidth(full) <= width && width >= 100) return [full];
|
|
459
|
+
|
|
460
|
+
const medium = joinLeftRight(
|
|
461
|
+
leftFull,
|
|
462
|
+
[ctxSegment, theme.fg("muted", formatTokenSegment(totals, "no-cache"))].join(
|
|
463
|
+
theme.fg("dim", " | "),
|
|
464
|
+
),
|
|
465
|
+
width,
|
|
466
|
+
);
|
|
467
|
+
if (visibleWidth(medium) <= width && width >= 80) return [medium];
|
|
468
|
+
|
|
469
|
+
const small = joinLeftRight(
|
|
470
|
+
leftFull,
|
|
471
|
+
[
|
|
472
|
+
ctxSegment,
|
|
473
|
+
theme.fg("muted", formatTokenSegment(totals, "total-only")),
|
|
474
|
+
].join(theme.fg("dim", " | ")),
|
|
475
|
+
width,
|
|
476
|
+
);
|
|
477
|
+
if (visibleWidth(small) <= width && width >= 60) return [small];
|
|
478
|
+
|
|
479
|
+
const tiny = joinLeftRight(leftFull, ctxSegment, width);
|
|
480
|
+
if (visibleWidth(tiny) <= width && width >= 40) return [tiny];
|
|
481
|
+
|
|
482
|
+
return [joinLeftRight(leftMin, ctxSegment, width)];
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
- [ ] **Step 2: Verify all tiers by resizing terminal**
|
|
486
|
+
|
|
487
|
+
Manual expected results:
|
|
488
|
+
|
|
489
|
+
- Wide terminal: cache fields visible.
|
|
490
|
+
- Medium terminal: cache fields disappear first.
|
|
491
|
+
- Small terminal: only total tokens remain.
|
|
492
|
+
- Tiny terminal: tokens disappear, context remains.
|
|
493
|
+
- Minimum terminal: only model and context remain.
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
### Task 5: Polish behavior and self-review
|
|
498
|
+
|
|
499
|
+
**Files:**
|
|
500
|
+
|
|
501
|
+
- Modify: `.pi/extensions/clean-footer/index.ts`
|
|
502
|
+
- Modify if needed: `docs/superpowers/plans/2026-05-09-clean-footer-extension.md`
|
|
503
|
+
|
|
504
|
+
- [ ] **Step 1: Verify command behavior**
|
|
505
|
+
|
|
506
|
+
Run manually:
|
|
507
|
+
|
|
508
|
+
```text
|
|
509
|
+
/footer
|
|
510
|
+
/footer
|
|
511
|
+
/footer refresh
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Expected:
|
|
515
|
+
|
|
516
|
+
- First `/footer`: default footer restored.
|
|
517
|
+
- Second `/footer`: clean footer restored.
|
|
518
|
+
- `/footer refresh`: git state refreshes and notification appears in UI.
|
|
519
|
+
|
|
520
|
+
- [ ] **Step 2: Verify no render-time git calls**
|
|
521
|
+
|
|
522
|
+
Inspect `.pi/extensions/clean-footer/index.ts` and confirm `execFileAsync("git", ...)` occurs only in `refreshGit`, never in `render`.
|
|
523
|
+
|
|
524
|
+
- [ ] **Step 3: Verify spec coverage**
|
|
525
|
+
|
|
526
|
+
Checklist:
|
|
527
|
+
|
|
528
|
+
- [ ] Project-local extension exists at `.pi/extensions/clean-footer/index.ts`.
|
|
529
|
+
- [ ] Footer enabled by default.
|
|
530
|
+
- [ ] Footer splits left/right.
|
|
531
|
+
- [ ] Model smart-shortening exists.
|
|
532
|
+
- [ ] Effort displays beside model as `low/med/high/xhigh` when available.
|
|
533
|
+
- [ ] Directory displays basename only.
|
|
534
|
+
- [ ] Git hides outside repo.
|
|
535
|
+
- [ ] Git shows branch plus dirty count including untracked.
|
|
536
|
+
- [ ] Git refresh is event-driven and debounced.
|
|
537
|
+
- [ ] Context shows `used/max` with `--` fallback.
|
|
538
|
+
- [ ] Context warning thresholds are implemented.
|
|
539
|
+
- [ ] Tokens show cumulative input/output/total/cache read/cache write.
|
|
540
|
+
- [ ] Adaptive tiers hide fields in requested order.
|
|
541
|
+
- [ ] `/footer` and `/footer refresh` work.
|
|
542
|
+
- [ ] Non-UI contexts avoid throwing.
|
|
543
|
+
|
|
544
|
+
- [ ] **Step 4: Keep workspace uncommitted**
|
|
545
|
+
|
|
546
|
+
Do not run `git commit`. Leave created/modified files in workspace for user review.
|