@albizures/pi-footer-status 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # @albizures/pi-footer-status
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 8b46ddd: Replace the scaffolded footer status extension with an always-on default-like Pi footer that adds a Context fill bar.
8
+ - 8b46ddd: Add the initial Pi footer status extension package scaffold.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @albizures/pi-footer-status
2
+
3
+ A tiny Pi package that replaces Pi's footer with a default-like custom footer and adds a dedicated Context fill bar.
4
+
5
+ After installation, the package is always on. It is no longer a no-op scaffold, and it does not add slash commands or configuration.
6
+
7
+ ## Behavior
8
+
9
+ The custom footer keeps the default-like Pi footer information:
10
+
11
+ - current working directory, git branch, and session name
12
+ - cumulative input/output token usage
13
+ - cache read/write stats and cache hit percentage when available
14
+ - cumulative cost or subscription-backed usage marker when available
15
+ - textual current context percentage on the stats line
16
+ - active model, provider when useful, and thinking level for reasoning models
17
+
18
+ It mirrors these default-like footer lines, then preserves extension status messages after the Context fill bar.
19
+
20
+ It inserts one additional Context fill bar line before extension statuses:
21
+
22
+ ```text
23
+ context ████████░░░░░░░░░░░░ 40.0%/128k
24
+ ```
25
+
26
+ The Context fill bar:
27
+
28
+ - is labeled `context`
29
+ - represents Pi's current context usage, not cumulative token totals
30
+ - uses Pi's public current context usage API
31
+ - is fixed at 20 cells
32
+ - uses `█` for filled cells and `░` for empty cells
33
+ - rounds the fill amount to the nearest cell
34
+ - shows at least one filled cell for any known non-zero usage
35
+ - uses the theme accent color normally, warning above 70%, and error above 90%
36
+ - keeps empty cells dim
37
+ - appends the numeric `percent/contextWindow` suffix on the same line
38
+ - does not include `(auto)` on the bar line
39
+
40
+ When current context usage is unknown, the footer renders a static placeholder instead of guessing:
41
+
42
+ ```text
43
+ context ░░░░░░░░░░░░░░░░░░░░ ?/128k
44
+ ```
@@ -0,0 +1,124 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { renderContextFillBar, renderFooter } from "./index.ts";
5
+
6
+ const identityTheme = {
7
+ fg: (_color: string, text: string) => text,
8
+ bold: (text: string) => text,
9
+ };
10
+
11
+ const taggedTheme = {
12
+ fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
13
+ bold: (text: string) => `<bold>${text}</bold>`,
14
+ };
15
+
16
+ function assistantEntry(usage: Partial<any>) {
17
+ return {
18
+ type: "message",
19
+ message: {
20
+ role: "assistant",
21
+ usage: {
22
+ input: 1200,
23
+ output: 345,
24
+ cacheRead: 0,
25
+ cacheWrite: 0,
26
+ cost: { total: 0 },
27
+ ...usage,
28
+ },
29
+ },
30
+ } as any;
31
+ }
32
+
33
+ function baseState(overrides: Record<string, unknown> = {}) {
34
+ return {
35
+ cwd: "/home/alice/work/repo",
36
+ home: "/home/alice",
37
+ sessionName: "named-session",
38
+ gitBranch: "main",
39
+ extensionStatuses: new Map<string, string>(),
40
+ availableProviderCount: 1,
41
+ model: { id: "gpt-test", provider: "openai", contextWindow: 128000, reasoning: true },
42
+ thinkingLevel: "medium",
43
+ contextUsage: { tokens: 51200, contextWindow: 128000, percent: 40 },
44
+ entries: [assistantEntry({ cacheRead: 100, cacheWrite: 50, cost: { total: 0.0123 } })],
45
+ ...overrides,
46
+ } as any;
47
+ }
48
+
49
+ test("renderFooter preserves default-like footer lines and inserts the Context fill bar before sorted extension statuses", () => {
50
+ const lines = renderFooter(
51
+ baseState({ extensionStatuses: new Map([["z-last", "second"], ["a-first", "first"]]) }),
52
+ identityTheme,
53
+ 120,
54
+ );
55
+
56
+ assert.equal(lines.length, 4);
57
+ assert.equal(lines[0], "~/work/repo (main) • named-session");
58
+ assert.match(lines[1], /↑1\.2k ↓345 R100 W50 CH7\.4% \$0\.012 40\.0%\/128k \(auto\)\s+gpt-test • medium/);
59
+ assert.equal(lines[2], "context ████████░░░░░░░░░░░░ 40.0%/128k");
60
+ assert.equal(lines[3], "first second");
61
+ });
62
+
63
+ test("renderContextFillBar uses fixed 20-cell known-usage bars with required rounding behavior", () => {
64
+ const cases = [
65
+ { percent: 0, expected: "░░░░░░░░░░░░░░░░░░░░ 0.0%/100k" },
66
+ { percent: 1, expected: "█░░░░░░░░░░░░░░░░░░░ 1.0%/100k" },
67
+ { percent: 40, expected: "████████░░░░░░░░░░░░ 40.0%/100k" },
68
+ { percent: 99.6, expected: "████████████████████ 99.6%/100k" },
69
+ ];
70
+
71
+ for (const { percent, expected } of cases) {
72
+ assert.equal(renderContextFillBar({ percent, contextWindow: 100000 }, identityTheme, 120), `context ${expected}`);
73
+ }
74
+ });
75
+
76
+ test("renderContextFillBar applies severity colors at the same thresholds as Pi context text", () => {
77
+ const normal = renderContextFillBar({ percent: 40, contextWindow: 100000 }, taggedTheme, 200);
78
+ assert.match(normal, /<accent>█/);
79
+ assert.match(normal, /<dim>░/);
80
+ assert.match(normal, /<accent>40\.0%\/100k<\/accent>/);
81
+
82
+ const warning = renderContextFillBar({ percent: 70.1, contextWindow: 100000 }, taggedTheme, 200);
83
+ assert.match(warning, /<warning>█/);
84
+ assert.match(warning, /<dim>░/);
85
+ assert.match(warning, /<warning>70\.1%\/100k<\/warning>/);
86
+
87
+ assert.match(renderContextFillBar({ percent: 90.1, contextWindow: 100000 }, taggedTheme, 200), /<error>█/);
88
+ assert.match(renderContextFillBar({ percent: 90.1, contextWindow: 100000 }, taggedTheme, 200), /<error>90\.1%\/100k<\/error>/);
89
+ });
90
+
91
+ test("renderContextFillBar renders unknown usage as a static dim placeholder with ? percentage", () => {
92
+ const line = renderContextFillBar({ percent: null, contextWindow: 200000 }, identityTheme, 120);
93
+
94
+ assert.equal(line, "context ░░░░░░░░░░░░░░░░░░░░ ?/200k");
95
+
96
+ const styledLine = renderContextFillBar({ percent: undefined, contextWindow: 200000 }, taggedTheme, 200);
97
+ assert.match(styledLine, /<dim>░░░░░░░░░░░░░░░░░░░░<\/dim>/);
98
+ assert.match(styledLine, /\?\/200k/);
99
+ assert.doesNotMatch(styledLine, /<accent>|<warning>|<error>/);
100
+ });
101
+
102
+ test("renderFooter renders unknown Context fill bar line without auto-compaction text", () => {
103
+ const lines = renderFooter(baseState({ contextUsage: { tokens: 0, contextWindow: 128000, percent: null } }), identityTheme, 120);
104
+
105
+ assert.equal(lines[2], "context ░░░░░░░░░░░░░░░░░░░░ ?/128k");
106
+ assert.doesNotMatch(lines[2], /auto/);
107
+ });
108
+
109
+ test("renderFooter truncates all rendered lines to terminal width", () => {
110
+ const lines = renderFooter(
111
+ baseState({
112
+ cwd: "/home/alice/a/very/long/path/to/a/repository",
113
+ sessionName: "a very long session name",
114
+ extensionStatuses: new Map([["status", "a very long extension status that must be truncated"]]),
115
+ }),
116
+ identityTheme,
117
+ 24,
118
+ );
119
+
120
+ assert.ok(lines.length >= 4);
121
+ for (const line of lines) {
122
+ assert.ok(visibleWidth(line) <= 24, `${line} exceeded width`);
123
+ }
124
+ });
@@ -0,0 +1,303 @@
1
+ import type { ContextUsage, ExtensionAPI, ExtensionContext, ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+
5
+ const BAR_WIDTH = 20;
6
+ const WARNING_PERCENT = 70;
7
+ const ERROR_PERCENT = 90;
8
+
9
+ type ThemeColor = "accent" | "dim" | "error" | "warning";
10
+
11
+ type FooterTheme = {
12
+ fg(color: ThemeColor | string, text: string): string;
13
+ bold(text: string): string;
14
+ };
15
+
16
+ type FooterModel = {
17
+ id: string;
18
+ provider: string;
19
+ contextWindow: number;
20
+ reasoning?: boolean;
21
+ };
22
+
23
+ type FooterState = {
24
+ cwd: string;
25
+ sessionName?: string;
26
+ gitBranch: string | null;
27
+ extensionStatuses: ReadonlyMap<string, string>;
28
+ availableProviderCount: number;
29
+ model?: FooterModel;
30
+ thinkingLevel?: string;
31
+ contextUsage?: ContextUsage;
32
+ entries: ExtensionContext["sessionManager"] extends { getEntries(): infer Entries } ? Entries : never;
33
+ usingSubscription?: boolean;
34
+ experimental?: boolean;
35
+ home?: string;
36
+ autoCompactEnabled?: boolean;
37
+ };
38
+
39
+ type FooterUsage = {
40
+ input?: number;
41
+ output?: number;
42
+ cacheRead?: number;
43
+ cacheWrite?: number;
44
+ cost?: { total?: number };
45
+ };
46
+
47
+ type UsageTotals = {
48
+ input: number;
49
+ output: number;
50
+ cacheRead: number;
51
+ cacheWrite: number;
52
+ cost: number;
53
+ };
54
+
55
+ export default function (pi: ExtensionAPI) {
56
+ let requestRender: (() => void) | undefined;
57
+
58
+ pi.on("session_start", (_event, ctx) => {
59
+ ctx.ui.setFooter((tui, theme, footerData) => {
60
+ requestRender = () => tui.requestRender();
61
+ const disposeBranch = footerData.onBranchChange(() => tui.requestRender());
62
+
63
+ return {
64
+ dispose() {
65
+ disposeBranch();
66
+ requestRender = undefined;
67
+ },
68
+ invalidate() {},
69
+ render(width: number): string[] {
70
+ return renderFooter(
71
+ collectFooterState(ctx, footerData, process.env.HOME || process.env.USERPROFILE),
72
+ theme,
73
+ width,
74
+ );
75
+ },
76
+ };
77
+ });
78
+ });
79
+
80
+ const rerender = () => {
81
+ requestRender?.();
82
+ };
83
+ pi.on("model_select", rerender);
84
+ pi.on("thinking_level_select", rerender);
85
+ pi.on("session_info_changed", rerender);
86
+ pi.on("message_end", rerender);
87
+ pi.on("agent_settled", rerender);
88
+ pi.on("session_compact", rerender);
89
+ }
90
+
91
+ function collectFooterState(
92
+ ctx: ExtensionContext,
93
+ footerData: ReadonlyFooterDataProvider,
94
+ home: string | undefined,
95
+ ): FooterState {
96
+ const model = ctx.model
97
+ ? {
98
+ id: ctx.model.id,
99
+ provider: ctx.model.provider,
100
+ contextWindow: ctx.model.contextWindow,
101
+ reasoning: ctx.model.reasoning,
102
+ }
103
+ : undefined;
104
+
105
+ return {
106
+ cwd: ctx.sessionManager.getCwd(),
107
+ sessionName: ctx.sessionManager.getSessionName(),
108
+ gitBranch: footerData.getGitBranch(),
109
+ extensionStatuses: footerData.getExtensionStatuses(),
110
+ availableProviderCount: footerData.getAvailableProviderCount(),
111
+ model,
112
+ thinkingLevel: ctx.thinkingLevel,
113
+ contextUsage: ctx.getContextUsage(),
114
+ entries: ctx.sessionManager.getEntries(),
115
+ usingSubscription: ctx.model
116
+ ? ctx.model.provider === "kimi-coding" || ctx.modelRegistry.isUsingOAuth(ctx.model)
117
+ : false,
118
+ experimental: process.env.PI_EXPERIMENTAL === "1",
119
+ home,
120
+ };
121
+ }
122
+
123
+ export function renderFooter(state: FooterState, theme: FooterTheme, width: number): string[] {
124
+ const usage = getUsageStats(state.entries);
125
+ const contextUsage = state.contextUsage;
126
+ const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
127
+ const contextPercentValue = contextUsage?.percent ?? 0;
128
+ const contextPercent = contextUsage?.percent !== null && contextUsage?.percent !== undefined
129
+ ? contextPercentValue.toFixed(1)
130
+ : "?";
131
+
132
+ let pwd = formatCwdForFooter(state.cwd, state.home);
133
+ if (state.gitBranch) pwd = `${pwd} (${state.gitBranch})`;
134
+ if (state.sessionName) pwd = `${pwd} • ${state.sessionName}`;
135
+
136
+ const statsParts: string[] = [];
137
+ if (usage.totals.input) statsParts.push(`↑${formatTokens(usage.totals.input)}`);
138
+ if (usage.totals.output) statsParts.push(`↓${formatTokens(usage.totals.output)}`);
139
+ if (usage.totals.cacheRead) statsParts.push(`R${formatTokens(usage.totals.cacheRead)}`);
140
+ if (usage.totals.cacheWrite) statsParts.push(`W${formatTokens(usage.totals.cacheWrite)}`);
141
+ if ((usage.totals.cacheRead > 0 || usage.totals.cacheWrite > 0) && usage.latestCacheHitRate !== undefined) {
142
+ statsParts.push(`CH${usage.latestCacheHitRate.toFixed(1)}%`);
143
+ }
144
+ if (usage.totals.cost || state.usingSubscription) {
145
+ statsParts.push(`$${usage.totals.cost.toFixed(3)}${state.usingSubscription ? " (sub)" : ""}`);
146
+ }
147
+
148
+ const autoIndicator = state.autoCompactEnabled === false ? "" : " (auto)";
149
+ const contextPercentDisplay = contextPercent === "?"
150
+ ? `?/${formatTokens(contextWindow)}${autoIndicator}`
151
+ : `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
152
+ statsParts.push(colorByContextSeverity(contextPercentValue, contextPercentDisplay, theme));
153
+ if (state.experimental) statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`);
154
+
155
+ const statsLine = renderStatsLine(statsParts.join(" "), state, theme, width);
156
+ const contextBarLine = renderContextFillBar({ percent: contextUsage?.percent, contextWindow }, theme, width);
157
+
158
+ const lines = [
159
+ truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")),
160
+ statsLine,
161
+ contextBarLine,
162
+ ];
163
+
164
+ const statuses = renderExtensionStatuses(state.extensionStatuses, theme, width);
165
+ if (statuses) lines.push(statuses);
166
+ return lines;
167
+ }
168
+
169
+ function renderStatsLine(statsLeftInput: string, state: FooterState, theme: FooterTheme, width: number): string {
170
+ let statsLeft = statsLeftInput;
171
+ let statsLeftWidth = visibleWidth(statsLeft);
172
+ if (statsLeftWidth > width) {
173
+ statsLeft = truncateToWidth(statsLeft, width, "...");
174
+ statsLeftWidth = visibleWidth(statsLeft);
175
+ }
176
+
177
+ const modelName = state.model?.id || "no-model";
178
+ let rightSideWithoutProvider = modelName;
179
+ if (state.model?.reasoning) {
180
+ const thinkingLevel = state.thinkingLevel || "off";
181
+ rightSideWithoutProvider = thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;
182
+ }
183
+
184
+ let rightSide = rightSideWithoutProvider;
185
+ if (state.availableProviderCount > 1 && state.model) {
186
+ rightSide = `(${state.model.provider}) ${rightSideWithoutProvider}`;
187
+ if (statsLeftWidth + 2 + visibleWidth(rightSide) > width) rightSide = rightSideWithoutProvider;
188
+ }
189
+
190
+ const rightSideWidth = visibleWidth(rightSide);
191
+ let statsLine: string;
192
+ if (statsLeftWidth + 2 + rightSideWidth <= width) {
193
+ statsLine = statsLeft + " ".repeat(width - statsLeftWidth - rightSideWidth) + rightSide;
194
+ } else {
195
+ const availableForRight = width - statsLeftWidth - 2;
196
+ if (availableForRight > 0) {
197
+ const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
198
+ statsLine = statsLeft + " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(truncatedRight))) + truncatedRight;
199
+ } else {
200
+ statsLine = statsLeft;
201
+ }
202
+ }
203
+
204
+ return theme.fg("dim", statsLeft) + theme.fg("dim", statsLine.slice(statsLeft.length));
205
+ }
206
+
207
+ export function renderContextFillBar(
208
+ usage: { percent: number | null | undefined; contextWindow: number },
209
+ theme: FooterTheme,
210
+ width: number,
211
+ ): string {
212
+ if (usage.percent === null || usage.percent === undefined) {
213
+ const placeholder = theme.fg("dim", "░".repeat(BAR_WIDTH));
214
+ return truncateToWidth(`context ${placeholder} ?/${formatTokens(usage.contextWindow)}`, width, theme.fg("dim", "..."));
215
+ }
216
+
217
+ const clampedPercent = Math.max(0, Math.min(100, usage.percent));
218
+ let filled = Math.round((clampedPercent / 100) * BAR_WIDTH);
219
+ if (clampedPercent > 0) filled = Math.max(1, filled);
220
+ if (clampedPercent >= 99.5) filled = BAR_WIDTH;
221
+
222
+ const filledCells = "█".repeat(filled);
223
+ const emptyCells = "░".repeat(BAR_WIDTH - filled);
224
+ const severityColor = clampedPercent > ERROR_PERCENT ? "error" : clampedPercent > WARNING_PERCENT ? "warning" : "accent";
225
+ const numeric = `${usage.percent.toFixed(1)}%/${formatTokens(usage.contextWindow)}`;
226
+ const line = `context ${theme.fg(severityColor, filledCells)}${theme.fg("dim", emptyCells)} ${theme.fg(severityColor, numeric)}`;
227
+ return truncateToWidth(line, width, theme.fg("dim", "..."));
228
+ }
229
+
230
+ function renderExtensionStatuses(statuses: ReadonlyMap<string, string>, theme: FooterTheme, width: number): string | undefined {
231
+ if (statuses.size === 0) return undefined;
232
+ const statusLine = Array.from(statuses.entries())
233
+ .sort(([a], [b]) => a.localeCompare(b))
234
+ .map(([, text]) => sanitizeStatusText(text))
235
+ .join(" ");
236
+ return truncateToWidth(statusLine, width, theme.fg("dim", "..."));
237
+ }
238
+
239
+ function getUsageStats(entries: FooterState["entries"]): { totals: UsageTotals; latestCacheHitRate?: number } {
240
+ const totals: UsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
241
+ let latestCacheHitRate: number | undefined;
242
+
243
+ for (const entry of entries) {
244
+ const usage = getEntryUsage(entry);
245
+ if (!usage) continue;
246
+ addUsage(totals, usage);
247
+
248
+ if (entry.type === "message" && entry.message.role === "assistant") {
249
+ const cacheRead = usage.cacheRead ?? 0;
250
+ const promptTokens = (usage.input ?? 0) + cacheRead + (usage.cacheWrite ?? 0);
251
+ latestCacheHitRate = promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined;
252
+ }
253
+ }
254
+
255
+ return { totals, latestCacheHitRate };
256
+ }
257
+
258
+ function getEntryUsage(entry: FooterState["entries"][number]): FooterUsage | undefined {
259
+ if (entry.type === "message") {
260
+ if (entry.message.role === "assistant") return entry.message.usage;
261
+ if (entry.message.role === "toolResult") return entry.message.usage;
262
+ }
263
+ if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) return entry.usage;
264
+ return undefined;
265
+ }
266
+
267
+ function addUsage(totals: UsageTotals, usage: FooterUsage): void {
268
+ totals.input += usage.input ?? 0;
269
+ totals.output += usage.output ?? 0;
270
+ totals.cacheRead += usage.cacheRead ?? 0;
271
+ totals.cacheWrite += usage.cacheWrite ?? 0;
272
+ totals.cost += usage.cost?.total ?? 0;
273
+ }
274
+
275
+ function colorByContextSeverity(percent: number, text: string, theme: FooterTheme): string {
276
+ if (percent > ERROR_PERCENT) return theme.fg("error", text);
277
+ if (percent > WARNING_PERCENT) return theme.fg("warning", text);
278
+ return text;
279
+ }
280
+
281
+ function sanitizeStatusText(text: string): string {
282
+ return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
283
+ }
284
+
285
+ export function formatTokens(count: number): string {
286
+ if (count < 1000) return count.toString();
287
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
288
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
289
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
290
+ return `${Math.round(count / 1000000)}M`;
291
+ }
292
+
293
+ export function formatCwdForFooter(cwd: string, home: string | undefined): string {
294
+ if (!home) return cwd;
295
+ const resolvedCwd = resolve(cwd);
296
+ const resolvedHome = resolve(home);
297
+ const relativeToHome = relative(resolvedHome, resolvedCwd);
298
+ const isInsideHome =
299
+ relativeToHome === "" ||
300
+ (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
301
+ if (!isInsideHome) return cwd;
302
+ return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
303
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@albizures/pi-footer-status",
3
+ "version": "0.1.1",
4
+ "description": "Pi package for customizing the footer status area.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "extension",
9
+ "footer",
10
+ "status"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "pi": {
18
+ "extensions": [
19
+ "./extensions"
20
+ ]
21
+ },
22
+ "dependencies": {
23
+ "@earendil-works/pi-tui": "^0.82.0"
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-coding-agent": "*"
27
+ }
28
+ }