pi-footer-manager 1.0.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 ADDED
@@ -0,0 +1,177 @@
1
+ # pi-footer-manager
2
+
3
+ One footer, many extensions: build flexible Pi footers from reusable fragments with configurable layout and built-in fragments instead of competing `setFooter()` calls.
4
+
5
+ `pi-footer-manager` lets one extension own `ctx.ui.setFooter(...)` while built-in and custom fragments are arranged through a shared API, with flexible rows, regions, widths, alignment, and redraw/invalidation flow.
6
+
7
+ ![pi-footer-manager screenshot](./assets/pi-footer-manager.png)
8
+
9
+ ## Included extensions
10
+
11
+ - `footer-manager/index.ts` — cooperative footer owner
12
+ - `fragments/footer-timer-fragment.ts` — example fragment showing elapsed work time
13
+ - `fragments/quota-footer-fragment.ts` — richer quota usage display for supported providers
14
+ - `fragments/quota-footer-fragment-text.ts` — text-focused quota usage display
15
+ - `fragments/context-gauge-text-fragment.ts` — text version of context usage
16
+
17
+ ## How configuration works
18
+
19
+ Layout is configured under `footerManager.layout` in Pi settings.
20
+
21
+ - `separator` controls how fragments are joined inside a region
22
+ - `rows` is an array of footer rows
23
+ - each row has `regions`
24
+ - each region can set:
25
+ - `width`: fraction like `0.35` or `"auto"`
26
+ - `align`: `"left"`, `"center"`, or `"right"`
27
+ - `fragments`: fragment ids to render in that region
28
+
29
+ Project settings override global settings.
30
+
31
+ ## Example: simple two-region footer
32
+
33
+ ```json
34
+ {
35
+ "footerManager": {
36
+ "layout": {
37
+ "separator": " > ",
38
+ "rows": [
39
+ {
40
+ "regions": [
41
+ { "width": 0.65, "align": "left", "fragments": ["cwd.full", "git.branch", "context.gauge"] },
42
+ { "width": 0.35, "align": "right", "fragments": ["model.name", "thinking.level", "statuses"] }
43
+ ]
44
+ }
45
+ ]
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## Example: mostly automatic sizing
52
+
53
+ ```json
54
+ {
55
+ "footerManager": {
56
+ "layout": {
57
+ "rows": [
58
+ {
59
+ "regions": [
60
+ { "align": "left", "fragments": ["cwd.full", "git.branch"] },
61
+ { "width": 0.35, "align": "right", "fragments": ["model.name", "statuses"] }
62
+ ]
63
+ }
64
+ ]
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ In mixed layouts, fixed fractional regions are allocated first and the remaining width goes to `"auto"` regions.
71
+
72
+ ## Example: multi-row footer
73
+
74
+ ```json
75
+ {
76
+ "footerManager": {
77
+ "layout": {
78
+ "rows": [
79
+ {
80
+ "regions": [
81
+ { "align": "left", "fragments": ["cwd.full", "git.branch"] },
82
+ { "align": "right", "fragments": ["model.name", "thinking.level"] }
83
+ ]
84
+ },
85
+ {
86
+ "regions": [
87
+ { "align": "left", "fragments": ["context.gauge.text", "quota.current.text", "timer.work"] }
88
+ ]
89
+ }
90
+ ]
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ## Widths and overflow
97
+
98
+ Think of each row as a fixed-width bar split into regions:
99
+
100
+ ```text
101
+ [ left region ][ right region ]
102
+ ```
103
+
104
+ For this layout:
105
+
106
+ ```json
107
+ {
108
+ "regions": [
109
+ { "align": "left", "fragments": ["cwd.full", "git.branch"] },
110
+ { "width": 0.35, "align": "right", "fragments": ["model.name", "statuses"] }
111
+ ]
112
+ }
113
+ ```
114
+
115
+ The row behaves roughly like this:
116
+
117
+ ```text
118
+ [ cwd.full > git.branch ][ model.name > statuses ]
119
+ ```
120
+
121
+ Rules of thumb:
122
+
123
+ - fixed fractional regions get their width first
124
+ - `"auto"` regions get the remaining space
125
+ - if content does not fit, fragments are dropped before the final visible fragment is truncated
126
+ - left- and center-aligned regions drop fragments from the right
127
+ - right-aligned regions drop fragments from the left
128
+
129
+ Multi-row layouts behave like stacked bars:
130
+
131
+ ```text
132
+ row 1: [ cwd.full > git.branch ][ model.name > statuses ]
133
+ row 2: [ context.gauge.text > timer.work ]
134
+ ```
135
+
136
+ ## Built-in fragments
137
+
138
+ Main built-ins provided by `footer-manager` include:
139
+
140
+ - `cwd.full` — full path of the current working directory
141
+ - `git.branch` — current Git branch for the active working tree
142
+ - `model.name` — active model name
143
+ - `model.cost` — input/output token pricing for the active model
144
+ - `model.cacheCost` — cached token read/write pricing for the active model
145
+ - `cache.hit` — cache hit rate summary
146
+ - `cache.hit_counts` — cache hit rate with read/write token counts
147
+ - `thinking.level` — current reasoning/thinking level
148
+ - `context.gauge` — graphical context usage indicator
149
+ - `cost.total` — total accumulated session cost
150
+ - `statuses` — status items contributed through Pi status APIs
151
+
152
+ The included fragment extensions add examples like:
153
+
154
+ - `timer.work` — elapsed time for the current agent run
155
+ - `context.gauge.text` — text version of context usage
156
+ - `quota.current` — quota usage summary for supported providers
157
+ - `quota.current.text` — text-focused quota usage summary
158
+
159
+ ## Custom fragments
160
+
161
+ Other extensions should register fragments instead of calling `ctx.ui.setFooter(...)` directly.
162
+
163
+ See detailed fragment API docs and examples in:
164
+
165
+ - [`footer-manager/README.md`](./footer-manager/README.md)
166
+
167
+ ## Check
168
+
169
+ ```bash
170
+ npm run check
171
+ ```
172
+
173
+ ## Publish dry run
174
+
175
+ ```bash
176
+ npm run release:check
177
+ ```
Binary file
@@ -0,0 +1,125 @@
1
+ # footer-manager
2
+
3
+ `footer-manager` is the cooperative owner of Pi footer rendering.
4
+
5
+ It calls `ctx.ui.setFooter(...)` once, while other extensions contribute footer fragments through a shared registration API instead of competing to replace the whole footer. Pi cannot enforce this yet, so disable conflicting extensions that also call `ctx.ui.setFooter(...)` directly.
6
+
7
+ ## Register a fragment
8
+
9
+ ```ts
10
+ import {
11
+ FOOTER_MANAGER_REGISTER_FRAGMENT,
12
+ type FooterFragmentRegistration,
13
+ } from "./footer-manager/types";
14
+
15
+ export default function (pi) {
16
+ pi.on("session_start", async () => {
17
+ const fragment: FooterFragmentRegistration = {
18
+ id: "my-extension.timer",
19
+ label: "Timer",
20
+ component: (env) => ({
21
+ render() {
22
+ return env.theme.fg("accent", "12m");
23
+ },
24
+ dispose() {},
25
+ }),
26
+ };
27
+ pi.events.emit(FOOTER_MANAGER_REGISTER_FRAGMENT, fragment);
28
+ });
29
+ }
30
+ ```
31
+
32
+ Fragment factories and `render()` are synchronous. Do async work inside the fragment, cache state locally, then call `env.invalidate()` when the rendered output should refresh. Use `env.separator` when a fragment needs to join multiple internal values with the current layout separator.
33
+
34
+ ## Invalidate / redraw
35
+
36
+ ```ts
37
+ env.invalidate();
38
+ // or
39
+ pi.events.emit("footer-manager:invalidate", { id: "my-extension.timer" });
40
+ ```
41
+
42
+ Invalidations are coalesced and the manager owns `tui.requestRender()`.
43
+
44
+ ## Layout configuration
45
+
46
+ Settings live under `footerManager.layout`.
47
+
48
+ - `separator` controls how fragments are joined inside a region
49
+ - `rows` is an array of footer rows
50
+ - each row has `regions`
51
+ - each region can set:
52
+ - `width`: a fraction like `0.35` or `"auto"`
53
+ - `align`: `"left"`, `"center"`, or `"right"`
54
+ - `fragments`: fragment ids to render in that region
55
+
56
+ Example:
57
+
58
+ ```json
59
+ {
60
+ "footerManager": {
61
+ "layout": {
62
+ "separator": " > ",
63
+ "rows": [
64
+ {
65
+ "regions": [
66
+ { "width": 0.65, "align": "left", "fragments": ["cwd.full", "git.branch", "context.gauge"] },
67
+ { "width": 0.35, "align": "right", "fragments": ["model.name", "thinking.level", "statuses"] }
68
+ ]
69
+ }
70
+ ]
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ Project settings override global settings. If project `footerManager` exists but is invalid, the default layout is used; global `footerManager` is not merged as fallback.
77
+
78
+ ## Widths and overflow
79
+
80
+ Widths are optional and default to `"auto"`.
81
+
82
+ - fractional regions get their width first
83
+ - `"auto"` regions get the remaining width
84
+ - if content does not fit, fragments are dropped before the last visible fragment is truncated
85
+ - left- and center-aligned regions drop fragments from the right
86
+ - right-aligned regions drop fragments from the left
87
+
88
+ Mixed example:
89
+
90
+ ```json
91
+ {
92
+ "regions": [
93
+ { "align": "left", "fragments": ["cwd.full", "git.branch"] },
94
+ { "width": 0.35, "align": "right", "fragments": ["model.name", "statuses"] }
95
+ ]
96
+ }
97
+ ```
98
+
99
+ This behaves roughly like:
100
+
101
+ ```text
102
+ [ cwd.full > git.branch ][ model.name > statuses ]
103
+ ```
104
+
105
+ For rows without auto regions, widths should sum to `1`. Positive non-`1` sums are normalized with a warning. Invalid rows, invalid regions, or zero-width fully fixed rows fall back to the built-in default layout.
106
+
107
+ ## Built-in fragments
108
+
109
+ - `cwd.full` — full path of the current working directory
110
+ - `git.branch` — current Git branch for the active working tree
111
+ - `model.name` — active model name
112
+ - `model.cost` — input/output token pricing for the active model
113
+ - `model.cacheCost` — cached token read/write pricing for the active model
114
+ - `cache.hit` — cache hit rate summary
115
+ - `cache.hit_counts` — cache hit rate with read/write token counts
116
+ - `thinking.level` — current reasoning/thinking level
117
+ - `context.gauge` — graphical context usage indicator
118
+ - `cost.total` — total accumulated session cost
119
+ - `statuses` — status items contributed through Pi status APIs
120
+
121
+ `statuses` preserves compatibility with extensions using `ctx.ui.setStatus(...)` by joining status values in insertion order with the layout separator.
122
+
123
+ ## Example extension
124
+
125
+ `../fragments/footer-timer-fragment.ts` registers `timer.work` through the event bus and demonstrates cached state plus `env.invalidate()`. Add `"timer.work"` to a layout region to show it.
@@ -0,0 +1,144 @@
1
+ import { buildSessionContext, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { homedir } from "node:os";
3
+ import type { FooterFragmentRegistration, FooterRenderEnv } from "./types.js";
4
+
5
+ export type BuiltInFragmentsOptions = {
6
+ getSeparator: () => string;
7
+ };
8
+
9
+ function formatTokens(tokens: number): string {
10
+ if (!Number.isFinite(tokens) || tokens <= 0) return "0";
11
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
12
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
13
+ return `${Math.round(tokens)}`;
14
+ }
15
+
16
+ function formatCost(cost: number): string {
17
+ if (!Number.isFinite(cost) || cost <= 0) return "$0.000";
18
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
19
+ if (cost < 1) return `$${cost.toFixed(3)}`;
20
+ return `$${cost.toFixed(2)}`;
21
+ }
22
+
23
+ function formatModelRate(rate: unknown): string {
24
+ const value = Number(rate) || 0;
25
+ if (value === 0) return "0";
26
+ if (Math.abs(value) < 0.01) return value.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
27
+ return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
28
+ }
29
+
30
+ function collapseHome(cwd: string): string {
31
+ const home = homedir();
32
+ return home && (cwd === home || cwd.startsWith(home + "/")) ? `~${cwd.slice(home.length)}` : cwd;
33
+ }
34
+
35
+ function compactModel(ctx: ExtensionContext): string {
36
+ const model = ctx.model;
37
+ const id = typeof model?.id === "string" ? model.id : "no-model";
38
+ const provider = typeof model?.provider === "string" ? model.provider : undefined;
39
+ const base = id.split("/").filter(Boolean).pop() || id;
40
+ if (!provider) return base;
41
+ return id.toLowerCase().includes(provider.toLowerCase()) ? base : `${provider}/${base}`;
42
+ }
43
+
44
+ function renderModelCost(ctx: ExtensionContext): string {
45
+ const cost = ctx.model?.cost;
46
+ if (!cost) return "";
47
+ return `↑$${formatModelRate(cost.input)} ↓$${formatModelRate(cost.output)}`;
48
+ }
49
+
50
+ function renderModelCacheCost(ctx: ExtensionContext): string {
51
+ const cost = ctx.model?.cost;
52
+ if (!cost) return "";
53
+ const read = Number(cost.cacheRead) || 0;
54
+ const write = Number(cost.cacheWrite) || 0;
55
+ if (read === 0 && write === 0) return "";
56
+ return `R${formatModelRate(read)} W${formatModelRate(write)}`;
57
+ }
58
+
59
+ function getBranchAssistantUsage(ctx: ExtensionContext): { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number } {
60
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
61
+ try {
62
+ for (const entry of ctx.sessionManager.getBranch()) {
63
+ if ((entry as any).type !== "message") continue;
64
+ const message = (entry as any).message;
65
+ if (message?.role !== "assistant") continue;
66
+ const usage = message.usage;
67
+ totals.input += Number(usage?.input) || 0;
68
+ totals.output += Number(usage?.output) || 0;
69
+ totals.cacheRead += Number(usage?.cacheRead) || 0;
70
+ totals.cacheWrite += Number(usage?.cacheWrite) || 0;
71
+ totals.cost += Number(usage?.cost?.total) || 0;
72
+ }
73
+ } catch {}
74
+ return totals;
75
+ }
76
+
77
+ function getCacheHit(ctx: ExtensionContext): { hitPercent: number; cacheRead: number; cacheWrite: number } | undefined {
78
+ const { input, cacheRead, cacheWrite } = getBranchAssistantUsage(ctx);
79
+ if (cacheRead === 0 && cacheWrite === 0) return undefined;
80
+ const denominator = input + cacheRead;
81
+ const hitPercent = denominator > 0 ? Math.round((cacheRead / denominator) * 100) : 0;
82
+ return { hitPercent, cacheRead, cacheWrite };
83
+ }
84
+
85
+ function renderCacheHit(ctx: ExtensionContext): string {
86
+ const cache = getCacheHit(ctx);
87
+ return cache ? `cache ${cache.hitPercent}%` : "";
88
+ }
89
+
90
+ function renderCacheHitCounts(ctx: ExtensionContext): string {
91
+ const cache = getCacheHit(ctx);
92
+ return cache ? `cache ${cache.hitPercent}% R${formatTokens(cache.cacheRead)}/W${formatTokens(cache.cacheWrite)}` : "";
93
+ }
94
+
95
+ function getThinkingLevel(ctx: ExtensionContext): string {
96
+ try {
97
+ const context = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
98
+ return context.thinkingLevel || "off";
99
+ } catch {
100
+ return "off";
101
+ }
102
+ }
103
+
104
+ function getContextInfo(ctx: ExtensionContext): { percentage: number; used?: number; total?: number } {
105
+ try {
106
+ const contextWindow = Number(ctx.model?.contextWindow) || 0;
107
+ if (contextWindow <= 0) return { percentage: 0 };
108
+ const context = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
109
+ const lastAssistant = [...context.messages].reverse().find((m: any) => m.role === "assistant" && m.stopReason !== "aborted") as any;
110
+ const usage = lastAssistant?.usage;
111
+ if (!usage) return { percentage: 0, used: 0, total: contextWindow };
112
+ const used = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
113
+ return { percentage: (used / contextWindow) * 100, used, total: contextWindow };
114
+ } catch {
115
+ return { percentage: 0 };
116
+ }
117
+ }
118
+
119
+ function renderContextGauge(ctx: ExtensionContext, theme: Theme): string {
120
+ const { percentage, used, total } = getContextInfo(ctx);
121
+ const width = 8;
122
+ const clamped = Math.max(0, Math.min(100, percentage));
123
+ const filled = Math.round((clamped / 100) * width);
124
+ const color = clamped >= 90 ? "error" : clamped >= 70 ? "warning" : clamped >= 50 ? "accent" : "success";
125
+ const bar = theme.fg(color, "━".repeat(filled)) + theme.fg("dim", "─".repeat(width - filled));
126
+ const counts = used !== undefined && total ? ` ${formatTokens(used)}/${formatTokens(total)}` : "";
127
+ return `${theme.fg("dim", "ctx ")}${bar} ${theme.fg("dim", `${Math.round(clamped)}%${counts}`)}`;
128
+ }
129
+
130
+ export function createBuiltInFragments(options: BuiltInFragmentsOptions): FooterFragmentRegistration[] {
131
+ return [
132
+ { id: "cwd.full", label: "CWD", component: ({ ctx, theme }: FooterRenderEnv) => ({ render: () => theme.fg("accent", collapseHome(ctx.cwd || process.cwd())) }) },
133
+ { id: "git.branch", label: "Git branch", component: ({ footerData, theme }: FooterRenderEnv) => ({ render: () => { const branch = footerData.getGitBranch(); return branch ? theme.fg("success", branch) : ""; } }) },
134
+ { id: "model.name", label: "Model", component: ({ ctx, theme }: FooterRenderEnv) => ({ render: () => theme.fg("muted", compactModel(ctx)) }) },
135
+ { id: "model.cost", label: "Model cost", component: ({ ctx, theme }: FooterRenderEnv) => ({ render: () => theme.fg("dim", renderModelCost(ctx)) }) },
136
+ { id: "model.cacheCost", label: "Model cache cost", component: ({ ctx, theme }: FooterRenderEnv) => ({ render: () => theme.fg("dim", renderModelCacheCost(ctx)) }) },
137
+ { id: "cache.hit", label: "Cache hit", component: ({ ctx, theme }: FooterRenderEnv) => ({ render: () => theme.fg("dim", renderCacheHit(ctx)) }) },
138
+ { id: "cache.hit_counts", label: "Cache hit with counts", component: ({ ctx, theme }: FooterRenderEnv) => ({ render: () => theme.fg("dim", renderCacheHitCounts(ctx)) }) },
139
+ { id: "thinking.level", label: "Thinking", component: ({ ctx, theme }: FooterRenderEnv) => ({ render: () => theme.fg("accent", getThinkingLevel(ctx)) }) },
140
+ { id: "context.gauge", label: "Context", component: ({ ctx, theme }: FooterRenderEnv) => ({ render: () => renderContextGauge(ctx, theme) }) },
141
+ { id: "cost.total", label: "Total cost", component: ({ ctx, theme }: FooterRenderEnv) => ({ render: () => theme.fg("dim", formatCost(getBranchAssistantUsage(ctx).cost)) }) },
142
+ { id: "statuses", label: "Statuses", component: ({ footerData, theme }: FooterRenderEnv) => ({ render: () => Array.from(footerData.getExtensionStatuses().values()).filter(Boolean).join(theme.fg("dim", options.getSeparator())) }) },
143
+ ];
144
+ }