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.
@@ -0,0 +1,483 @@
1
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ FOOTER_MANAGER_REGISTER_FRAGMENT,
4
+ FOOTER_MANAGER_UNREGISTER_FRAGMENT,
5
+ type FooterFragmentRegistration,
6
+ type FooterRenderEnv,
7
+ } from "../footer-manager/types.js";
8
+ import { execSync } from "node:child_process";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ interface RateWindow {
14
+ label: string;
15
+ usedPercent: number;
16
+ resetsIn?: string;
17
+ }
18
+
19
+ interface UsageSnapshot {
20
+ provider: string;
21
+ windows: RateWindow[];
22
+ error?: string;
23
+ fetchedAt: number;
24
+ }
25
+
26
+ const FRAGMENT_ID = "quota.current";
27
+ const USAGE_REFRESH_INTERVAL = 5 * 60_000;
28
+ const usageCache = new Map<string, UsageSnapshot>();
29
+
30
+ const PROVIDER_MAP: Record<string, string> = {
31
+ anthropic: "claude",
32
+ "openai-codex": "codex",
33
+ "github-copilot": "copilot",
34
+ "google-gemini-cli": "gemini",
35
+ minimax: "minimax",
36
+ "minimax-cn": "minimax-cn",
37
+ "kimi-coding": "kimi-coding",
38
+ openrouter: "openrouter",
39
+ };
40
+
41
+ function loadAuthJson(): Record<string, any> {
42
+ const authPath = join(homedir(), ".pi", "agent", "auth.json");
43
+ try {
44
+ if (existsSync(authPath)) return JSON.parse(readFileSync(authPath, "utf-8"));
45
+ } catch {}
46
+ return {};
47
+ }
48
+
49
+ function resolveAuthValue(value: unknown): string | undefined {
50
+ if (typeof value !== "string") return undefined;
51
+ const trimmed = value.trim();
52
+ if (!trimmed) return undefined;
53
+
54
+ if (trimmed.startsWith("!")) {
55
+ try {
56
+ return execSync(trimmed.slice(1), { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 2000 }).trim() || undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ if (/^[A-Z][A-Z0-9_]*$/.test(trimmed) && process.env[trimmed]) return process.env[trimmed];
63
+ return trimmed;
64
+ }
65
+
66
+ function getApiKey(providerKey: string, envVar: string): string | undefined {
67
+ if (process.env[envVar]) return process.env[envVar];
68
+ const entry = loadAuthJson()[providerKey];
69
+ if (!entry) return undefined;
70
+ return typeof entry === "string" ? resolveAuthValue(entry) : resolveAuthValue(entry.key ?? entry.access ?? entry.refresh);
71
+ }
72
+
73
+ function getClaudeToken(): string | undefined {
74
+ const auth = loadAuthJson();
75
+ if (auth.anthropic?.access) return auth.anthropic.access;
76
+
77
+ try {
78
+ const keychainData = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', {
79
+ encoding: "utf-8",
80
+ stdio: ["pipe", "pipe", "pipe"],
81
+ timeout: 2000,
82
+ }).trim();
83
+ if (keychainData) return JSON.parse(keychainData).claudeAiOauth?.accessToken;
84
+ } catch {}
85
+
86
+ return undefined;
87
+ }
88
+
89
+ function getCopilotToken(): string | undefined {
90
+ return loadAuthJson()["github-copilot"]?.refresh;
91
+ }
92
+
93
+ function getCodexToken(): { token: string; accountId?: string } | undefined {
94
+ const auth = loadAuthJson();
95
+ if (auth["openai-codex"]?.access) {
96
+ return { token: auth["openai-codex"].access, accountId: auth["openai-codex"]?.accountId };
97
+ }
98
+
99
+ const codexPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json");
100
+ try {
101
+ if (!existsSync(codexPath)) return undefined;
102
+ const data = JSON.parse(readFileSync(codexPath, "utf-8"));
103
+ if (data.OPENAI_API_KEY) return { token: data.OPENAI_API_KEY };
104
+ if (data.tokens?.access_token) return { token: data.tokens.access_token, accountId: data.tokens.account_id };
105
+ } catch {}
106
+
107
+ return undefined;
108
+ }
109
+
110
+ function getGeminiToken(): string | undefined {
111
+ const auth = loadAuthJson();
112
+ if (auth["google-gemini-cli"]?.access) return auth["google-gemini-cli"].access;
113
+
114
+ const geminiPath = join(homedir(), ".gemini", "oauth_creds.json");
115
+ try {
116
+ if (existsSync(geminiPath)) return JSON.parse(readFileSync(geminiPath, "utf-8")).access_token;
117
+ } catch {}
118
+
119
+ return undefined;
120
+ }
121
+
122
+ function getMinimaxToken(provider: "minimax" | "minimax-cn"): string | undefined {
123
+ return provider === "minimax" ? getApiKey("minimax", "MINIMAX_API_KEY") : getApiKey("minimax-cn", "MINIMAX_CN_API_KEY");
124
+ }
125
+
126
+ function getKimiToken(): string | undefined {
127
+ return getApiKey("kimi-coding", "KIMI_API_KEY");
128
+ }
129
+
130
+ function getOpenRouterToken(): string | undefined {
131
+ return getApiKey("openrouter", "OPENROUTER_API_KEY");
132
+ }
133
+
134
+ function clampPercent(value: number): number {
135
+ if (!Number.isFinite(value)) return 0;
136
+ return Math.max(0, Math.min(100, value));
137
+ }
138
+
139
+ function normalizePercent(value: number): number {
140
+ if (!Number.isFinite(value)) return 0;
141
+ return clampPercent(value <= 1 && value >= 0 ? value * 100 : value);
142
+ }
143
+
144
+ function formatResetTime(date: Date): string {
145
+ const diffMins = Math.floor((date.getTime() - Date.now()) / 60_000);
146
+ if (diffMins < 0) return "now";
147
+ if (diffMins < 60) return `${diffMins}m`;
148
+ const hours = Math.floor(diffMins / 60);
149
+ const mins = diffMins % 60;
150
+ if (hours < 24) return mins ? `${hours}h${mins}m` : `${hours}h`;
151
+ const days = Math.floor(hours / 24);
152
+ const rest = hours % 24;
153
+ return rest ? `${days}d${rest}h` : `${days}d`;
154
+ }
155
+
156
+ function getWindowLabel(durationMs: number | undefined, fallback: string): string {
157
+ if (!durationMs || !Number.isFinite(durationMs) || durationMs <= 0) return fallback;
158
+ const hourMs = 60 * 60 * 1000;
159
+ const dayMs = 24 * hourMs;
160
+ const weekMs = 7 * dayMs;
161
+ if (Math.abs(durationMs - weekMs) <= hourMs * 2 || fallback === "Week") return "Week";
162
+ if (Math.abs(durationMs - dayMs) <= hourMs * 2 || fallback === "Day") return "Day";
163
+ if (Math.abs(durationMs - 5 * hourMs) <= hourMs * 2 || fallback === "5h") return fallback;
164
+ const hours = Math.round(durationMs / hourMs);
165
+ if (hours >= 1 && hours < 48) return `${hours}h`;
166
+ const days = Math.round(durationMs / dayMs);
167
+ return days >= 1 ? `${days}d` : `${Math.max(1, Math.round(durationMs / 60_000))}m`;
168
+ }
169
+
170
+ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 5000): Promise<Response> {
171
+ const controller = new AbortController();
172
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
173
+ try {
174
+ return await fetch(url, { ...init, signal: controller.signal });
175
+ } finally {
176
+ clearTimeout(timeout);
177
+ }
178
+ }
179
+
180
+ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
181
+ const token = getClaudeToken();
182
+ if (!token) return { provider: "Claude", windows: [], error: "no-auth", fetchedAt: Date.now() };
183
+
184
+ try {
185
+ const res = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
186
+ headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
187
+ });
188
+ if (!res.ok) return { provider: "Claude", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
189
+ const data = (await res.json()) as any;
190
+ const windows: RateWindow[] = [];
191
+ if (data.five_hour?.utilization !== undefined) {
192
+ windows.push({ label: "5h", usedPercent: normalizePercent(data.five_hour.utilization), resetsIn: data.five_hour.resets_at ? formatResetTime(new Date(data.five_hour.resets_at)) : undefined });
193
+ }
194
+ if (data.seven_day?.utilization !== undefined) {
195
+ windows.push({ label: "Week", usedPercent: normalizePercent(data.seven_day.utilization), resetsIn: data.seven_day.resets_at ? formatResetTime(new Date(data.seven_day.resets_at)) : undefined });
196
+ }
197
+ return { provider: "Claude", windows, fetchedAt: Date.now() };
198
+ } catch (error) {
199
+ return { provider: "Claude", windows: [], error: String(error), fetchedAt: Date.now() };
200
+ }
201
+ }
202
+
203
+ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
204
+ const token = getCopilotToken();
205
+ if (!token) return { provider: "Copilot", windows: [], error: "no-auth", fetchedAt: Date.now() };
206
+
207
+ try {
208
+ const res = await fetchWithTimeout("https://api.github.com/copilot_internal/user", {
209
+ headers: {
210
+ "Editor-Version": "vscode/1.96.2",
211
+ "User-Agent": "GitHubCopilotChat/0.26.7",
212
+ "X-Github-Api-Version": "2025-04-01",
213
+ Accept: "application/json",
214
+ Authorization: `token ${token}`,
215
+ },
216
+ });
217
+ if (!res.ok) return { provider: "Copilot", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
218
+ const data = (await res.json()) as any;
219
+ const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
220
+ const resetsIn = resetDate ? formatResetTime(resetDate) : undefined;
221
+ const windows: RateWindow[] = [];
222
+ if (data.quota_snapshots?.premium_interactions) windows.push({ label: "Premium", usedPercent: clampPercent(100 - (data.quota_snapshots.premium_interactions.percent_remaining || 0)), resetsIn });
223
+ if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) windows.push({ label: "Chat", usedPercent: clampPercent(100 - (data.quota_snapshots.chat.percent_remaining || 0)), resetsIn });
224
+ return { provider: "Copilot", windows, fetchedAt: Date.now() };
225
+ } catch (error) {
226
+ return { provider: "Copilot", windows: [], error: String(error), fetchedAt: Date.now() };
227
+ }
228
+ }
229
+
230
+ async function fetchCodexUsage(): Promise<UsageSnapshot> {
231
+ const creds = getCodexToken();
232
+ if (!creds) return { provider: "Codex", windows: [], error: "no-auth", fetchedAt: Date.now() };
233
+
234
+ try {
235
+ const headers: Record<string, string> = { Authorization: `Bearer ${creds.token}`, "User-Agent": "pi-agent", Accept: "application/json" };
236
+ if (creds.accountId) headers["ChatGPT-Account-Id"] = creds.accountId;
237
+ const res = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", { method: "GET", headers });
238
+ if (!res.ok) return { provider: "Codex", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
239
+ const data = (await res.json()) as any;
240
+ const windows: RateWindow[] = [];
241
+ if (data.rate_limit?.primary_window) {
242
+ const pw = data.rate_limit.primary_window;
243
+ windows.push({ label: getWindowLabel(typeof pw.limit_window_seconds === "number" ? pw.limit_window_seconds * 1000 : undefined, "5h"), usedPercent: clampPercent(pw.used_percent || 0), resetsIn: pw.reset_at ? formatResetTime(new Date(pw.reset_at * 1000)) : undefined });
244
+ }
245
+ if (data.rate_limit?.secondary_window) {
246
+ const sw = data.rate_limit.secondary_window;
247
+ windows.push({ label: getWindowLabel(typeof sw.limit_window_seconds === "number" ? sw.limit_window_seconds * 1000 : undefined, "Week"), usedPercent: clampPercent(sw.used_percent || 0), resetsIn: sw.reset_at ? formatResetTime(new Date(sw.reset_at * 1000)) : undefined });
248
+ }
249
+ return { provider: "Codex", windows, fetchedAt: Date.now() };
250
+ } catch (error) {
251
+ return { provider: "Codex", windows: [], error: String(error), fetchedAt: Date.now() };
252
+ }
253
+ }
254
+
255
+ async function fetchGeminiUsage(): Promise<UsageSnapshot> {
256
+ const token = getGeminiToken();
257
+ if (!token) return { provider: "Gemini", windows: [], error: "no-auth", fetchedAt: Date.now() };
258
+
259
+ try {
260
+ const res = await fetchWithTimeout("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
261
+ method: "POST",
262
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
263
+ body: "{}",
264
+ });
265
+ if (!res.ok) return { provider: "Gemini", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
266
+ const data = (await res.json()) as any;
267
+ const quotas: Record<string, number> = {};
268
+ for (const bucket of data.buckets || []) {
269
+ const model = bucket.modelId || "unknown";
270
+ const frac = bucket.remainingFraction ?? 1;
271
+ if (!quotas[model] || frac < quotas[model]) quotas[model] = frac;
272
+ }
273
+ let proMin = 1;
274
+ let flashMin = 1;
275
+ let hasPro = false;
276
+ let hasFlash = false;
277
+ for (const [model, frac] of Object.entries(quotas)) {
278
+ if (model.toLowerCase().includes("pro")) {
279
+ hasPro = true;
280
+ if (frac < proMin) proMin = frac;
281
+ }
282
+ if (model.toLowerCase().includes("flash")) {
283
+ hasFlash = true;
284
+ if (frac < flashMin) flashMin = frac;
285
+ }
286
+ }
287
+ const windows: RateWindow[] = [];
288
+ if (hasPro) windows.push({ label: "Pro", usedPercent: clampPercent((1 - proMin) * 100) });
289
+ if (hasFlash) windows.push({ label: "Flash", usedPercent: clampPercent((1 - flashMin) * 100) });
290
+ return { provider: "Gemini", windows, fetchedAt: Date.now() };
291
+ } catch (error) {
292
+ return { provider: "Gemini", windows: [], error: String(error), fetchedAt: Date.now() };
293
+ }
294
+ }
295
+
296
+ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<UsageSnapshot> {
297
+ const token = getMinimaxToken(provider);
298
+ const providerLabel = provider === "minimax-cn" ? "MiniMax CN" : "MiniMax";
299
+ const endpoint = provider === "minimax-cn" ? "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" : "https://api.minimax.io/v1/api/openplatform/coding_plan/remains";
300
+ if (!token) return { provider: providerLabel, windows: [], error: "no-auth", fetchedAt: Date.now() };
301
+
302
+ try {
303
+ const res = await fetchWithTimeout(endpoint, { method: "GET", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
304
+ if (!res.ok) return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
305
+ const data = (await res.json()) as any;
306
+ const remains = Array.isArray(data?.model_remains) ? data.model_remains : [];
307
+ const bucket = remains.find((entry: any) => typeof entry?.model_name === "string" && /^minimax-m/i.test(entry.model_name)) || remains.find((entry: any) => typeof entry?.model_name === "string" && /minimax/i.test(entry.model_name)) || remains[0];
308
+ if (!bucket) return { provider: providerLabel, windows: [], error: "no-usage-data", fetchedAt: Date.now() };
309
+ const windows: RateWindow[] = [];
310
+ const intervalTotal = Number(bucket.current_interval_total_count) || 0;
311
+ const intervalRemaining = Number(bucket.current_interval_usage_count) || 0;
312
+ if (intervalTotal > 0) windows.push({ label: getWindowLabel(bucket.start_time && bucket.end_time ? Number(bucket.end_time) - Number(bucket.start_time) : undefined, "5h"), usedPercent: clampPercent(((intervalTotal - intervalRemaining) / intervalTotal) * 100), resetsIn: bucket.end_time ? formatResetTime(new Date(Number(bucket.end_time))) : undefined });
313
+ const weeklyTotal = Number(bucket.current_weekly_total_count) || 0;
314
+ const weeklyRemaining = Number(bucket.current_weekly_usage_count) || 0;
315
+ if (weeklyTotal > 0) windows.push({ label: "Week", usedPercent: clampPercent(((weeklyTotal - weeklyRemaining) / weeklyTotal) * 100), resetsIn: bucket.weekly_end_time ? formatResetTime(new Date(Number(bucket.weekly_end_time))) : undefined });
316
+ return { provider: providerLabel, windows, fetchedAt: Date.now() };
317
+ } catch (error) {
318
+ return { provider: providerLabel, windows: [], error: String(error), fetchedAt: Date.now() };
319
+ }
320
+ }
321
+
322
+ async function fetchKimiUsage(): Promise<UsageSnapshot> {
323
+ const token = getKimiToken();
324
+ if (!token) return { provider: "Kimi Coding", windows: [], error: "no-auth", fetchedAt: Date.now() };
325
+
326
+ try {
327
+ const res = await fetchWithTimeout("https://api.kimi.com/coding/v1/usages", { method: "GET", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
328
+ if (!res.ok) return { provider: "Kimi Coding", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
329
+ const data = (await res.json()) as any;
330
+ const windows: RateWindow[] = [];
331
+ for (const limit of data.limits || []) {
332
+ const total = Number(limit.detail?.limit) || 0;
333
+ const remaining = Number(limit.detail?.remaining) || 0;
334
+ if (total > 0) windows.push({ label: getWindowLabel(limit.window?.duration && limit.window?.timeUnit === "TIME_UNIT_MINUTE" ? limit.window.duration * 60 * 1000 : undefined, "5h"), usedPercent: clampPercent(((total - remaining) / total) * 100), resetsIn: limit.detail?.resetTime ? formatResetTime(new Date(limit.detail.resetTime)) : undefined });
335
+ }
336
+ const weeklyLimit = Number(data.usage?.limit) || 0;
337
+ const weeklyRemaining = Number(data.usage?.remaining) || 0;
338
+ if (weeklyLimit > 0) windows.push({ label: "Weekly", usedPercent: clampPercent(((weeklyLimit - weeklyRemaining) / weeklyLimit) * 100), resetsIn: data.usage?.resetTime ? formatResetTime(new Date(data.usage.resetTime)) : undefined });
339
+ return { provider: "Kimi Coding", windows, fetchedAt: Date.now() };
340
+ } catch (error) {
341
+ return { provider: "Kimi Coding", windows: [], error: String(error), fetchedAt: Date.now() };
342
+ }
343
+ }
344
+
345
+ async function fetchOpenRouterUsage(): Promise<UsageSnapshot> {
346
+ const token = getOpenRouterToken();
347
+ if (!token) return { provider: "OpenRouter", windows: [], error: "no-auth", fetchedAt: Date.now() };
348
+
349
+ try {
350
+ const res = await fetchWithTimeout("https://openrouter.ai/api/v1/credits", {
351
+ method: "GET",
352
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
353
+ });
354
+ if (!res.ok) return { provider: "OpenRouter", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
355
+ const data = (await res.json()) as any;
356
+ const totalCredits = Number(data?.data?.total_credits);
357
+ const totalUsage = Number(data?.data?.total_usage);
358
+ if (!Number.isFinite(totalCredits) || totalCredits <= 0 || !Number.isFinite(totalUsage)) {
359
+ return { provider: "OpenRouter", windows: [], error: "no-usage-data", fetchedAt: Date.now() };
360
+ }
361
+
362
+ const remainingCredits = Math.max(0, totalCredits - totalUsage);
363
+ const usedPercent = clampPercent((totalUsage / totalCredits) * 100);
364
+ const remainingText = `$${remainingCredits.toFixed(2)} left`;
365
+ return {
366
+ provider: "OpenRouter",
367
+ windows: [{ label: "Credits", usedPercent, resetsIn: remainingText }],
368
+ fetchedAt: Date.now(),
369
+ };
370
+ } catch (error) {
371
+ return { provider: "OpenRouter", windows: [], error: String(error), fetchedAt: Date.now() };
372
+ }
373
+ }
374
+
375
+ async function fetchUsageForProvider(provider: string): Promise<UsageSnapshot> {
376
+ switch (provider) {
377
+ case "claude":
378
+ return fetchClaudeUsage();
379
+ case "codex":
380
+ return fetchCodexUsage();
381
+ case "copilot":
382
+ return fetchCopilotUsage();
383
+ case "gemini":
384
+ return fetchGeminiUsage();
385
+ case "minimax":
386
+ return fetchMinimaxUsage("minimax");
387
+ case "minimax-cn":
388
+ return fetchMinimaxUsage("minimax-cn");
389
+ case "kimi-coding":
390
+ return fetchKimiUsage();
391
+ case "openrouter":
392
+ return fetchOpenRouterUsage();
393
+ default:
394
+ return { provider: "Unknown", windows: [], error: "unknown-provider", fetchedAt: Date.now() };
395
+ }
396
+ }
397
+
398
+ function renderUsageBar(usedPercent: number, width: number, theme: Theme): string {
399
+ const clamped = clampPercent(usedPercent);
400
+ const filled = Math.round((clamped / 100) * width);
401
+ const color = clamped >= 92 ? "error" : clamped >= 85 ? "warning" : "success";
402
+ return theme.fg(color, "━".repeat(filled)) + theme.fg("dim", "─".repeat(width - filled));
403
+ }
404
+
405
+ function renderUsageWindow(window: RateWindow, theme: Theme): string {
406
+ const pct = `${Math.round(window.usedPercent)}%`;
407
+ const reset = window.resetsIn ? ` ${window.resetsIn}` : "";
408
+ return `${theme.fg("dim", window.label)} ${renderUsageBar(window.usedPercent, 8, theme)} ${theme.fg("dim", pct + reset)}`;
409
+ }
410
+
411
+ function renderUsage(usage: UsageSnapshot, theme: Theme, separator: string): string {
412
+ if (!usage.windows.length) return "";
413
+ return [theme.fg("accent", usage.provider), ...usage.windows.map((window) => renderUsageWindow(window, theme))].join(theme.fg("dim", separator));
414
+ }
415
+
416
+ export default function (pi: ExtensionAPI) {
417
+ let refreshCurrent: (() => void) | undefined;
418
+
419
+ const registration: FooterFragmentRegistration = {
420
+ id: FRAGMENT_ID,
421
+ label: "Current quota",
422
+ component: (env: FooterRenderEnv) => {
423
+ let latestUsage: UsageSnapshot | null = null;
424
+ let activeProvider: string | null = null;
425
+ let disposed = false;
426
+
427
+ const refresh = () => {
428
+ const modelProvider = env.ctx.model?.provider;
429
+ const provider = modelProvider ? PROVIDER_MAP[modelProvider] : undefined;
430
+ if (!provider) {
431
+ activeProvider = null;
432
+ latestUsage = null;
433
+ env.invalidate();
434
+ return;
435
+ }
436
+
437
+ activeProvider = provider;
438
+ const cached = usageCache.get(provider);
439
+ if (cached?.windows.length) {
440
+ latestUsage = cached;
441
+ env.invalidate();
442
+ }
443
+
444
+ fetchUsageForProvider(provider)
445
+ .then((usage) => {
446
+ if (disposed || activeProvider !== provider) return;
447
+ if (usage.windows.length === 0 && usage.error && cached?.windows.length) return;
448
+ usageCache.set(provider, usage);
449
+ latestUsage = usage;
450
+ env.invalidate();
451
+ })
452
+ .catch(() => {});
453
+ };
454
+
455
+ refreshCurrent = refresh;
456
+ refresh();
457
+ const interval = setInterval(refresh, USAGE_REFRESH_INTERVAL);
458
+
459
+ return {
460
+ render() {
461
+ return latestUsage ? renderUsage(latestUsage, env.theme, env.separator) : "";
462
+ },
463
+ dispose() {
464
+ disposed = true;
465
+ clearInterval(interval);
466
+ if (refreshCurrent === refresh) refreshCurrent = undefined;
467
+ },
468
+ };
469
+ },
470
+ };
471
+
472
+ pi.on("session_start", async () => {
473
+ pi.events.emit(FOOTER_MANAGER_REGISTER_FRAGMENT, registration);
474
+ });
475
+
476
+ pi.on("model_select", async () => {
477
+ refreshCurrent?.();
478
+ });
479
+
480
+ pi.on("session_shutdown", async () => {
481
+ pi.events.emit(FOOTER_MANAGER_UNREGISTER_FRAGMENT, { id: FRAGMENT_ID });
482
+ });
483
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "pi-footer-manager",
3
+ "version": "1.0.0",
4
+ "description": "One footer, many extensions: build flexible Pi footers from reusable fragments with configurable layout and built-in fragments instead of competing `setFooter()` calls. `pi-footer-manager` lets one extension own footer rendering while built-in and custom fragments are arranged through a shared API and flexible layout system.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "extension",
10
+ "footer-manager"
11
+ ],
12
+ "exports": "./footer-manager/index.ts",
13
+ "files": [
14
+ "footer-manager",
15
+ "fragments",
16
+ "assets",
17
+ "README.md"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sergiobonfiglio/pi-footer-manager.git"
22
+ },
23
+ "homepage": "https://github.com/sergiobonfiglio/pi-footer-manager#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/sergiobonfiglio/pi-footer-manager/issues"
26
+ },
27
+ "scripts": {
28
+ "check": "tsc -p tsconfig.json --noEmit",
29
+ "pack:dry": "npm pack --dry-run",
30
+ "release:check": "npm run check && npm run pack:dry",
31
+ "publish:public": "npm publish --access public"
32
+ },
33
+ "pi": {
34
+ "extensions": [
35
+ "./footer-manager/index.ts",
36
+ "./fragments/footer-timer-fragment.ts",
37
+ "./fragments/quota-footer-fragment.ts",
38
+ "./fragments/quota-footer-fragment-text.ts",
39
+ "./fragments/context-gauge-text-fragment.ts"
40
+ ]
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": "*",
44
+ "@earendil-works/pi-tui": "*"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^24.0.0",
48
+ "typescript": "^5.0.0"
49
+ }
50
+ }