@bugabinga/pi-ext-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/CHANGELOG.md +18 -0
- package/README.md +11 -0
- package/assets/footer_suite.gif +0 -0
- package/index.ts +301 -0
- package/package.json +16 -0
- package/progress.ts +33 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-05-21
|
|
4
|
+
|
|
5
|
+
- a40a427 prepare extensions for npm release
|
|
6
|
+
- 6a97080 add asciinema demo workflow
|
|
7
|
+
- 517ba54 pi: add extension footer fallbacks
|
|
8
|
+
- 133cb7d chore(pi): migrate extensions to earendil packages
|
|
9
|
+
- f144b06 fix(pi): restore extension notifications
|
|
10
|
+
- 5ca1296 Rework Pi agent extensions
|
|
11
|
+
- b87a61a feat(pi): monorepo workspace — all extensions are proper packages
|
|
12
|
+
- b18f0e2 refactor(pi/footer): clean sweep — kill dead code, extract helpers, name constants, type properly
|
|
13
|
+
- 972978f fix(pi/footer): force full redraw (requestRender(true)) to clear old footer on hide
|
|
14
|
+
- 0ea30fd fix(pi/footer): force render after swap to immediately clear old lines
|
|
15
|
+
- a49441f fix(pi/footer): swap footer via setFooter() on toggle for true 0-height when hidden
|
|
16
|
+
- 1cfa6a1 fix(pi/footer): import Key from pi-tui, not pi-coding-agent
|
|
17
|
+
- 933bcb6 pi(ext/footer): semantic zones, flow layout, 4-line stable height, /footer toggle
|
|
18
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# footer
|
|
2
|
+
|
|
3
|
+
Shared Pi footer renderer.
|
|
4
|
+
|
|
5
|
+
Renders `footer:segment` events from producer extensions into stable workspace/LLM rows and includes Pi extension statuses as the last row.
|
|
6
|
+
|
|
7
|
+
## Demo
|
|
8
|
+
|
|
9
|
+
<!-- demo:footer_suite:start -->
|
|
10
|
+

|
|
11
|
+
<!-- demo:footer_suite:end -->
|
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer renderer extension.
|
|
3
|
+
*
|
|
4
|
+
* Listens for "footer:segment" events from producer extensions and renders
|
|
5
|
+
* a 4-line footer with stable height:
|
|
6
|
+
* line 0 = workspace zone (cwd, branch, PR, runtime, session name)
|
|
7
|
+
* line 1-2 = llm zone (model, thinking, context, tokens, cost, timing) — flow layout
|
|
8
|
+
* line 3 = extension statuses via ctx.ui.setStatus()
|
|
9
|
+
*
|
|
10
|
+
* Producers use semantic zones ("workspace" | "llm"). Footer decides row mapping.
|
|
11
|
+
* Publishes a process-global capability flag so producers can choose footer
|
|
12
|
+
* segments over ctx.ui.setStatus() fallbacks without ping events.
|
|
13
|
+
* Uses setFooter() — the single footer owner. Producers are decoupled.
|
|
14
|
+
*
|
|
15
|
+
* /footer or ctrl+alt+f toggles visibility by swapping between
|
|
16
|
+
* the real footer and a zero-height stub via setFooter().
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
21
|
+
import { renderBlocksBar } from "./progress";
|
|
22
|
+
|
|
23
|
+
// ── Constants ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const FOOTER_KEY = Symbol.for("@bugabinga/pi-ext-footer");
|
|
26
|
+
const SEPARATOR_COLOR = "borderMuted" as ThemeColor;
|
|
27
|
+
const DEFAULT_SEGMENT_COLOR = "muted" as ThemeColor;
|
|
28
|
+
const STATUS_COLOR = "dim" as ThemeColor;
|
|
29
|
+
const BAR_WIDTH = 10;
|
|
30
|
+
const RETRY_MOUNT_MS = 50;
|
|
31
|
+
|
|
32
|
+
// ── Types ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
type FooterGlobal = {
|
|
35
|
+
version: 1;
|
|
36
|
+
loaded: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type FooterZone = "workspace" | "llm";
|
|
40
|
+
|
|
41
|
+
type Segment = {
|
|
42
|
+
id: string;
|
|
43
|
+
text: string;
|
|
44
|
+
icon?: string;
|
|
45
|
+
color?: ThemeColor;
|
|
46
|
+
bar?: number;
|
|
47
|
+
suffix?: string;
|
|
48
|
+
zone: FooterZone;
|
|
49
|
+
order?: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type SegmentUpdate = Segment | { id: string; text: undefined };
|
|
53
|
+
|
|
54
|
+
// ── Pure helpers ──────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** Pad line with side margins, truncating to fit terminal width. */
|
|
57
|
+
function padLine(line: string, width: number): string {
|
|
58
|
+
if (width <= 1) return truncateToWidth(line, width);
|
|
59
|
+
const contentWidth = width - 2;
|
|
60
|
+
return ` ${truncateToWidth(line, contentWidth)} `;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Collapse whitespace and strip control characters for single-line display. */
|
|
64
|
+
function sanitizeStatus(text: string): string {
|
|
65
|
+
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Zero-height footer stub for hidden mode. */
|
|
69
|
+
const HIDDEN_FOOTER = {
|
|
70
|
+
dispose: () => {},
|
|
71
|
+
invalidate() {},
|
|
72
|
+
render(): string[] { return []; },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ── Extension ─────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export default function footerExtension(pi: ExtensionAPI) {
|
|
78
|
+
const state: FooterGlobal = { version: 1, loaded: true };
|
|
79
|
+
(globalThis as any)[FOOTER_KEY] = state;
|
|
80
|
+
|
|
81
|
+
const segments = new Map<string, Segment>();
|
|
82
|
+
let requestRender: (() => void) | undefined;
|
|
83
|
+
let renderScheduled = false;
|
|
84
|
+
let hidden = false;
|
|
85
|
+
let currentCtx: ExtensionContext | undefined;
|
|
86
|
+
|
|
87
|
+
// ── Segment handling ──────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function handleSegment(data: unknown) {
|
|
90
|
+
const payload = data as SegmentUpdate;
|
|
91
|
+
if (!payload?.id) return;
|
|
92
|
+
|
|
93
|
+
if (!("text" in payload) || payload.text === undefined) {
|
|
94
|
+
segments.delete(payload.id);
|
|
95
|
+
} else {
|
|
96
|
+
segments.set(payload.id, payload as Segment);
|
|
97
|
+
}
|
|
98
|
+
scheduleRender();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
pi.events.on("footer:segment", handleSegment);
|
|
102
|
+
|
|
103
|
+
// ── Coalesced render scheduling ───────────────────────────────
|
|
104
|
+
|
|
105
|
+
function scheduleRender() {
|
|
106
|
+
if (renderScheduled) return;
|
|
107
|
+
if (!requestRender) {
|
|
108
|
+
if (segments.size > 0) setTimeout(() => scheduleRender(), RETRY_MOUNT_MS);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
renderScheduled = true;
|
|
112
|
+
queueMicrotask(() => {
|
|
113
|
+
renderScheduled = false;
|
|
114
|
+
requestRender?.();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Segment queries ───────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function getZoneSegments(zone: FooterZone): Segment[] {
|
|
121
|
+
return Array.from(segments.values())
|
|
122
|
+
.filter((s) => s.zone === zone)
|
|
123
|
+
.sort((a, b) => (a.order ?? 50) - (b.order ?? 50));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Segment rendering ─────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function renderSegment(seg: Segment, theme: Theme): string {
|
|
129
|
+
const color = seg.color ?? DEFAULT_SEGMENT_COLOR;
|
|
130
|
+
const parts: string[] = [];
|
|
131
|
+
|
|
132
|
+
if (seg.icon) parts.push(theme.fg(color, seg.icon));
|
|
133
|
+
if (seg.text) parts.push(theme.fg(color, seg.text));
|
|
134
|
+
|
|
135
|
+
if (seg.bar !== undefined) {
|
|
136
|
+
const bar = renderBlocksBar(seg.bar, BAR_WIDTH, theme, color);
|
|
137
|
+
const suffix = seg.suffix ? theme.fg(color, seg.suffix) : "";
|
|
138
|
+
parts.push(bar + suffix);
|
|
139
|
+
} else if (seg.suffix) {
|
|
140
|
+
parts.push(theme.fg(color, seg.suffix));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return parts.join(" ");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function joinSegments(segs: Segment[], sep: string, theme: Theme): string {
|
|
147
|
+
return segs.map((s) => renderSegment(s, theme)).join(sep);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Zone rendering ────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function safeSessionName(ctx: ExtensionContext | undefined): string | undefined {
|
|
153
|
+
try {
|
|
154
|
+
return ctx?.sessionManager.getSessionName();
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderWorkspaceLine(theme: Theme, width: number, ctx: ExtensionContext | undefined): string {
|
|
161
|
+
const segs = getZoneSegments("workspace");
|
|
162
|
+
const name = safeSessionName(ctx);
|
|
163
|
+
if (name) {
|
|
164
|
+
segs.push({ id: "session-name", text: name, icon: "✎", color: "accent" as ThemeColor, zone: "workspace", order: 0.5 });
|
|
165
|
+
segs.sort((a, b) => (a.order ?? 50) - (b.order ?? 50));
|
|
166
|
+
}
|
|
167
|
+
if (segs.length === 0) return "";
|
|
168
|
+
const sep = theme.fg(SEPARATOR_COLOR, " | ");
|
|
169
|
+
return truncateToWidth(joinSegments(segs, sep, theme), width);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Flow layout: fill line 1, overflow to line 2, truncate line 2. */
|
|
173
|
+
function renderLlmLines(theme: Theme, width: number): [string, string] {
|
|
174
|
+
const segs = getZoneSegments("llm");
|
|
175
|
+
if (segs.length === 0) return ["", ""];
|
|
176
|
+
|
|
177
|
+
const sep = theme.fg(SEPARATOR_COLOR, " | ");
|
|
178
|
+
const rendered = segs.map((s) => renderSegment(s, theme));
|
|
179
|
+
|
|
180
|
+
let line1 = "";
|
|
181
|
+
let line1Width = 0;
|
|
182
|
+
let overflowIndex = -1;
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
185
|
+
const segWidth = visibleWidth(rendered[i]!);
|
|
186
|
+
const prefixWidth = line1Width > 0 ? visibleWidth(sep) : 0;
|
|
187
|
+
|
|
188
|
+
if (line1Width + prefixWidth + segWidth <= width) {
|
|
189
|
+
line1 += (line1Width > 0 ? sep : "") + rendered[i];
|
|
190
|
+
line1Width += prefixWidth + segWidth;
|
|
191
|
+
} else {
|
|
192
|
+
overflowIndex = i;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (overflowIndex === -1) return [line1, ""];
|
|
198
|
+
|
|
199
|
+
const line2 = truncateToWidth(rendered.slice(overflowIndex).join(sep), width);
|
|
200
|
+
return [line1, line2];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderStatusLine(theme: Theme, statuses: ReadonlyMap<string, string>): string {
|
|
204
|
+
if (statuses.size === 0) return "";
|
|
205
|
+
const parts = Array.from(statuses.entries())
|
|
206
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
207
|
+
.map(([, t]) => sanitizeStatus(t))
|
|
208
|
+
.join(" · ");
|
|
209
|
+
return theme.fg(STATUS_COLOR, parts);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function renderFooterLines(
|
|
213
|
+
theme: Theme,
|
|
214
|
+
width: number,
|
|
215
|
+
ctx: ExtensionContext | undefined,
|
|
216
|
+
statuses: ReadonlyMap<string, string>,
|
|
217
|
+
): string[] {
|
|
218
|
+
const contentWidth = Math.max(0, width - 2);
|
|
219
|
+
const workspaceLine = renderWorkspaceLine(theme, contentWidth, ctx);
|
|
220
|
+
const [llmLine1, llmLine2] = renderLlmLines(theme, contentWidth);
|
|
221
|
+
const statusLine = renderStatusLine(theme, statuses);
|
|
222
|
+
|
|
223
|
+
return [
|
|
224
|
+
padLine(workspaceLine, width),
|
|
225
|
+
padLine(llmLine1, width),
|
|
226
|
+
padLine(llmLine2, width),
|
|
227
|
+
padLine(statusLine, width),
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Mounting ──────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function mountFooter() {
|
|
234
|
+
if (!currentCtx) return;
|
|
235
|
+
|
|
236
|
+
if (hidden) {
|
|
237
|
+
requestRender = undefined;
|
|
238
|
+
currentCtx.ui.setFooter((tui) => {
|
|
239
|
+
tui.requestRender(true);
|
|
240
|
+
return HIDDEN_FOOTER;
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
currentCtx.ui.setFooter((tui, theme, footerData) => {
|
|
246
|
+
requestRender = () => tui.requestRender();
|
|
247
|
+
const unsubBranch = footerData.onBranchChange(() => scheduleRender());
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
dispose: () => {
|
|
251
|
+
unsubBranch();
|
|
252
|
+
requestRender = undefined;
|
|
253
|
+
},
|
|
254
|
+
invalidate() {},
|
|
255
|
+
render(width: number): string[] {
|
|
256
|
+
try {
|
|
257
|
+
return renderFooterLines(theme, width, currentCtx, footerData.getExtensionStatuses());
|
|
258
|
+
} catch {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
setTimeout(() => scheduleRender(), 0);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Toggle ────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
function toggleVisibility(ctx: ExtensionContext) {
|
|
271
|
+
hidden = !hidden;
|
|
272
|
+
currentCtx = ctx;
|
|
273
|
+
ctx.ui.notify(hidden ? "Footer hidden" : "Footer visible", "info");
|
|
274
|
+
mountFooter();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
pi.registerCommand("footer", {
|
|
278
|
+
description: "Toggle footer visibility",
|
|
279
|
+
handler: async (_args, ctx) => toggleVisibility(ctx),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
pi.registerShortcut(Key.ctrlAlt("f"), {
|
|
283
|
+
description: "Toggle footer visibility",
|
|
284
|
+
handler: async (ctx) => toggleVisibility(ctx),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── Lifecycle ─────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
290
|
+
if (!ctx.hasUI) return;
|
|
291
|
+
currentCtx = ctx;
|
|
292
|
+
mountFooter();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
pi.on("session_shutdown", async (event) => {
|
|
296
|
+
if (event.reason === "quit" || event.reason === "reload") state.loaded = false;
|
|
297
|
+
segments.clear();
|
|
298
|
+
requestRender = undefined;
|
|
299
|
+
currentCtx = undefined;
|
|
300
|
+
});
|
|
301
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bugabinga/pi-ext-footer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
8
|
+
"@earendil-works/pi-tui": "*"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"description": "Shared stable footer renderer for Pi.",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-extension"
|
|
15
|
+
]
|
|
16
|
+
}
|
package/progress.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress bar rendering with discrete block characters.
|
|
3
|
+
*
|
|
4
|
+
* Renders ▁▂▃▄▅▆▇█ glyphs on a dim background track.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
const GLYPHS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
10
|
+
const MAX_LEVEL = GLYPHS.length - 1;
|
|
11
|
+
const RESET = "\x1b[39m\x1b[49m";
|
|
12
|
+
|
|
13
|
+
/** Render discrete block characters with dim background track. */
|
|
14
|
+
export function renderBlocksBar(pct: number, width: number, theme: Theme, color: ThemeColor): string {
|
|
15
|
+
const clamped = Math.max(0, Math.min(100, pct));
|
|
16
|
+
const filledFloat = (clamped / 100) * width;
|
|
17
|
+
const dimBg = fgToBgAnsi(theme.getFgAnsi("dim"));
|
|
18
|
+
const fgColor = theme.getFgAnsi(color);
|
|
19
|
+
|
|
20
|
+
const result: string[] = [];
|
|
21
|
+
for (let i = 0; i < width; i++) {
|
|
22
|
+
const blockFill = Math.max(0, Math.min(1, filledFloat - i));
|
|
23
|
+
const level = Math.round(blockFill * MAX_LEVEL);
|
|
24
|
+
const glyph = GLYPHS[level];
|
|
25
|
+
result.push(level > 0 ? `${dimBg}${fgColor}${glyph}${RESET}` : `${dimBg}${glyph}${RESET}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return result.join("");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fgToBgAnsi(fgAnsi: string): string {
|
|
32
|
+
return fgAnsi.replace("\x1b[38;", "\x1b[48;");
|
|
33
|
+
}
|