@ogulcancelik/pi-minimal-footer 0.1.13 → 0.1.15

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.
Files changed (3) hide show
  1. package/README.md +5 -8
  2. package/index.ts +240 -110
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,8 +10,9 @@ Minimal footer for [pi](https://github.com/earendil-works/pi) that replaces the
10
10
 
11
11
  - **Context gauge** — optional working directory and git branch, model, thinking level, and context window usage with token counts
12
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
13
+ - **Shared usage cache** — all pi instances share one cached result per provider and account, so opening more sessions doesn't mean more quota API calls
14
14
  - **Git integration** — branch name, dirty state, ahead/behind counts
15
+ - **Extension statuses** — text that other extensions set with `ctx.ui.setStatus()`, on its own line like pi's default footer
15
16
 
16
17
  ## Supported providers
17
18
 
@@ -46,13 +47,9 @@ Accepted false values: `0`, `false`, `no`, `off` (case-insensitive).
46
47
 
47
48
  ## How it works
48
49
 
49
- 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.
50
+ The footer reads context usage from pi's `ctx.getContextUsage()`, the same value as pi's default footer: the token counts of the last response, plus an estimate for the messages after it. After a compaction, pi does not know the new size until the next response, so the gauge shows `?/<window>`, like pi's default footer. Subscription usage is fetched from each provider's dedicated quota API using your existing auth tokens from Pi's agent directory (`~/.pi/agent/auth.json` by default) or environment variables.
50
51
 
51
- Usage is fetched:
52
-
53
- - Once on startup
54
- - Immediately on model switch (Ctrl+P)
55
- - Every 5 minutes after that
52
+ Usage is cached in `~/.pi/agent/pi-minimal-footer/` and shared by every pi instance. Each instance re-reads the cache on startup, on model switch (Ctrl+P), and every 30 seconds. Only one instance fetches when the cache is older than 5 minutes. Failed fetches keep the last good numbers and retry after a minute, or after the provider's `retry-after` when rate-limited.
56
53
 
57
54
  Git state is refreshed:
58
55
 
@@ -71,5 +68,5 @@ Anthropic's OAuth usage endpoint (`/api/oauth/usage`) rate-limits requests by `U
71
68
  ## Notes
72
69
 
73
70
  - Replaces the default pi footer entirely via `ctx.ui.setFooter()`
74
- - Auth tokens are read from `~/.pi/agent/auth.json` (populated by `/login`) or standard env vars (`ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, etc.)
71
+ - Auth tokens are read from Pi's `auth.json` (populated by `/login`) or standard env vars (`ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, etc.). The footer respects `PI_CODING_AGENT_DIR`, including paths starting with `~/`.
75
72
  - Providers without auth simply don't show a usage bar — no errors
package/index.ts CHANGED
@@ -8,10 +8,20 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { buildSessionContext } from "@earendil-works/pi-coding-agent";
11
+ import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
12
12
  import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
13
13
  import { execSync } from "node:child_process";
14
- import { existsSync, readFileSync } from "node:fs";
14
+ import { createHash } from "node:crypto";
15
+ import {
16
+ existsSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ renameSync,
21
+ statSync,
22
+ unlinkSync,
23
+ writeFileSync,
24
+ } from "node:fs";
15
25
  import { join } from "node:path";
16
26
  import { homedir } from "node:os";
17
27
 
@@ -20,13 +30,14 @@ import { homedir } from "node:os";
20
30
  interface RateWindow {
21
31
  label: string;
22
32
  usedPercent: number;
23
- resetsIn?: string; // human readable "2h38m"
33
+ resetsAt?: number; // epoch ms; formatted at render time so the countdown stays live
24
34
  }
25
35
 
26
36
  interface UsageSnapshot {
27
37
  provider: string;
28
38
  windows: RateWindow[];
29
39
  error?: string;
40
+ retryAfterMs?: number;
30
41
  fetchedAt: number;
31
42
  }
32
43
 
@@ -37,10 +48,23 @@ interface GitCache {
37
48
  behind: number;
38
49
  }
39
50
 
51
+ interface ContextInfo {
52
+ percentage: number | null;
53
+ used: number | null;
54
+ total: number;
55
+ }
56
+
40
57
  // ============ Usage Cache ============
41
58
 
42
- const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes
43
- const usageCache = new Map<string, UsageSnapshot>(); // keyed by provider
59
+ // Usage is cached on disk and shared by every pi instance, so N open panes
60
+ // cost one request per TTL instead of N.
61
+ const USAGE_TTL = 5 * 60_000;
62
+ const USAGE_CHECK_INTERVAL = 30_000;
63
+ const USAGE_ERROR_RETRY = 60_000;
64
+ const USAGE_MAX_STALE = 60 * 60_000;
65
+ const USAGE_CLAIM = 30_000; // other instances hold off this long while one fetches
66
+ const USAGE_FILE_MAX_AGE = 24 * 60 * 60_000;
67
+ const USAGE_CACHE_DIR = join(homedir(), ".pi", "agent", "pi-minimal-footer");
44
68
 
45
69
  // ============ Env Flags ============
46
70
 
@@ -103,10 +127,14 @@ function refreshGitCache(): boolean {
103
127
  let next: GitCache | null = null;
104
128
 
105
129
  try {
106
- const status = execSync("git status --porcelain=v2 --branch 2>/dev/null", {
107
- encoding: "utf8",
108
- timeout: 1000,
109
- });
130
+ const status = execSync(
131
+ "git status --porcelain=v2 --branch",
132
+ {
133
+ encoding: "utf8",
134
+ timeout: 1000,
135
+ stdio: ["ignore", "pipe", "ignore"],
136
+ },
137
+ );
110
138
  next = parseGitStatus(status.trimEnd());
111
139
  } catch {
112
140
  next = null;
@@ -143,8 +171,8 @@ function getEmailPrefixFromJwt(token: string): string | null {
143
171
 
144
172
  // ============ Auth Loading ============
145
173
 
146
- function loadAuthJson(): Record<string, any> {
147
- const authPath = join(homedir(), ".pi", "agent", "auth.json");
174
+ export function loadAuthJson(): Record<string, any> {
175
+ const authPath = join(getAgentDir(), "auth.json");
148
176
  try {
149
177
  if (existsSync(authPath)) {
150
178
  return JSON.parse(readFileSync(authPath, "utf-8"));
@@ -295,13 +323,6 @@ function clampPercent(value: number): number {
295
323
  return Math.max(0, Math.min(100, value));
296
324
  }
297
325
 
298
- /** Normalize a value that might be 0-1 fraction OR 0-100 percent, then clamp. */
299
- function normalizePercent(value: number): number {
300
- if (!Number.isFinite(value)) return 0;
301
- const normalized = value <= 1 && value >= 0 ? value * 100 : value;
302
- return Math.max(0, Math.min(100, normalized));
303
- }
304
-
305
326
  function getWindowLabel(durationMs: number | undefined, fallback: string): string {
306
327
  if (!durationMs || !Number.isFinite(durationMs) || durationMs <= 0) return fallback;
307
328
 
@@ -341,6 +362,33 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 5000
341
362
  }
342
363
  }
343
364
 
365
+ function toTimestamp(value: unknown): number | undefined {
366
+ if (value === undefined || value === null || value === "") return undefined;
367
+ const ms = new Date(value as any).getTime();
368
+ return Number.isFinite(ms) ? ms : undefined;
369
+ }
370
+
371
+ function parseRetryAfter(res: Response): number | undefined {
372
+ const header = res.headers.get("retry-after");
373
+ if (header) {
374
+ const seconds = Number(header);
375
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
376
+ const date = Date.parse(header);
377
+ if (Number.isFinite(date)) return Math.max(0, date - Date.now());
378
+ }
379
+ return res.status === 429 ? USAGE_TTL : undefined;
380
+ }
381
+
382
+ function httpError(provider: string, res: Response): UsageSnapshot {
383
+ return {
384
+ provider,
385
+ windows: [],
386
+ error: `HTTP ${res.status}`,
387
+ retryAfterMs: parseRetryAfter(res),
388
+ fetchedAt: Date.now(),
389
+ };
390
+ }
391
+
344
392
  // ============ Usage Fetchers ============
345
393
 
346
394
  async function fetchClaudeUsage(): Promise<UsageSnapshot> {
@@ -360,7 +408,7 @@ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
360
408
  });
361
409
 
362
410
  if (!res.ok) {
363
- return { provider: "Claude", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
411
+ return httpError("Claude", res);
364
412
  }
365
413
 
366
414
  const data = (await res.json()) as any;
@@ -369,16 +417,16 @@ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
369
417
  if (data.five_hour?.utilization !== undefined) {
370
418
  windows.push({
371
419
  label: "5h",
372
- usedPercent: normalizePercent(data.five_hour.utilization),
373
- resetsIn: data.five_hour.resets_at ? formatResetTime(new Date(data.five_hour.resets_at)) : undefined,
420
+ usedPercent: clampPercent(data.five_hour.utilization),
421
+ resetsAt: toTimestamp(data.five_hour.resets_at),
374
422
  });
375
423
  }
376
424
 
377
425
  if (data.seven_day?.utilization !== undefined) {
378
426
  windows.push({
379
427
  label: "Week",
380
- usedPercent: normalizePercent(data.seven_day.utilization),
381
- resetsIn: data.seven_day.resets_at ? formatResetTime(new Date(data.seven_day.resets_at)) : undefined,
428
+ usedPercent: clampPercent(data.seven_day.utilization),
429
+ resetsAt: toTimestamp(data.seven_day.resets_at),
382
430
  });
383
431
  }
384
432
 
@@ -406,19 +454,18 @@ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
406
454
  });
407
455
 
408
456
  if (!res.ok) {
409
- return { provider: "Copilot", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
457
+ return httpError("Copilot", res);
410
458
  }
411
459
 
412
460
  const data = (await res.json()) as any;
413
461
  const windows: RateWindow[] = [];
414
462
 
415
- const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
416
- const resetsIn = resetDate ? formatResetTime(resetDate) : undefined;
463
+ const resetsAt = toTimestamp(data.quota_reset_date_utc);
417
464
 
418
465
  if (data.quota_snapshots?.premium_interactions) {
419
466
  const pi = data.quota_snapshots.premium_interactions;
420
467
  const usedPercent = clampPercent(100 - (pi.percent_remaining || 0));
421
- windows.push({ label: "Premium", usedPercent, resetsIn });
468
+ windows.push({ label: "Premium", usedPercent, resetsAt });
422
469
  }
423
470
 
424
471
  if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) {
@@ -426,7 +473,7 @@ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
426
473
  windows.push({
427
474
  label: "Chat",
428
475
  usedPercent: clampPercent(100 - (chat.percent_remaining || 0)),
429
- resetsIn,
476
+ resetsAt,
430
477
  });
431
478
  }
432
479
 
@@ -461,7 +508,7 @@ async function fetchCodexUsage(): Promise<UsageSnapshot> {
461
508
  });
462
509
 
463
510
  if (!res.ok) {
464
- return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
511
+ return httpError(providerLabel, res);
465
512
  }
466
513
 
467
514
  const data = (await res.json()) as any;
@@ -469,23 +516,21 @@ async function fetchCodexUsage(): Promise<UsageSnapshot> {
469
516
 
470
517
  if (data.rate_limit?.primary_window) {
471
518
  const pw = data.rate_limit.primary_window;
472
- const resetDate = pw.reset_at ? new Date(pw.reset_at * 1000) : undefined;
473
519
  const durationMs = typeof pw.limit_window_seconds === "number" ? pw.limit_window_seconds * 1000 : undefined;
474
520
  windows.push({
475
521
  label: getWindowLabel(durationMs, "5h"),
476
522
  usedPercent: clampPercent(pw.used_percent || 0),
477
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
523
+ resetsAt: pw.reset_at ? pw.reset_at * 1000 : undefined,
478
524
  });
479
525
  }
480
526
 
481
527
  if (data.rate_limit?.secondary_window) {
482
528
  const sw = data.rate_limit.secondary_window;
483
- const resetDate = sw.reset_at ? new Date(sw.reset_at * 1000) : undefined;
484
529
  const durationMs = typeof sw.limit_window_seconds === "number" ? sw.limit_window_seconds * 1000 : undefined;
485
530
  windows.push({
486
531
  label: getWindowLabel(durationMs, "Week"),
487
532
  usedPercent: clampPercent(sw.used_percent || 0),
488
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
533
+ resetsAt: sw.reset_at ? sw.reset_at * 1000 : undefined,
489
534
  });
490
535
  }
491
536
 
@@ -509,7 +554,7 @@ async function fetchGeminiUsage(): Promise<UsageSnapshot> {
509
554
  });
510
555
 
511
556
  if (!res.ok) {
512
- return { provider: "Gemini", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
557
+ return httpError("Gemini", res);
513
558
  }
514
559
 
515
560
  const data = (await res.json()) as any;
@@ -571,7 +616,7 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
571
616
  });
572
617
 
573
618
  if (!res.ok) {
574
- return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
619
+ return httpError(providerLabel, res);
575
620
  }
576
621
 
577
622
  const data = (await res.json()) as any;
@@ -610,7 +655,6 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
610
655
  const intervalRemaining = Number(textBucket.current_interval_remaining_percent);
611
656
  if (Number.isFinite(intervalRemaining)) {
612
657
  const usedPercent = clampPercent(100 - intervalRemaining);
613
- const resetDate = textBucket.end_time ? new Date(Number(textBucket.end_time)) : undefined;
614
658
  const durationMs =
615
659
  textBucket.start_time && textBucket.end_time
616
660
  ? Number(textBucket.end_time) - Number(textBucket.start_time)
@@ -618,16 +662,13 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
618
662
  windows.push({
619
663
  label: getWindowLabel(durationMs, "5h"),
620
664
  usedPercent,
621
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
665
+ resetsAt: textBucket.end_time ? Number(textBucket.end_time) : undefined,
622
666
  });
623
667
  }
624
668
 
625
669
  const weeklyRemaining = Number(textBucket.current_weekly_remaining_percent);
626
670
  if (Number.isFinite(weeklyRemaining)) {
627
671
  const usedPercent = clampPercent(100 - weeklyRemaining);
628
- const resetDate = textBucket.weekly_end_time
629
- ? new Date(Number(textBucket.weekly_end_time))
630
- : undefined;
631
672
  const durationMs =
632
673
  textBucket.weekly_start_time && textBucket.weekly_end_time
633
674
  ? Number(textBucket.weekly_end_time) - Number(textBucket.weekly_start_time)
@@ -635,7 +676,7 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
635
676
  windows.push({
636
677
  label: getWindowLabel(durationMs, "Week"),
637
678
  usedPercent,
638
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
679
+ resetsAt: textBucket.weekly_end_time ? Number(textBucket.weekly_end_time) : undefined,
639
680
  });
640
681
  }
641
682
 
@@ -666,7 +707,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
666
707
  });
667
708
 
668
709
  if (!res.ok) {
669
- return { provider: "Kimi Coding", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
710
+ return httpError("Kimi Coding", res);
670
711
  }
671
712
 
672
713
  const data = (await res.json()) as any;
@@ -678,7 +719,6 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
678
719
  if (windowLimit > 0) {
679
720
  const used = windowLimit - windowRemaining;
680
721
  const usedPercent = clampPercent((used / windowLimit) * 100);
681
- const resetDate = limit.detail?.resetTime ? new Date(limit.detail.resetTime) : undefined;
682
722
  const durationMs =
683
723
  limit.window?.duration && limit.window?.timeUnit === "TIME_UNIT_MINUTE"
684
724
  ? limit.window.duration * 60 * 1000
@@ -687,7 +727,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
687
727
  windows.push({
688
728
  label: getWindowLabel(durationMs, "5h"),
689
729
  usedPercent,
690
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
730
+ resetsAt: toTimestamp(limit.detail?.resetTime),
691
731
  });
692
732
  }
693
733
  }
@@ -702,7 +742,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
702
742
  windows.push({
703
743
  label: "Weekly",
704
744
  usedPercent,
705
- resetsIn: weeklyResetTime ? formatResetTime(new Date(weeklyResetTime)) : undefined,
745
+ resetsAt: toTimestamp(weeklyResetTime),
706
746
  });
707
747
  }
708
748
 
@@ -728,7 +768,7 @@ async function fetchOpencodeGoUsage(): Promise<UsageSnapshot> {
728
768
  });
729
769
 
730
770
  if (!res.ok) {
731
- return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
771
+ return httpError(providerLabel, res);
732
772
  }
733
773
 
734
774
  const data = (await res.json()) as any;
@@ -736,26 +776,26 @@ async function fetchOpencodeGoUsage(): Promise<UsageSnapshot> {
736
776
 
737
777
  if (data.rollingUsage) {
738
778
  const usedPercent = clampPercent(data.rollingUsage.usagePercent ?? 0);
739
- const resetsIn = Number.isFinite(data.rollingUsage.resetInSec)
740
- ? formatResetTime(new Date(Date.now() + data.rollingUsage.resetInSec * 1000))
779
+ const resetsAt = Number.isFinite(data.rollingUsage.resetInSec)
780
+ ? Date.now() + data.rollingUsage.resetInSec * 1000
741
781
  : undefined;
742
- windows.push({ label: "5h", usedPercent, resetsIn });
782
+ windows.push({ label: "5h", usedPercent, resetsAt });
743
783
  }
744
784
 
745
785
  if (data.weeklyUsage) {
746
786
  const usedPercent = clampPercent(data.weeklyUsage.usagePercent ?? 0);
747
- const resetsIn = Number.isFinite(data.weeklyUsage.resetInSec)
748
- ? formatResetTime(new Date(Date.now() + data.weeklyUsage.resetInSec * 1000))
787
+ const resetsAt = Number.isFinite(data.weeklyUsage.resetInSec)
788
+ ? Date.now() + data.weeklyUsage.resetInSec * 1000
749
789
  : undefined;
750
- windows.push({ label: "Week", usedPercent, resetsIn });
790
+ windows.push({ label: "Week", usedPercent, resetsAt });
751
791
  }
752
792
 
753
793
  if (data.monthlyUsage) {
754
794
  const usedPercent = clampPercent(data.monthlyUsage.usagePercent ?? 0);
755
- const resetsIn = Number.isFinite(data.monthlyUsage.resetInSec)
756
- ? formatResetTime(new Date(Date.now() + data.monthlyUsage.resetInSec * 1000))
795
+ const resetsAt = Number.isFinite(data.monthlyUsage.resetInSec)
796
+ ? Date.now() + data.monthlyUsage.resetInSec * 1000
757
797
  : undefined;
758
- windows.push({ label: "Month", usedPercent, resetsIn });
798
+ windows.push({ label: "Month", usedPercent, resetsAt });
759
799
  }
760
800
 
761
801
  return { provider: providerLabel, windows, fetchedAt: Date.now() };
@@ -782,6 +822,73 @@ function detectProvider(modelProvider: string): string | null {
782
822
  return PROVIDER_MAP[modelProvider] || null;
783
823
  }
784
824
 
825
+ function getCredential(provider: string): string | undefined {
826
+ switch (provider) {
827
+ case "claude":
828
+ return getClaudeToken();
829
+ case "codex": {
830
+ const creds = getCodexToken();
831
+ return creds ? `${creds.accountId ?? ""}:${creds.token}` : undefined;
832
+ }
833
+ case "copilot":
834
+ return getCopilotToken();
835
+ case "gemini":
836
+ return getGeminiToken();
837
+ case "minimax":
838
+ case "minimax-cn":
839
+ return getMinimaxToken(provider);
840
+ case "kimi-coding":
841
+ return getKimiToken();
842
+ case "opencode-go":
843
+ return getOpencodeToken();
844
+ default:
845
+ return undefined;
846
+ }
847
+ }
848
+
849
+ // ============ Shared Usage Cache ============
850
+
851
+ interface SharedUsage {
852
+ snapshot?: UsageSnapshot; // last successful result; failures never overwrite it
853
+ nextFetchAt: number;
854
+ }
855
+
856
+ /** One file per provider + account, so different logins never share numbers. */
857
+ function sharedUsagePath(provider: string, credential: string): string {
858
+ const account = createHash("sha256").update(credential).digest("hex").slice(0, 16);
859
+ return join(USAGE_CACHE_DIR, `${provider}-${account}.json`);
860
+ }
861
+
862
+ function readSharedUsage(path: string): SharedUsage | null {
863
+ try {
864
+ return JSON.parse(readFileSync(path, "utf-8"));
865
+ } catch {
866
+ return null;
867
+ }
868
+ }
869
+
870
+ function writeSharedUsage(path: string, data: SharedUsage): void {
871
+ try {
872
+ mkdirSync(USAGE_CACHE_DIR, { recursive: true });
873
+ // Write-then-rename so readers never see a half-written file.
874
+ const tmp = `${path}.${process.pid}.tmp`;
875
+ writeFileSync(tmp, JSON.stringify(data));
876
+ renameSync(tmp, path);
877
+ pruneSharedUsage();
878
+ } catch {}
879
+ }
880
+
881
+ /** Token rotation creates a new file per account; drop ones nobody touched in a day. */
882
+ function pruneSharedUsage(): void {
883
+ try {
884
+ const now = Date.now();
885
+ for (const name of readdirSync(USAGE_CACHE_DIR)) {
886
+ const file = join(USAGE_CACHE_DIR, name);
887
+ if (now - statSync(file).mtimeMs > USAGE_FILE_MAX_AGE) unlinkSync(file);
888
+ }
889
+ } catch {}
890
+ }
891
+
785
892
  async function fetchUsageForProvider(provider: string): Promise<UsageSnapshot> {
786
893
  switch (provider) {
787
894
  case "claude":
@@ -868,14 +975,15 @@ export default function (pi: ExtensionAPI) {
868
975
  }
869
976
 
870
977
  function renderContextGauge(
871
- percentage: number,
978
+ percentage: number | null,
872
979
  theme: any,
873
- used?: number,
980
+ used?: number | null,
874
981
  total?: number,
875
982
  options?: { barWidth?: number; includeCounts?: boolean }
876
983
  ): string {
877
984
  const barWidth = Math.max(4, options?.barWidth ?? CTX_GAUGE_WIDTH);
878
- const clamped = Math.max(0, Math.min(100, percentage));
985
+ const known = percentage !== null;
986
+ const clamped = known ? Math.max(0, Math.min(100, percentage)) : 0;
879
987
  const filled = Math.round((clamped / 100) * barWidth);
880
988
  const empty = barWidth - filled;
881
989
 
@@ -886,11 +994,12 @@ export default function (pi: ExtensionAPI) {
886
994
  else color = "success";
887
995
 
888
996
  const bar = theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty));
889
- const pct = `${Math.round(clamped)}%`;
890
- const counts =
891
- options?.includeCounts === false || used === undefined || !total
892
- ? ""
893
- : ` ${formatTokenCount(used)}/${formatTokenCount(total)}`;
997
+ const pct = known ? `${Math.round(clamped)}%` : "?";
998
+ let counts = "";
999
+ if (options?.includeCounts !== false && total) {
1000
+ if (!known) counts = `/${formatTokenCount(total)}`;
1001
+ else if (used != null) counts = ` ${formatTokenCount(used)}/${formatTokenCount(total)}`;
1002
+ }
894
1003
 
895
1004
  return theme.fg("dim", "ctx ") + bar + " " + theme.fg("dim", pct + counts);
896
1005
  }
@@ -916,7 +1025,10 @@ export default function (pi: ExtensionAPI) {
916
1025
  const dim = (s: string) => theme.fg("dim", s);
917
1026
  const bar = renderUsageBar(window.usedPercent, Math.max(4, options?.barWidth ?? 10), theme);
918
1027
  const pct = dim(`${Math.round(window.usedPercent)}%`);
919
- const timeStr = options?.includeReset === false || !window.resetsIn ? "" : " " + dim(window.resetsIn);
1028
+ const timeStr =
1029
+ options?.includeReset === false || window.resetsAt === undefined
1030
+ ? ""
1031
+ : " " + dim(formatResetTime(new Date(window.resetsAt)));
920
1032
  return `${dim(window.label)} ${bar} ${pct}${timeStr}`;
921
1033
  }
922
1034
 
@@ -942,6 +1054,14 @@ export default function (pi: ExtensionAPI) {
942
1054
  return wrapFooterSegments(segments, width, sep);
943
1055
  }
944
1056
 
1057
+ function renderExtensionStatuses(statuses: ReadonlyMap<string, string>): string {
1058
+ return Array.from(statuses.entries())
1059
+ .sort(([a], [b]) => a.localeCompare(b))
1060
+ .map(([, text]) => text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim())
1061
+ .filter(Boolean)
1062
+ .join(" ");
1063
+ }
1064
+
945
1065
  function getThinkingLevel(ctx: any): string {
946
1066
  const entries = ctx.sessionManager.getEntries();
947
1067
  const leafId = ctx.sessionManager.getLeafId();
@@ -949,7 +1069,14 @@ export default function (pi: ExtensionAPI) {
949
1069
  return context.thinkingLevel || "off";
950
1070
  }
951
1071
 
952
- function getContextInfo(ctx: any): { percentage: number; used: number; total: number } {
1072
+ function getContextInfo(ctx: any): ContextInfo {
1073
+ if (typeof ctx.getContextUsage !== "function") return getLastResponseContextInfo(ctx);
1074
+ const usage = ctx.getContextUsage();
1075
+ if (!usage) return { percentage: 0, used: 0, total: 0 };
1076
+ return { percentage: usage.percent, used: usage.tokens, total: usage.contextWindow };
1077
+ }
1078
+
1079
+ function getLastResponseContextInfo(ctx: any): ContextInfo {
953
1080
  const model = ctx.model;
954
1081
  const contextWindow = model?.contextWindow ?? 0;
955
1082
  if (contextWindow === 0) return { percentage: 0, used: 0, total: 0 };
@@ -983,58 +1110,62 @@ export default function (pi: ExtensionAPI) {
983
1110
  if (refreshGitCache()) tuiRef?.requestRender();
984
1111
  }
985
1112
 
986
- /** Fetch usage for the active provider. Shows cached data immediately,
987
- * then fetches fresh in the background. Discards results if provider
988
- * changed while the fetch was in flight. */
989
- function fetchUsage(modelProvider: string): void {
990
- const provider = detectProvider(modelProvider);
991
- if (!provider) {
992
- activeProvider = null;
993
- latestUsage = null;
994
- stopRefreshTimer();
995
- tuiRef?.requestRender();
1113
+ function showSnapshot(provider: string, snapshot: UsageSnapshot | undefined): void {
1114
+ if (activeProvider !== provider) return;
1115
+ const fresh = snapshot && Date.now() - snapshot.fetchedAt < USAGE_MAX_STALE;
1116
+ latestUsage = fresh ? snapshot : null;
1117
+ tuiRef?.requestRender();
1118
+ }
1119
+
1120
+ /** Show the shared cached usage, and fetch only if it's due. Coordination is best-effort:
1121
+ * the fetcher pushes nextFetchAt forward first, so other instances skip while it works. */
1122
+ function syncUsage(provider: string): void {
1123
+ const credential = getCredential(provider);
1124
+ if (!credential) {
1125
+ showSnapshot(provider, undefined);
996
1126
  return;
997
1127
  }
998
1128
 
999
- activeProvider = provider;
1129
+ const path = sharedUsagePath(provider, credential);
1130
+ const shared = readSharedUsage(path);
1131
+ showSnapshot(provider, shared?.snapshot);
1132
+ if (shared && Date.now() < shared.nextFetchAt) return;
1000
1133
 
1001
- // Show cached data immediately if available
1002
- const cached = usageCache.get(provider);
1003
- if (cached && cached.windows.length > 0) {
1004
- latestUsage = cached;
1005
- tuiRef?.requestRender();
1006
- }
1134
+ writeSharedUsage(path, { snapshot: shared?.snapshot, nextFetchAt: Date.now() + USAGE_CLAIM });
1007
1135
 
1008
- // Fetch fresh in background — keep cached data on transient errors
1009
1136
  fetchUsageForProvider(provider)
1010
1137
  .then((u) => {
1011
- if (!u || activeProvider !== provider) return;
1012
- if (u.windows.length === 0 && u.error && cached?.windows.length) return;
1013
- usageCache.set(provider, u);
1014
- latestUsage = u;
1015
- tuiRef?.requestRender();
1138
+ const now = Date.now();
1139
+ const latest = readSharedUsage(path);
1140
+ const next: SharedUsage = u.error
1141
+ ? {
1142
+ snapshot: latest?.snapshot,
1143
+ // Never shorten a backoff another instance may have recorded meanwhile.
1144
+ nextFetchAt: Math.max(now + (u.retryAfterMs ?? USAGE_ERROR_RETRY), latest?.nextFetchAt ?? 0),
1145
+ }
1146
+ : { snapshot: u, nextFetchAt: now + USAGE_TTL };
1147
+ writeSharedUsage(path, next);
1148
+ showSnapshot(provider, next.snapshot);
1016
1149
  })
1017
1150
  .catch(() => {});
1018
1151
  }
1019
1152
 
1020
- /** Start (or restart) the periodic refresh timer. */
1153
+ function selectProvider(modelProvider: string): void {
1154
+ activeProvider = detectProvider(modelProvider);
1155
+ if (activeProvider) {
1156
+ syncUsage(activeProvider);
1157
+ } else {
1158
+ latestUsage = null;
1159
+ tuiRef?.requestRender();
1160
+ }
1161
+ }
1162
+
1163
+ /** Re-read the shared cache periodically; also keeps reset countdowns ticking. */
1021
1164
  function startRefreshTimer(): void {
1022
1165
  if (refreshTimer) clearInterval(refreshTimer);
1023
1166
  refreshTimer = setInterval(() => {
1024
- if (activeProvider) {
1025
- const provider = activeProvider;
1026
- const cached = usageCache.get(provider);
1027
- fetchUsageForProvider(provider)
1028
- .then((u) => {
1029
- if (!u || activeProvider !== provider) return;
1030
- if (u.windows.length === 0 && u.error && cached?.windows.length) return;
1031
- usageCache.set(provider, u);
1032
- latestUsage = u;
1033
- tuiRef?.requestRender();
1034
- })
1035
- .catch(() => {});
1036
- }
1037
- }, USAGE_REFRESH_INTERVAL);
1167
+ if (activeProvider) syncUsage(activeProvider);
1168
+ }, USAGE_CHECK_INTERVAL);
1038
1169
  }
1039
1170
 
1040
1171
  function stopRefreshTimer(): void {
@@ -1058,10 +1189,8 @@ export default function (pi: ExtensionAPI) {
1058
1189
 
1059
1190
  // Initial fetch inside factory — tui is guaranteed available here,
1060
1191
  // so requestRender() will work when the async fetch completes.
1061
- if (ctx.model?.provider) {
1062
- fetchUsage(ctx.model.provider);
1063
- startRefreshTimer();
1064
- }
1192
+ if (ctx.model?.provider) selectProvider(ctx.model.provider);
1193
+ startRefreshTimer();
1065
1194
 
1066
1195
  return {
1067
1196
  dispose: () => {
@@ -1147,6 +1276,9 @@ export default function (pi: ExtensionAPI) {
1147
1276
  lines.push(...renderUsageLine(latestUsage, width, theme));
1148
1277
  }
1149
1278
 
1279
+ const statusLine = renderExtensionStatuses(footerData.getExtensionStatuses?.() ?? new Map());
1280
+ if (statusLine) lines.push(statusLine);
1281
+
1150
1282
  return lines.map((line) => truncateToWidth(line, width));
1151
1283
  },
1152
1284
  };
@@ -1158,10 +1290,8 @@ export default function (pi: ExtensionAPI) {
1158
1290
  refreshGitFooter();
1159
1291
  });
1160
1292
 
1161
- // Refresh when model changes — fetch immediately, restart timer
1162
1293
  pi.on("model_select", (event, _ctx) => {
1163
1294
  if (!event.model?.provider) return;
1164
- fetchUsage(event.model.provider);
1165
- startRefreshTimer(); // reset the 5min countdown since we just fetched
1295
+ selectProvider(event.model.provider);
1166
1296
  });
1167
1297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogulcancelik/pi-minimal-footer",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Minimal footer for pi with context gauge and subscription usage bars",
5
5
  "type": "module",
6
6
  "keywords": [