@ogulcancelik/pi-minimal-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Can Celik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # pi-minimal-footer
2
+
3
+ Minimal footer for [pi](https://github.com/badlogic/pi-mono) that replaces the default footer with a compact two-line display: context gauge on top, subscription usage bars below.
4
+
5
+ ![Claude Max](assets/claude.png)
6
+
7
+ ![OpenAI Codex](assets/codex.png)
8
+
9
+ ## Features
10
+
11
+ - **Context gauge** — working directory, git branch, model, thinking level, and context window usage with token counts
12
+ - **Subscription usage bars** — rolling window quotas with reset timers for supported providers
13
+ - **Auto-refresh** — fetches usage on startup and model switch, then every 5 minutes
14
+ - **Git integration** — branch name, dirty state, ahead/behind counts (supports jj)
15
+
16
+ ## Supported providers
17
+
18
+ | Provider | What it shows |
19
+ |----------|---------------|
20
+ | Claude Max | 5h + weekly rolling windows |
21
+ | OpenAI Codex | Primary + secondary rolling windows |
22
+ | GitHub Copilot | Premium interactions + chat quotas |
23
+ | Google Gemini | Pro + Flash remaining quotas |
24
+ | MiniMax | 5h + weekly rolling windows (Token Plan / Coding Plan) |
25
+ | MiniMax CN | Same as MiniMax, China endpoint |
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pi install npm:@ogulcancelik/pi-minimal-footer
31
+ ```
32
+
33
+ ## How it works
34
+
35
+ The footer reads context usage from the last assistant message's token counts (free — comes with every LLM response). Subscription usage is fetched from each provider's dedicated quota API using your existing auth tokens from `~/.pi/agent/auth.json` or environment variables.
36
+
37
+ Usage is fetched:
38
+ - Once on startup
39
+ - Immediately on model switch (Ctrl+P)
40
+ - Every 5 minutes after that
41
+
42
+ The footer adapts to narrow terminals by stacking lines vertically instead of the single-line wide layout.
43
+
44
+ ## Notes
45
+
46
+ - Replaces the default pi footer entirely via `ctx.ui.setFooter()`
47
+ - Auth tokens are read from `~/.pi/agent/auth.json` (populated by `/login`) or standard env vars (`ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, etc.)
48
+ - Providers without auth simply don't show a usage bar — no errors
Binary file
Binary file
package/index.ts ADDED
@@ -0,0 +1,935 @@
1
+ /**
2
+ * Context Gauge Extension
3
+ *
4
+ * Custom footer with context gauge + subscription usage bars.
5
+ * Auto-detects provider from current model and shows relevant usage.
6
+ *
7
+ * Supports: Claude Max, Codex, Copilot, Gemini
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
+ import { buildSessionContext } from "@mariozechner/pi-coding-agent";
12
+ import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
13
+ import { execSync } from "node:child_process";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { homedir } from "node:os";
17
+
18
+ // ============ Types ============
19
+
20
+ interface RateWindow {
21
+ label: string;
22
+ usedPercent: number;
23
+ resetsIn?: string; // human readable "2h38m"
24
+ }
25
+
26
+ interface UsageSnapshot {
27
+ provider: string;
28
+ windows: RateWindow[];
29
+ error?: string;
30
+ fetchedAt: number;
31
+ }
32
+
33
+ interface GitCache {
34
+ branch: string | null;
35
+ dirty: boolean;
36
+ ahead: number;
37
+ behind: number;
38
+ isJj: boolean;
39
+ }
40
+
41
+ // ============ Usage Cache ============
42
+
43
+ const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes
44
+ const usageCache = new Map<string, UsageSnapshot>(); // keyed by provider
45
+
46
+ // ============ Identity Cache ============
47
+
48
+ let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved
49
+
50
+ // ============ Git Cache ============
51
+
52
+ let gitCache: GitCache | null = null;
53
+
54
+ function refreshGitCache(): void {
55
+ gitCache = null;
56
+
57
+ try {
58
+ const gitRoot = execSync("git rev-parse --show-toplevel 2>/dev/null", {
59
+ encoding: "utf8",
60
+ timeout: 500,
61
+ }).trim();
62
+
63
+ if (!gitRoot) return;
64
+
65
+ const isJj = existsSync(`${gitRoot}/.jj`);
66
+ let branch: string | null = null;
67
+
68
+ if (isJj) {
69
+ try {
70
+ const jjBranch = execSync(
71
+ `jj log -r 'heads(ancestors(@) & bookmarks())' --no-graph -T 'bookmarks.map(|b| b.name()).join(" ")' --limit 1 2>/dev/null`,
72
+ { encoding: "utf8", timeout: 1000 }
73
+ ).trim();
74
+ branch = jjBranch ? jjBranch.split(" ")[0] : null;
75
+ } catch {}
76
+ } else {
77
+ try {
78
+ const gitBranch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", {
79
+ encoding: "utf8",
80
+ timeout: 500,
81
+ }).trim();
82
+ branch = gitBranch && gitBranch !== "HEAD" ? gitBranch : null;
83
+ } catch {}
84
+ }
85
+
86
+ let dirty = false;
87
+ try {
88
+ const status = execSync("git status --porcelain 2>/dev/null", {
89
+ encoding: "utf8",
90
+ timeout: 500,
91
+ });
92
+ dirty = status.trim().length > 0;
93
+ } catch {}
94
+
95
+ let ahead = 0;
96
+ let behind = 0;
97
+ try {
98
+ const counts = execSync("git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null", {
99
+ encoding: "utf8",
100
+ timeout: 500,
101
+ }).trim();
102
+ const [a, b] = counts.split(/\s+/);
103
+ ahead = parseInt(a, 10) || 0;
104
+ behind = parseInt(b, 10) || 0;
105
+ } catch {}
106
+
107
+ gitCache = { branch, dirty, ahead, behind, isJj };
108
+ } catch {
109
+ gitCache = null;
110
+ }
111
+ }
112
+
113
+ // ============ JWT Helpers ============
114
+
115
+ function decodeJwtPayload(token: string): Record<string, any> | null {
116
+ try {
117
+ const parts = token.split(".");
118
+ if (parts.length < 2) return null;
119
+ let payload = parts[1];
120
+ // Add base64 padding
121
+ payload += "=".repeat((4 - (payload.length % 4)) % 4);
122
+ const decoded = Buffer.from(payload, "base64url").toString("utf-8");
123
+ return JSON.parse(decoded);
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function getEmailPrefixFromJwt(token: string): string | null {
130
+ const payload = decodeJwtPayload(token);
131
+ const email = payload?.["https://api.openai.com/profile"]?.email;
132
+ if (!email || typeof email !== "string") return null;
133
+ const prefix = email.split("@")[0];
134
+ return prefix || null;
135
+ }
136
+
137
+ // ============ Auth Loading ============
138
+
139
+ function loadAuthJson(): Record<string, any> {
140
+ const authPath = join(homedir(), ".pi", "agent", "auth.json");
141
+ try {
142
+ if (existsSync(authPath)) {
143
+ return JSON.parse(readFileSync(authPath, "utf-8"));
144
+ }
145
+ } catch {}
146
+ return {};
147
+ }
148
+
149
+ function resolveAuthValue(value: unknown): string | undefined {
150
+ if (typeof value !== "string") return undefined;
151
+ const trimmed = value.trim();
152
+ if (!trimmed) return undefined;
153
+
154
+ if (trimmed.startsWith("!")) {
155
+ try {
156
+ const output = execSync(trimmed.slice(1), {
157
+ encoding: "utf-8",
158
+ stdio: ["pipe", "pipe", "pipe"],
159
+ timeout: 2000,
160
+ }).trim();
161
+ return output || undefined;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }
166
+
167
+ if (/^[A-Z][A-Z0-9_]*$/.test(trimmed) && process.env[trimmed]) {
168
+ return process.env[trimmed];
169
+ }
170
+
171
+ return trimmed;
172
+ }
173
+
174
+ function getApiKey(providerKey: string, envVar: string): string | undefined {
175
+ if (process.env[envVar]) return process.env[envVar];
176
+
177
+ const auth = loadAuthJson();
178
+ const entry = auth[providerKey];
179
+ if (!entry) return undefined;
180
+
181
+ if (typeof entry === "string") {
182
+ return resolveAuthValue(entry);
183
+ }
184
+
185
+ return resolveAuthValue(entry.key ?? entry.access ?? entry.refresh);
186
+ }
187
+
188
+ function getClaudeToken(): string | undefined {
189
+ const auth = loadAuthJson();
190
+ if (auth.anthropic?.access) return auth.anthropic.access;
191
+
192
+ // Fallback: Claude CLI keychain (macOS)
193
+ try {
194
+ const keychainData = execSync(
195
+ 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
196
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
197
+ ).trim();
198
+ if (keychainData) {
199
+ const parsed = JSON.parse(keychainData);
200
+ if (parsed.claudeAiOauth?.accessToken) {
201
+ return parsed.claudeAiOauth.accessToken;
202
+ }
203
+ }
204
+ } catch {}
205
+
206
+ return undefined;
207
+ }
208
+
209
+ function getCopilotToken(): string | undefined {
210
+ const auth = loadAuthJson();
211
+ return auth["github-copilot"]?.refresh;
212
+ }
213
+
214
+ function getCodexToken(): { token: string; accountId?: string } | undefined {
215
+ const auth = loadAuthJson();
216
+ if (auth["openai-codex"]?.access) {
217
+ return { token: auth["openai-codex"].access, accountId: auth["openai-codex"]?.accountId };
218
+ }
219
+
220
+ // Fallback: ~/.codex/auth.json
221
+ const codexPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json");
222
+ try {
223
+ if (existsSync(codexPath)) {
224
+ const data = JSON.parse(readFileSync(codexPath, "utf-8"));
225
+ if (data.OPENAI_API_KEY) {
226
+ return { token: data.OPENAI_API_KEY };
227
+ }
228
+ if (data.tokens?.access_token) {
229
+ return { token: data.tokens.access_token, accountId: data.tokens.account_id };
230
+ }
231
+ }
232
+ } catch {}
233
+
234
+ return undefined;
235
+ }
236
+
237
+ function getGeminiToken(): string | undefined {
238
+ const auth = loadAuthJson();
239
+ if (auth["google-gemini-cli"]?.access) return auth["google-gemini-cli"].access;
240
+
241
+ // Fallback: ~/.gemini/oauth_creds.json
242
+ const geminiPath = join(homedir(), ".gemini", "oauth_creds.json");
243
+ try {
244
+ if (existsSync(geminiPath)) {
245
+ const data = JSON.parse(readFileSync(geminiPath, "utf-8"));
246
+ return data.access_token;
247
+ }
248
+ } catch {}
249
+
250
+ return undefined;
251
+ }
252
+
253
+ function getMinimaxToken(provider: "minimax" | "minimax-cn"): string | undefined {
254
+ return provider === "minimax"
255
+ ? getApiKey("minimax", "MINIMAX_API_KEY")
256
+ : getApiKey("minimax-cn", "MINIMAX_CN_API_KEY");
257
+ }
258
+
259
+ // ============ Time Formatting ============
260
+
261
+ function formatResetTime(date: Date): string {
262
+ const diffMs = date.getTime() - Date.now();
263
+ if (diffMs < 0) return "now";
264
+
265
+ const diffMins = Math.floor(diffMs / 60000);
266
+ if (diffMins < 60) return `${diffMins}m`;
267
+
268
+ const hours = Math.floor(diffMins / 60);
269
+ const mins = diffMins % 60;
270
+ if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
271
+
272
+ const days = Math.floor(hours / 24);
273
+ const remainingHours = hours % 24;
274
+ return remainingHours > 0 ? `${days}d${remainingHours}h` : `${days}d`;
275
+ }
276
+
277
+ /** Clamp a percentage to [0, 100]. Does NOT auto-normalize 0-1 fractions. */
278
+ function clampPercent(value: number): number {
279
+ if (!Number.isFinite(value)) return 0;
280
+ return Math.max(0, Math.min(100, value));
281
+ }
282
+
283
+ /** Normalize a value that might be 0-1 fraction OR 0-100 percent, then clamp. */
284
+ function normalizePercent(value: number): number {
285
+ if (!Number.isFinite(value)) return 0;
286
+ const normalized = value <= 1 && value >= 0 ? value * 100 : value;
287
+ return Math.max(0, Math.min(100, normalized));
288
+ }
289
+
290
+ function getWindowLabel(durationMs: number | undefined, fallback: string): string {
291
+ if (!durationMs || !Number.isFinite(durationMs) || durationMs <= 0) return fallback;
292
+
293
+ const hourMs = 60 * 60 * 1000;
294
+ const dayMs = 24 * hourMs;
295
+ const weekMs = 7 * dayMs;
296
+
297
+ // Check if duration is close to a standard window and use the fallback label.
298
+ // This preserves "5h" / "Week" / "Day" labels even when the actual
299
+ // rolling window start/end times don't align perfectly.
300
+ const isCloseToWeek = Math.abs(durationMs - weekMs) <= hourMs * 2;
301
+ const isCloseToDay = Math.abs(durationMs - dayMs) <= hourMs * 2;
302
+ const isCloseTo5h = Math.abs(durationMs - 5 * hourMs) <= hourMs * 2;
303
+
304
+ if (isCloseToWeek || fallback === "Week") return "Week";
305
+ if (isCloseToDay || fallback === "Day") return "Day";
306
+ if (isCloseTo5h || fallback === "5h") return fallback;
307
+
308
+ const hours = Math.round(durationMs / hourMs);
309
+ if (hours >= 1 && hours < 48) return `${hours}h`;
310
+
311
+ const days = Math.round(durationMs / dayMs);
312
+ if (days >= 1) return `${days}d`;
313
+
314
+ const mins = Math.max(1, Math.round(durationMs / 60000));
315
+ return `${mins}m`;
316
+ }
317
+
318
+ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 5000): Promise<Response> {
319
+ const controller = new AbortController();
320
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
321
+
322
+ try {
323
+ return await fetch(url, { ...init, signal: controller.signal });
324
+ } finally {
325
+ clearTimeout(timeout);
326
+ }
327
+ }
328
+
329
+ // ============ Usage Fetchers ============
330
+
331
+ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
332
+ const token = getClaudeToken();
333
+ if (!token) {
334
+ return { provider: "Claude", windows: [], error: "no-auth", fetchedAt: Date.now() };
335
+ }
336
+
337
+ try {
338
+ const res = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
339
+ headers: {
340
+ Authorization: `Bearer ${token}`,
341
+ "anthropic-beta": "oauth-2025-04-20",
342
+ },
343
+ });
344
+
345
+ if (!res.ok) {
346
+ return { provider: "Claude", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
347
+ }
348
+
349
+ const data = (await res.json()) as any;
350
+ const windows: RateWindow[] = [];
351
+
352
+ if (data.five_hour?.utilization !== undefined) {
353
+ windows.push({
354
+ label: "5h",
355
+ usedPercent: normalizePercent(data.five_hour.utilization),
356
+ resetsIn: data.five_hour.resets_at ? formatResetTime(new Date(data.five_hour.resets_at)) : undefined,
357
+ });
358
+ }
359
+
360
+ if (data.seven_day?.utilization !== undefined) {
361
+ windows.push({
362
+ label: "Week",
363
+ usedPercent: normalizePercent(data.seven_day.utilization),
364
+ resetsIn: data.seven_day.resets_at ? formatResetTime(new Date(data.seven_day.resets_at)) : undefined,
365
+ });
366
+ }
367
+
368
+ return { provider: "Claude", windows, fetchedAt: Date.now() };
369
+ } catch (e) {
370
+ return { provider: "Claude", windows: [], error: String(e), fetchedAt: Date.now() };
371
+ }
372
+ }
373
+
374
+ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
375
+ const token = getCopilotToken();
376
+ if (!token) {
377
+ return { provider: "Copilot", windows: [], error: "no-auth", fetchedAt: Date.now() };
378
+ }
379
+
380
+ try {
381
+ const res = await fetchWithTimeout("https://api.github.com/copilot_internal/user", {
382
+ headers: {
383
+ "Editor-Version": "vscode/1.96.2",
384
+ "User-Agent": "GitHubCopilotChat/0.26.7",
385
+ "X-Github-Api-Version": "2025-04-01",
386
+ Accept: "application/json",
387
+ Authorization: `token ${token}`,
388
+ },
389
+ });
390
+
391
+ if (!res.ok) {
392
+ return { provider: "Copilot", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
393
+ }
394
+
395
+ const data = (await res.json()) as any;
396
+ const windows: RateWindow[] = [];
397
+
398
+ const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
399
+ const resetsIn = resetDate ? formatResetTime(resetDate) : undefined;
400
+
401
+ if (data.quota_snapshots?.premium_interactions) {
402
+ const pi = data.quota_snapshots.premium_interactions;
403
+ const usedPercent = clampPercent(100 - (pi.percent_remaining || 0));
404
+ windows.push({ label: "Premium", usedPercent, resetsIn });
405
+ }
406
+
407
+ if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) {
408
+ const chat = data.quota_snapshots.chat;
409
+ windows.push({
410
+ label: "Chat",
411
+ usedPercent: clampPercent(100 - (chat.percent_remaining || 0)),
412
+ resetsIn,
413
+ });
414
+ }
415
+
416
+ return { provider: "Copilot", windows, fetchedAt: Date.now() };
417
+ } catch (e) {
418
+ return { provider: "Copilot", windows: [], error: String(e), fetchedAt: Date.now() };
419
+ }
420
+ }
421
+
422
+ async function fetchCodexUsage(): Promise<UsageSnapshot> {
423
+ const creds = getCodexToken();
424
+ if (!creds) {
425
+ return { provider: "Codex", windows: [], error: "no-auth", fetchedAt: Date.now() };
426
+ }
427
+
428
+ const providerLabel = "Codex";
429
+
430
+ try {
431
+ const headers: Record<string, string> = {
432
+ Authorization: `Bearer ${creds.token}`,
433
+ "User-Agent": "pi-agent",
434
+ Accept: "application/json",
435
+ };
436
+
437
+ if (creds.accountId) {
438
+ headers["ChatGPT-Account-Id"] = creds.accountId;
439
+ }
440
+
441
+ const res = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", {
442
+ method: "GET",
443
+ headers,
444
+ });
445
+
446
+ if (!res.ok) {
447
+ return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
448
+ }
449
+
450
+ const data = (await res.json()) as any;
451
+ const windows: RateWindow[] = [];
452
+
453
+ if (data.rate_limit?.primary_window) {
454
+ const pw = data.rate_limit.primary_window;
455
+ const resetDate = pw.reset_at ? new Date(pw.reset_at * 1000) : undefined;
456
+ const durationMs = typeof pw.limit_window_seconds === "number" ? pw.limit_window_seconds * 1000 : undefined;
457
+ windows.push({
458
+ label: getWindowLabel(durationMs, "5h"),
459
+ usedPercent: clampPercent(pw.used_percent || 0),
460
+ resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
461
+ });
462
+ }
463
+
464
+ if (data.rate_limit?.secondary_window) {
465
+ const sw = data.rate_limit.secondary_window;
466
+ const resetDate = sw.reset_at ? new Date(sw.reset_at * 1000) : undefined;
467
+ const durationMs = typeof sw.limit_window_seconds === "number" ? sw.limit_window_seconds * 1000 : undefined;
468
+ windows.push({
469
+ label: getWindowLabel(durationMs, "Week"),
470
+ usedPercent: clampPercent(sw.used_percent || 0),
471
+ resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
472
+ });
473
+ }
474
+
475
+ return { provider: providerLabel, windows, fetchedAt: Date.now() };
476
+ } catch (e) {
477
+ return { provider: providerLabel, windows: [], error: String(e), fetchedAt: Date.now() };
478
+ }
479
+ }
480
+
481
+ async function fetchGeminiUsage(): Promise<UsageSnapshot> {
482
+ const token = getGeminiToken();
483
+ if (!token) {
484
+ return { provider: "Gemini", windows: [], error: "no-auth", fetchedAt: Date.now() };
485
+ }
486
+
487
+ try {
488
+ const res = await fetchWithTimeout("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
489
+ method: "POST",
490
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
491
+ body: "{}",
492
+ });
493
+
494
+ if (!res.ok) {
495
+ return { provider: "Gemini", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
496
+ }
497
+
498
+ const data = (await res.json()) as any;
499
+ const quotas: Record<string, number> = {};
500
+
501
+ for (const bucket of data.buckets || []) {
502
+ const model = bucket.modelId || "unknown";
503
+ const frac = bucket.remainingFraction ?? 1;
504
+ if (!quotas[model] || frac < quotas[model]) quotas[model] = frac;
505
+ }
506
+
507
+ const windows: RateWindow[] = [];
508
+ let proMin = 1,
509
+ flashMin = 1;
510
+ let hasProModel = false,
511
+ hasFlashModel = false;
512
+
513
+ for (const [model, frac] of Object.entries(quotas)) {
514
+ if (model.toLowerCase().includes("pro")) {
515
+ hasProModel = true;
516
+ if (frac < proMin) proMin = frac;
517
+ }
518
+ if (model.toLowerCase().includes("flash")) {
519
+ hasFlashModel = true;
520
+ if (frac < flashMin) flashMin = frac;
521
+ }
522
+ }
523
+
524
+ if (hasProModel) windows.push({ label: "Pro", usedPercent: clampPercent((1 - proMin) * 100) });
525
+ if (hasFlashModel) windows.push({ label: "Flash", usedPercent: clampPercent((1 - flashMin) * 100) });
526
+
527
+ return { provider: "Gemini", windows, fetchedAt: Date.now() };
528
+ } catch (e) {
529
+ return { provider: "Gemini", windows: [], error: String(e), fetchedAt: Date.now() };
530
+ }
531
+ }
532
+
533
+ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<UsageSnapshot> {
534
+ const token = getMinimaxToken(provider);
535
+ const providerLabel = provider === "minimax-cn" ? "MiniMax CN" : "MiniMax";
536
+ const endpoint =
537
+ provider === "minimax-cn"
538
+ ? "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains"
539
+ : "https://api.minimax.io/v1/api/openplatform/coding_plan/remains";
540
+
541
+ if (!token) {
542
+ return { provider: providerLabel, windows: [], error: "no-auth", fetchedAt: Date.now() };
543
+ }
544
+
545
+ try {
546
+ const res = await fetchWithTimeout(endpoint, {
547
+ method: "GET",
548
+ headers: {
549
+ Authorization: `Bearer ${token}`,
550
+ "Content-Type": "application/json",
551
+ },
552
+ });
553
+
554
+ if (!res.ok) {
555
+ return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
556
+ }
557
+
558
+ const data = (await res.json()) as any;
559
+ const baseResp = data?.base_resp;
560
+ if (baseResp?.status_code && baseResp.status_code !== 0) {
561
+ return {
562
+ provider: providerLabel,
563
+ windows: [],
564
+ error: baseResp.status_msg || `API ${baseResp.status_code}`,
565
+ fetchedAt: Date.now(),
566
+ };
567
+ }
568
+
569
+ const remains = Array.isArray(data?.model_remains) ? data.model_remains : [];
570
+ const textBucket =
571
+ remains.find((entry: any) => typeof entry?.model_name === "string" && /^minimax-m/i.test(entry.model_name)) ||
572
+ remains.find((entry: any) => typeof entry?.model_name === "string" && /minimax/i.test(entry.model_name)) ||
573
+ remains[0];
574
+
575
+ if (!textBucket) {
576
+ return { provider: providerLabel, windows: [], error: "no-usage-data", fetchedAt: Date.now() };
577
+ }
578
+
579
+ const windows: RateWindow[] = [];
580
+
581
+ const intervalTotal = Number(textBucket.current_interval_total_count) || 0;
582
+ const intervalRemaining = Number(textBucket.current_interval_usage_count) || 0;
583
+ if (intervalTotal > 0) {
584
+ // current_*_usage_count = remaining (confirmed against MiniMax website UI)
585
+ const used = intervalTotal - intervalRemaining;
586
+ const usedPercent = clampPercent((used / intervalTotal) * 100);
587
+ const resetDate = textBucket.end_time ? new Date(Number(textBucket.end_time)) : undefined;
588
+ const durationMs =
589
+ textBucket.start_time && textBucket.end_time
590
+ ? Number(textBucket.end_time) - Number(textBucket.start_time)
591
+ : undefined;
592
+ windows.push({
593
+ label: getWindowLabel(durationMs, "5h"),
594
+ usedPercent,
595
+ resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
596
+ });
597
+ }
598
+
599
+ const weeklyTotal = Number(textBucket.current_weekly_total_count) || 0;
600
+ const weeklyRemaining = Number(textBucket.current_weekly_usage_count) || 0;
601
+ if (weeklyTotal > 0) {
602
+ // current_*_usage_count = remaining (confirmed against MiniMax website UI)
603
+ const used = weeklyTotal - weeklyRemaining;
604
+ const usedPercent = clampPercent((used / weeklyTotal) * 100);
605
+ const resetDate = textBucket.weekly_end_time ? new Date(Number(textBucket.weekly_end_time)) : undefined;
606
+ const durationMs =
607
+ textBucket.weekly_start_time && textBucket.weekly_end_time
608
+ ? Number(textBucket.weekly_end_time) - Number(textBucket.weekly_start_time)
609
+ : undefined;
610
+ windows.push({
611
+ label: getWindowLabel(durationMs, "Week"),
612
+ usedPercent,
613
+ resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
614
+ });
615
+ }
616
+
617
+ return { provider: providerLabel, windows, fetchedAt: Date.now() };
618
+ } catch (e) {
619
+ return { provider: providerLabel, windows: [], error: String(e), fetchedAt: Date.now() };
620
+ }
621
+ }
622
+
623
+ // ============ Provider Detection ============
624
+
625
+ // Map pi provider names to our internal usage provider keys
626
+ const PROVIDER_MAP: Record<string, string> = {
627
+ anthropic: "claude", // Claude Max subscription
628
+ "openai-codex": "codex", // Codex subscription
629
+ "github-copilot": "copilot", // Copilot subscription
630
+ "google-gemini-cli": "gemini", // Gemini CLI subscription
631
+ minimax: "minimax", // MiniMax Token Plan / Coding Plan
632
+ "minimax-cn": "minimax-cn", // MiniMax China plan
633
+ };
634
+
635
+ function detectProvider(modelProvider: string): string | null {
636
+ return PROVIDER_MAP[modelProvider] || null;
637
+ }
638
+
639
+ async function fetchUsageForProvider(provider: string): Promise<UsageSnapshot> {
640
+ switch (provider) {
641
+ case "claude":
642
+ return fetchClaudeUsage();
643
+ case "codex":
644
+ return fetchCodexUsage();
645
+ case "copilot":
646
+ return fetchCopilotUsage();
647
+ case "gemini":
648
+ return fetchGeminiUsage();
649
+ case "minimax":
650
+ return fetchMinimaxUsage("minimax");
651
+ case "minimax-cn":
652
+ return fetchMinimaxUsage("minimax-cn");
653
+ default:
654
+ return { provider: "Unknown", windows: [], error: "unknown-provider", fetchedAt: Date.now() };
655
+ }
656
+ }
657
+
658
+ // ============ Extension ============
659
+
660
+ export default function (pi: ExtensionAPI) {
661
+ const CTX_GAUGE_WIDTH = 12;
662
+
663
+ // Thin bar characters (same style for both context and usage)
664
+ const BAR_FILLED = "━";
665
+ const BAR_EMPTY = "─";
666
+
667
+ function formatTokenCount(tokens: number): string {
668
+ if (tokens >= 1_000_000) {
669
+ const m = tokens / 1_000_000;
670
+ return m % 1 === 0 ? `${m}M` : `${m.toFixed(1).replace(/\.0$/, "")}M`;
671
+ }
672
+ if (tokens >= 1_000) {
673
+ return `${Math.round(tokens / 1_000)}k`;
674
+ }
675
+ return `${tokens}`;
676
+ }
677
+
678
+ function renderContextGauge(percentage: number, theme: any, used?: number, total?: number): string {
679
+ const clamped = Math.max(0, Math.min(100, percentage));
680
+ const filled = Math.round((clamped / 100) * CTX_GAUGE_WIDTH);
681
+ const empty = CTX_GAUGE_WIDTH - filled;
682
+
683
+ let color: string;
684
+ if (clamped >= 90) color = "error";
685
+ else if (clamped >= 70) color = "warning";
686
+ else if (clamped >= 50) color = "accent";
687
+ else color = "success";
688
+
689
+ const bar = theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty));
690
+ const pct = `${Math.round(clamped)}%`;
691
+ const counts = used !== undefined && total ? ` ${formatTokenCount(used)}/${formatTokenCount(total)}` : "";
692
+
693
+ return theme.fg("dim", "ctx ") + bar + " " + theme.fg("dim", pct + counts);
694
+ }
695
+
696
+ function renderUsageBar(usedPercent: number, barWidth: number, theme: any): string {
697
+ const clamped = Math.max(0, Math.min(100, usedPercent));
698
+ const filled = Math.round((clamped / 100) * barWidth);
699
+ const empty = barWidth - filled;
700
+
701
+ let color: string;
702
+ if (clamped >= 92) color = "error";
703
+ else if (clamped >= 85) color = "warning";
704
+ else color = "success";
705
+
706
+ return theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty));
707
+ }
708
+
709
+ function renderUsageLine(usage: UsageSnapshot, width: number, theme: any): string[] {
710
+ if (!usage.windows.length) return [];
711
+
712
+ const dim = (s: string) => theme.fg("dim", s);
713
+ const sep = " " + dim(">") + " ";
714
+ const USAGE_BAR_WIDTH = 10;
715
+ const NARROW_THRESHOLD = 60;
716
+
717
+ if (width < NARROW_THRESHOLD) {
718
+ // Narrow: one line per window
719
+ const lines: string[] = [];
720
+ for (const w of usage.windows) {
721
+ const bar = renderUsageBar(w.usedPercent, USAGE_BAR_WIDTH, theme);
722
+ const pct = dim(`${Math.round(w.usedPercent)}%`);
723
+ const timeStr = w.resetsIn ? " " + dim(w.resetsIn) : "";
724
+ lines.push(`${dim(w.label)} ${bar} ${pct}${timeStr}`);
725
+ }
726
+ return lines;
727
+ }
728
+
729
+ // Wide: single line with > separators, fixed-width bars
730
+ // Format: "Codex > 5h ━━━━━━━━━━ 46% 2h38m > 7d ━━━━━━━━━━ 46% 2d1h"
731
+ const parts: string[] = [theme.fg("accent", usage.provider)];
732
+
733
+ for (const w of usage.windows) {
734
+ const bar = renderUsageBar(w.usedPercent, USAGE_BAR_WIDTH, theme);
735
+ const pct = dim(`${Math.round(w.usedPercent)}%`);
736
+ const timeStr = w.resetsIn ? " " + dim(w.resetsIn) : "";
737
+ parts.push(`${dim(w.label)} ${bar} ${pct}${timeStr}`);
738
+ }
739
+
740
+ return [parts.join(sep)];
741
+ }
742
+
743
+ function getThinkingLevel(ctx: any): string {
744
+ const entries = ctx.sessionManager.getEntries();
745
+ const leafId = ctx.sessionManager.getLeafId();
746
+ const context = buildSessionContext(entries, leafId);
747
+ return context.thinkingLevel || "off";
748
+ }
749
+
750
+ function getContextInfo(ctx: any): { percentage: number; used: number; total: number } {
751
+ const model = ctx.model;
752
+ const contextWindow = model?.contextWindow ?? 0;
753
+ if (contextWindow === 0) return { percentage: 0, used: 0, total: 0 };
754
+
755
+ const entries = ctx.sessionManager.getEntries();
756
+ const leafId = ctx.sessionManager.getLeafId();
757
+ const context = buildSessionContext(entries, leafId);
758
+ const messages = context.messages;
759
+
760
+ const lastAssistant = messages
761
+ .slice()
762
+ .reverse()
763
+ .find((m: any) => m.role === "assistant" && m.stopReason !== "aborted");
764
+
765
+ if (!lastAssistant?.usage) return { percentage: 0, used: 0, total: contextWindow };
766
+
767
+ const usage = lastAssistant.usage;
768
+ const contextTokens = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
769
+
770
+ return { percentage: (contextTokens / contextWindow) * 100, used: contextTokens, total: contextWindow };
771
+ }
772
+
773
+ // Track usage state for rendering
774
+ let latestUsage: UsageSnapshot | null = null;
775
+ let activeProvider: string | null = null; // internal provider key for the current model
776
+ let refreshTimer: ReturnType<typeof setInterval> | null = null;
777
+
778
+ // Store tui reference for triggering re-renders from event handlers
779
+ let tuiRef: { requestRender: () => void } | null = null;
780
+
781
+ /** Fetch usage for the active provider. Shows cached data immediately,
782
+ * then fetches fresh in the background. Discards results if provider
783
+ * changed while the fetch was in flight. */
784
+ function fetchUsage(modelProvider: string): void {
785
+ const provider = detectProvider(modelProvider);
786
+ if (!provider) return;
787
+
788
+ activeProvider = provider;
789
+
790
+ // Show cached data immediately if available
791
+ const cached = usageCache.get(provider);
792
+ if (cached && cached.windows.length > 0) {
793
+ latestUsage = cached;
794
+ tuiRef?.requestRender();
795
+ }
796
+
797
+ // Fetch fresh in background
798
+ fetchUsageForProvider(provider)
799
+ .then((u) => {
800
+ if (!u || activeProvider !== provider) return;
801
+ usageCache.set(provider, u);
802
+ latestUsage = u;
803
+ tuiRef?.requestRender();
804
+ })
805
+ .catch(() => {});
806
+ }
807
+
808
+ /** Start (or restart) the periodic refresh timer. */
809
+ function startRefreshTimer(): void {
810
+ if (refreshTimer) clearInterval(refreshTimer);
811
+ refreshTimer = setInterval(() => {
812
+ if (activeProvider) {
813
+ fetchUsageForProvider(activeProvider)
814
+ .then((u) => {
815
+ if (!u) return;
816
+ usageCache.set(activeProvider!, u);
817
+ latestUsage = u;
818
+ tuiRef?.requestRender();
819
+ })
820
+ .catch(() => {});
821
+ }
822
+ }, USAGE_REFRESH_INTERVAL);
823
+ }
824
+
825
+ function stopRefreshTimer(): void {
826
+ if (refreshTimer) {
827
+ clearInterval(refreshTimer);
828
+ refreshTimer = null;
829
+ }
830
+ }
831
+
832
+ pi.on("session_start", async (_event, ctx) => {
833
+ refreshGitCache();
834
+
835
+ if (!ctx.hasUI) return;
836
+
837
+ ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
838
+ // Store tui reference for event handlers — must be set before any async
839
+ // fetch completes so requestRender() works on first load.
840
+ tuiRef = tui;
841
+
842
+ const unsub = footerData.onBranchChange(() => {
843
+ refreshGitCache();
844
+ tui.requestRender();
845
+ });
846
+
847
+ return {
848
+ dispose: () => {
849
+ unsub();
850
+ tuiRef = null;
851
+ stopRefreshTimer();
852
+ },
853
+ invalidate() {},
854
+ render(width: number): string[] {
855
+ const { percentage, used: ctxUsed, total: ctxTotal } = getContextInfo(ctx);
856
+
857
+ // Build parts for status line
858
+ let pwd = process.cwd();
859
+ const home = process.env.HOME || process.env.USERPROFILE;
860
+ if (home && pwd.startsWith(home)) {
861
+ pwd = `~${pwd.slice(home.length)}`;
862
+ }
863
+
864
+ let branchStr = "";
865
+ if (gitCache?.branch) {
866
+ const branchColor = gitCache.dirty ? "warning" : "success";
867
+ branchStr = theme.fg(branchColor, gitCache.branch);
868
+ if (gitCache.dirty) branchStr += theme.fg("warning", " *");
869
+ if (gitCache.ahead) branchStr += theme.fg("success", ` ↑${gitCache.ahead}`);
870
+ if (gitCache.behind) branchStr += theme.fg("error", ` ↓${gitCache.behind}`);
871
+ }
872
+
873
+ // Model + thinking
874
+ const modelName = ctx.model?.id?.split("/").pop() || "no-model";
875
+ let modelStr = theme.fg("muted", modelName);
876
+ if (ctx.model?.reasoning) {
877
+ const thinkingLevel = getThinkingLevel(ctx);
878
+ if (thinkingLevel !== "off") {
879
+ modelStr += " " + theme.fg("dim", ">") + " " + theme.fg("accent", thinkingLevel);
880
+ }
881
+ }
882
+
883
+ // Context gauge
884
+ const gauge = renderContextGauge(percentage, theme, ctxUsed, ctxTotal);
885
+
886
+ const sep = " " + theme.fg("dim", ">") + " ";
887
+ const NARROW_THRESHOLD = 60;
888
+
889
+ const lines: string[] = [];
890
+
891
+ if (width < NARROW_THRESHOLD) {
892
+ // Narrow: stack vertically
893
+ const pwdColored = theme.fg("accent", pwd);
894
+ if (branchStr) {
895
+ lines.push(truncateToWidth(pwdColored + sep + branchStr, width));
896
+ } else {
897
+ lines.push(truncateToWidth(pwdColored, width));
898
+ }
899
+ lines.push(truncateToWidth(modelStr, width));
900
+ lines.push(truncateToWidth(gauge, width));
901
+ } else {
902
+ // Wide: single line
903
+ const pwdColored = theme.fg("accent", pwd);
904
+ const branchPart = branchStr ? sep + branchStr : "";
905
+ const modelPart = sep + modelStr;
906
+ const gaugePart = sep + gauge;
907
+ lines.push(truncateToWidth(pwdColored + branchPart + modelPart + gaugePart, width));
908
+ }
909
+
910
+ if (latestUsage && latestUsage.windows.length > 0) {
911
+ const usageLines = renderUsageLine(latestUsage, width, theme);
912
+ for (const line of usageLines) {
913
+ lines.push(truncateToWidth(line, width));
914
+ }
915
+ }
916
+
917
+ return lines;
918
+ },
919
+ };
920
+ });
921
+
922
+ // Initial usage fetch — after setFooter so tuiRef is set
923
+ if (ctx.model?.provider) {
924
+ fetchUsage(ctx.model.provider);
925
+ startRefreshTimer();
926
+ }
927
+ });
928
+
929
+ // Refresh when model changes — fetch immediately, restart timer
930
+ pi.on("model_select", (event, _ctx) => {
931
+ if (!event.model?.provider) return;
932
+ fetchUsage(event.model.provider);
933
+ startRefreshTimer(); // reset the 5min countdown since we just fetched
934
+ });
935
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@ogulcancelik/pi-minimal-footer",
3
+ "version": "0.1.0",
4
+ "description": "Minimal footer for pi with context gauge and subscription usage bars",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "footer",
10
+ "context",
11
+ "usage",
12
+ "coding-agent"
13
+ ],
14
+ "author": "Can Celik",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/ogulcancelik/pi-extensions.git",
19
+ "directory": "packages/pi-minimal-footer"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/ogulcancelik/pi-extensions/issues"
23
+ },
24
+ "homepage": "https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer#readme",
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "files": [
34
+ "index.ts",
35
+ "assets/claude.png",
36
+ "assets/codex.png",
37
+ "README.md",
38
+ "LICENSE"
39
+ ]
40
+ }