@ogulcancelik/pi-minimal-footer 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -9
- package/index.ts +199 -97
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ 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
|
-
- **
|
|
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
15
|
|
|
16
16
|
## Supported providers
|
|
@@ -46,13 +46,9 @@ Accepted false values: `0`, `false`, `no`, `off` (case-insensitive).
|
|
|
46
46
|
|
|
47
47
|
## How it works
|
|
48
48
|
|
|
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.
|
|
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's agent directory (`~/.pi/agent/auth.json` by default) or environment variables.
|
|
50
50
|
|
|
51
|
-
Usage is
|
|
52
|
-
|
|
53
|
-
- Once on startup
|
|
54
|
-
- Immediately on model switch (Ctrl+P)
|
|
55
|
-
- Every 5 minutes after that
|
|
51
|
+
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
52
|
|
|
57
53
|
Git state is refreshed:
|
|
58
54
|
|
|
@@ -66,10 +62,10 @@ The footer adapts to narrow terminals by stacking lines vertically instead of th
|
|
|
66
62
|
|
|
67
63
|
### Claude Max usage bar not showing
|
|
68
64
|
|
|
69
|
-
Anthropic's OAuth usage endpoint (`/api/oauth/usage`)
|
|
65
|
+
Anthropic's OAuth usage endpoint (`/api/oauth/usage`) rate-limits requests by `User-Agent`. Anything other than Claude Code gets persistent 429s ([claude-code#30930](https://github.com/anthropics/claude-code/issues/30930)), so the footer identifies as `claude-code/<version>`. The limit is still tight, so with many pi sessions open the bar may briefly keep showing the last known values.
|
|
70
66
|
|
|
71
67
|
## Notes
|
|
72
68
|
|
|
73
69
|
- Replaces the default pi footer entirely via `ctx.ui.setFooter()`
|
|
74
|
-
- Auth tokens are read from
|
|
70
|
+
- 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
71
|
- 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 {
|
|
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
|
-
|
|
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
|
|
|
@@ -39,8 +50,15 @@ interface GitCache {
|
|
|
39
50
|
|
|
40
51
|
// ============ Usage Cache ============
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
// Usage is cached on disk and shared by every pi instance, so N open panes
|
|
54
|
+
// cost one request per TTL instead of N.
|
|
55
|
+
const USAGE_TTL = 5 * 60_000;
|
|
56
|
+
const USAGE_CHECK_INTERVAL = 30_000;
|
|
57
|
+
const USAGE_ERROR_RETRY = 60_000;
|
|
58
|
+
const USAGE_MAX_STALE = 60 * 60_000;
|
|
59
|
+
const USAGE_CLAIM = 30_000; // other instances hold off this long while one fetches
|
|
60
|
+
const USAGE_FILE_MAX_AGE = 24 * 60 * 60_000;
|
|
61
|
+
const USAGE_CACHE_DIR = join(homedir(), ".pi", "agent", "pi-minimal-footer");
|
|
44
62
|
|
|
45
63
|
// ============ Env Flags ============
|
|
46
64
|
|
|
@@ -143,8 +161,8 @@ function getEmailPrefixFromJwt(token: string): string | null {
|
|
|
143
161
|
|
|
144
162
|
// ============ Auth Loading ============
|
|
145
163
|
|
|
146
|
-
function loadAuthJson(): Record<string, any> {
|
|
147
|
-
const authPath = join(
|
|
164
|
+
export function loadAuthJson(): Record<string, any> {
|
|
165
|
+
const authPath = join(getAgentDir(), "auth.json");
|
|
148
166
|
try {
|
|
149
167
|
if (existsSync(authPath)) {
|
|
150
168
|
return JSON.parse(readFileSync(authPath, "utf-8"));
|
|
@@ -295,13 +313,6 @@ function clampPercent(value: number): number {
|
|
|
295
313
|
return Math.max(0, Math.min(100, value));
|
|
296
314
|
}
|
|
297
315
|
|
|
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
316
|
function getWindowLabel(durationMs: number | undefined, fallback: string): string {
|
|
306
317
|
if (!durationMs || !Number.isFinite(durationMs) || durationMs <= 0) return fallback;
|
|
307
318
|
|
|
@@ -341,6 +352,33 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 5000
|
|
|
341
352
|
}
|
|
342
353
|
}
|
|
343
354
|
|
|
355
|
+
function toTimestamp(value: unknown): number | undefined {
|
|
356
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
357
|
+
const ms = new Date(value as any).getTime();
|
|
358
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseRetryAfter(res: Response): number | undefined {
|
|
362
|
+
const header = res.headers.get("retry-after");
|
|
363
|
+
if (header) {
|
|
364
|
+
const seconds = Number(header);
|
|
365
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
366
|
+
const date = Date.parse(header);
|
|
367
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
368
|
+
}
|
|
369
|
+
return res.status === 429 ? USAGE_TTL : undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function httpError(provider: string, res: Response): UsageSnapshot {
|
|
373
|
+
return {
|
|
374
|
+
provider,
|
|
375
|
+
windows: [],
|
|
376
|
+
error: `HTTP ${res.status}`,
|
|
377
|
+
retryAfterMs: parseRetryAfter(res),
|
|
378
|
+
fetchedAt: Date.now(),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
344
382
|
// ============ Usage Fetchers ============
|
|
345
383
|
|
|
346
384
|
async function fetchClaudeUsage(): Promise<UsageSnapshot> {
|
|
@@ -354,11 +392,13 @@ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
|
|
|
354
392
|
headers: {
|
|
355
393
|
Authorization: `Bearer ${token}`,
|
|
356
394
|
"anthropic-beta": "oauth-2025-04-20",
|
|
395
|
+
// Other user agents land in a much stricter rate-limit bucket (persistent 429s).
|
|
396
|
+
"User-Agent": "claude-code/2.1.282",
|
|
357
397
|
},
|
|
358
398
|
});
|
|
359
399
|
|
|
360
400
|
if (!res.ok) {
|
|
361
|
-
return
|
|
401
|
+
return httpError("Claude", res);
|
|
362
402
|
}
|
|
363
403
|
|
|
364
404
|
const data = (await res.json()) as any;
|
|
@@ -367,16 +407,16 @@ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
|
|
|
367
407
|
if (data.five_hour?.utilization !== undefined) {
|
|
368
408
|
windows.push({
|
|
369
409
|
label: "5h",
|
|
370
|
-
usedPercent:
|
|
371
|
-
|
|
410
|
+
usedPercent: clampPercent(data.five_hour.utilization),
|
|
411
|
+
resetsAt: toTimestamp(data.five_hour.resets_at),
|
|
372
412
|
});
|
|
373
413
|
}
|
|
374
414
|
|
|
375
415
|
if (data.seven_day?.utilization !== undefined) {
|
|
376
416
|
windows.push({
|
|
377
417
|
label: "Week",
|
|
378
|
-
usedPercent:
|
|
379
|
-
|
|
418
|
+
usedPercent: clampPercent(data.seven_day.utilization),
|
|
419
|
+
resetsAt: toTimestamp(data.seven_day.resets_at),
|
|
380
420
|
});
|
|
381
421
|
}
|
|
382
422
|
|
|
@@ -404,19 +444,18 @@ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
|
|
|
404
444
|
});
|
|
405
445
|
|
|
406
446
|
if (!res.ok) {
|
|
407
|
-
return
|
|
447
|
+
return httpError("Copilot", res);
|
|
408
448
|
}
|
|
409
449
|
|
|
410
450
|
const data = (await res.json()) as any;
|
|
411
451
|
const windows: RateWindow[] = [];
|
|
412
452
|
|
|
413
|
-
const
|
|
414
|
-
const resetsIn = resetDate ? formatResetTime(resetDate) : undefined;
|
|
453
|
+
const resetsAt = toTimestamp(data.quota_reset_date_utc);
|
|
415
454
|
|
|
416
455
|
if (data.quota_snapshots?.premium_interactions) {
|
|
417
456
|
const pi = data.quota_snapshots.premium_interactions;
|
|
418
457
|
const usedPercent = clampPercent(100 - (pi.percent_remaining || 0));
|
|
419
|
-
windows.push({ label: "Premium", usedPercent,
|
|
458
|
+
windows.push({ label: "Premium", usedPercent, resetsAt });
|
|
420
459
|
}
|
|
421
460
|
|
|
422
461
|
if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) {
|
|
@@ -424,7 +463,7 @@ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
|
|
|
424
463
|
windows.push({
|
|
425
464
|
label: "Chat",
|
|
426
465
|
usedPercent: clampPercent(100 - (chat.percent_remaining || 0)),
|
|
427
|
-
|
|
466
|
+
resetsAt,
|
|
428
467
|
});
|
|
429
468
|
}
|
|
430
469
|
|
|
@@ -459,7 +498,7 @@ async function fetchCodexUsage(): Promise<UsageSnapshot> {
|
|
|
459
498
|
});
|
|
460
499
|
|
|
461
500
|
if (!res.ok) {
|
|
462
|
-
return
|
|
501
|
+
return httpError(providerLabel, res);
|
|
463
502
|
}
|
|
464
503
|
|
|
465
504
|
const data = (await res.json()) as any;
|
|
@@ -467,23 +506,21 @@ async function fetchCodexUsage(): Promise<UsageSnapshot> {
|
|
|
467
506
|
|
|
468
507
|
if (data.rate_limit?.primary_window) {
|
|
469
508
|
const pw = data.rate_limit.primary_window;
|
|
470
|
-
const resetDate = pw.reset_at ? new Date(pw.reset_at * 1000) : undefined;
|
|
471
509
|
const durationMs = typeof pw.limit_window_seconds === "number" ? pw.limit_window_seconds * 1000 : undefined;
|
|
472
510
|
windows.push({
|
|
473
511
|
label: getWindowLabel(durationMs, "5h"),
|
|
474
512
|
usedPercent: clampPercent(pw.used_percent || 0),
|
|
475
|
-
|
|
513
|
+
resetsAt: pw.reset_at ? pw.reset_at * 1000 : undefined,
|
|
476
514
|
});
|
|
477
515
|
}
|
|
478
516
|
|
|
479
517
|
if (data.rate_limit?.secondary_window) {
|
|
480
518
|
const sw = data.rate_limit.secondary_window;
|
|
481
|
-
const resetDate = sw.reset_at ? new Date(sw.reset_at * 1000) : undefined;
|
|
482
519
|
const durationMs = typeof sw.limit_window_seconds === "number" ? sw.limit_window_seconds * 1000 : undefined;
|
|
483
520
|
windows.push({
|
|
484
521
|
label: getWindowLabel(durationMs, "Week"),
|
|
485
522
|
usedPercent: clampPercent(sw.used_percent || 0),
|
|
486
|
-
|
|
523
|
+
resetsAt: sw.reset_at ? sw.reset_at * 1000 : undefined,
|
|
487
524
|
});
|
|
488
525
|
}
|
|
489
526
|
|
|
@@ -507,7 +544,7 @@ async function fetchGeminiUsage(): Promise<UsageSnapshot> {
|
|
|
507
544
|
});
|
|
508
545
|
|
|
509
546
|
if (!res.ok) {
|
|
510
|
-
return
|
|
547
|
+
return httpError("Gemini", res);
|
|
511
548
|
}
|
|
512
549
|
|
|
513
550
|
const data = (await res.json()) as any;
|
|
@@ -569,7 +606,7 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
|
|
|
569
606
|
});
|
|
570
607
|
|
|
571
608
|
if (!res.ok) {
|
|
572
|
-
return
|
|
609
|
+
return httpError(providerLabel, res);
|
|
573
610
|
}
|
|
574
611
|
|
|
575
612
|
const data = (await res.json()) as any;
|
|
@@ -608,7 +645,6 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
|
|
|
608
645
|
const intervalRemaining = Number(textBucket.current_interval_remaining_percent);
|
|
609
646
|
if (Number.isFinite(intervalRemaining)) {
|
|
610
647
|
const usedPercent = clampPercent(100 - intervalRemaining);
|
|
611
|
-
const resetDate = textBucket.end_time ? new Date(Number(textBucket.end_time)) : undefined;
|
|
612
648
|
const durationMs =
|
|
613
649
|
textBucket.start_time && textBucket.end_time
|
|
614
650
|
? Number(textBucket.end_time) - Number(textBucket.start_time)
|
|
@@ -616,16 +652,13 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
|
|
|
616
652
|
windows.push({
|
|
617
653
|
label: getWindowLabel(durationMs, "5h"),
|
|
618
654
|
usedPercent,
|
|
619
|
-
|
|
655
|
+
resetsAt: textBucket.end_time ? Number(textBucket.end_time) : undefined,
|
|
620
656
|
});
|
|
621
657
|
}
|
|
622
658
|
|
|
623
659
|
const weeklyRemaining = Number(textBucket.current_weekly_remaining_percent);
|
|
624
660
|
if (Number.isFinite(weeklyRemaining)) {
|
|
625
661
|
const usedPercent = clampPercent(100 - weeklyRemaining);
|
|
626
|
-
const resetDate = textBucket.weekly_end_time
|
|
627
|
-
? new Date(Number(textBucket.weekly_end_time))
|
|
628
|
-
: undefined;
|
|
629
662
|
const durationMs =
|
|
630
663
|
textBucket.weekly_start_time && textBucket.weekly_end_time
|
|
631
664
|
? Number(textBucket.weekly_end_time) - Number(textBucket.weekly_start_time)
|
|
@@ -633,7 +666,7 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
|
|
|
633
666
|
windows.push({
|
|
634
667
|
label: getWindowLabel(durationMs, "Week"),
|
|
635
668
|
usedPercent,
|
|
636
|
-
|
|
669
|
+
resetsAt: textBucket.weekly_end_time ? Number(textBucket.weekly_end_time) : undefined,
|
|
637
670
|
});
|
|
638
671
|
}
|
|
639
672
|
|
|
@@ -664,7 +697,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
|
|
|
664
697
|
});
|
|
665
698
|
|
|
666
699
|
if (!res.ok) {
|
|
667
|
-
return
|
|
700
|
+
return httpError("Kimi Coding", res);
|
|
668
701
|
}
|
|
669
702
|
|
|
670
703
|
const data = (await res.json()) as any;
|
|
@@ -676,7 +709,6 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
|
|
|
676
709
|
if (windowLimit > 0) {
|
|
677
710
|
const used = windowLimit - windowRemaining;
|
|
678
711
|
const usedPercent = clampPercent((used / windowLimit) * 100);
|
|
679
|
-
const resetDate = limit.detail?.resetTime ? new Date(limit.detail.resetTime) : undefined;
|
|
680
712
|
const durationMs =
|
|
681
713
|
limit.window?.duration && limit.window?.timeUnit === "TIME_UNIT_MINUTE"
|
|
682
714
|
? limit.window.duration * 60 * 1000
|
|
@@ -685,7 +717,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
|
|
|
685
717
|
windows.push({
|
|
686
718
|
label: getWindowLabel(durationMs, "5h"),
|
|
687
719
|
usedPercent,
|
|
688
|
-
|
|
720
|
+
resetsAt: toTimestamp(limit.detail?.resetTime),
|
|
689
721
|
});
|
|
690
722
|
}
|
|
691
723
|
}
|
|
@@ -700,7 +732,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
|
|
|
700
732
|
windows.push({
|
|
701
733
|
label: "Weekly",
|
|
702
734
|
usedPercent,
|
|
703
|
-
|
|
735
|
+
resetsAt: toTimestamp(weeklyResetTime),
|
|
704
736
|
});
|
|
705
737
|
}
|
|
706
738
|
|
|
@@ -726,7 +758,7 @@ async function fetchOpencodeGoUsage(): Promise<UsageSnapshot> {
|
|
|
726
758
|
});
|
|
727
759
|
|
|
728
760
|
if (!res.ok) {
|
|
729
|
-
return
|
|
761
|
+
return httpError(providerLabel, res);
|
|
730
762
|
}
|
|
731
763
|
|
|
732
764
|
const data = (await res.json()) as any;
|
|
@@ -734,26 +766,26 @@ async function fetchOpencodeGoUsage(): Promise<UsageSnapshot> {
|
|
|
734
766
|
|
|
735
767
|
if (data.rollingUsage) {
|
|
736
768
|
const usedPercent = clampPercent(data.rollingUsage.usagePercent ?? 0);
|
|
737
|
-
const
|
|
738
|
-
?
|
|
769
|
+
const resetsAt = Number.isFinite(data.rollingUsage.resetInSec)
|
|
770
|
+
? Date.now() + data.rollingUsage.resetInSec * 1000
|
|
739
771
|
: undefined;
|
|
740
|
-
windows.push({ label: "5h", usedPercent,
|
|
772
|
+
windows.push({ label: "5h", usedPercent, resetsAt });
|
|
741
773
|
}
|
|
742
774
|
|
|
743
775
|
if (data.weeklyUsage) {
|
|
744
776
|
const usedPercent = clampPercent(data.weeklyUsage.usagePercent ?? 0);
|
|
745
|
-
const
|
|
746
|
-
?
|
|
777
|
+
const resetsAt = Number.isFinite(data.weeklyUsage.resetInSec)
|
|
778
|
+
? Date.now() + data.weeklyUsage.resetInSec * 1000
|
|
747
779
|
: undefined;
|
|
748
|
-
windows.push({ label: "Week", usedPercent,
|
|
780
|
+
windows.push({ label: "Week", usedPercent, resetsAt });
|
|
749
781
|
}
|
|
750
782
|
|
|
751
783
|
if (data.monthlyUsage) {
|
|
752
784
|
const usedPercent = clampPercent(data.monthlyUsage.usagePercent ?? 0);
|
|
753
|
-
const
|
|
754
|
-
?
|
|
785
|
+
const resetsAt = Number.isFinite(data.monthlyUsage.resetInSec)
|
|
786
|
+
? Date.now() + data.monthlyUsage.resetInSec * 1000
|
|
755
787
|
: undefined;
|
|
756
|
-
windows.push({ label: "Month", usedPercent,
|
|
788
|
+
windows.push({ label: "Month", usedPercent, resetsAt });
|
|
757
789
|
}
|
|
758
790
|
|
|
759
791
|
return { provider: providerLabel, windows, fetchedAt: Date.now() };
|
|
@@ -780,6 +812,73 @@ function detectProvider(modelProvider: string): string | null {
|
|
|
780
812
|
return PROVIDER_MAP[modelProvider] || null;
|
|
781
813
|
}
|
|
782
814
|
|
|
815
|
+
function getCredential(provider: string): string | undefined {
|
|
816
|
+
switch (provider) {
|
|
817
|
+
case "claude":
|
|
818
|
+
return getClaudeToken();
|
|
819
|
+
case "codex": {
|
|
820
|
+
const creds = getCodexToken();
|
|
821
|
+
return creds ? `${creds.accountId ?? ""}:${creds.token}` : undefined;
|
|
822
|
+
}
|
|
823
|
+
case "copilot":
|
|
824
|
+
return getCopilotToken();
|
|
825
|
+
case "gemini":
|
|
826
|
+
return getGeminiToken();
|
|
827
|
+
case "minimax":
|
|
828
|
+
case "minimax-cn":
|
|
829
|
+
return getMinimaxToken(provider);
|
|
830
|
+
case "kimi-coding":
|
|
831
|
+
return getKimiToken();
|
|
832
|
+
case "opencode-go":
|
|
833
|
+
return getOpencodeToken();
|
|
834
|
+
default:
|
|
835
|
+
return undefined;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ============ Shared Usage Cache ============
|
|
840
|
+
|
|
841
|
+
interface SharedUsage {
|
|
842
|
+
snapshot?: UsageSnapshot; // last successful result; failures never overwrite it
|
|
843
|
+
nextFetchAt: number;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** One file per provider + account, so different logins never share numbers. */
|
|
847
|
+
function sharedUsagePath(provider: string, credential: string): string {
|
|
848
|
+
const account = createHash("sha256").update(credential).digest("hex").slice(0, 16);
|
|
849
|
+
return join(USAGE_CACHE_DIR, `${provider}-${account}.json`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function readSharedUsage(path: string): SharedUsage | null {
|
|
853
|
+
try {
|
|
854
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
855
|
+
} catch {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function writeSharedUsage(path: string, data: SharedUsage): void {
|
|
861
|
+
try {
|
|
862
|
+
mkdirSync(USAGE_CACHE_DIR, { recursive: true });
|
|
863
|
+
// Write-then-rename so readers never see a half-written file.
|
|
864
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
865
|
+
writeFileSync(tmp, JSON.stringify(data));
|
|
866
|
+
renameSync(tmp, path);
|
|
867
|
+
pruneSharedUsage();
|
|
868
|
+
} catch {}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Token rotation creates a new file per account; drop ones nobody touched in a day. */
|
|
872
|
+
function pruneSharedUsage(): void {
|
|
873
|
+
try {
|
|
874
|
+
const now = Date.now();
|
|
875
|
+
for (const name of readdirSync(USAGE_CACHE_DIR)) {
|
|
876
|
+
const file = join(USAGE_CACHE_DIR, name);
|
|
877
|
+
if (now - statSync(file).mtimeMs > USAGE_FILE_MAX_AGE) unlinkSync(file);
|
|
878
|
+
}
|
|
879
|
+
} catch {}
|
|
880
|
+
}
|
|
881
|
+
|
|
783
882
|
async function fetchUsageForProvider(provider: string): Promise<UsageSnapshot> {
|
|
784
883
|
switch (provider) {
|
|
785
884
|
case "claude":
|
|
@@ -914,7 +1013,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
914
1013
|
const dim = (s: string) => theme.fg("dim", s);
|
|
915
1014
|
const bar = renderUsageBar(window.usedPercent, Math.max(4, options?.barWidth ?? 10), theme);
|
|
916
1015
|
const pct = dim(`${Math.round(window.usedPercent)}%`);
|
|
917
|
-
const timeStr =
|
|
1016
|
+
const timeStr =
|
|
1017
|
+
options?.includeReset === false || window.resetsAt === undefined
|
|
1018
|
+
? ""
|
|
1019
|
+
: " " + dim(formatResetTime(new Date(window.resetsAt)));
|
|
918
1020
|
return `${dim(window.label)} ${bar} ${pct}${timeStr}`;
|
|
919
1021
|
}
|
|
920
1022
|
|
|
@@ -981,58 +1083,62 @@ export default function (pi: ExtensionAPI) {
|
|
|
981
1083
|
if (refreshGitCache()) tuiRef?.requestRender();
|
|
982
1084
|
}
|
|
983
1085
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1086
|
+
function showSnapshot(provider: string, snapshot: UsageSnapshot | undefined): void {
|
|
1087
|
+
if (activeProvider !== provider) return;
|
|
1088
|
+
const fresh = snapshot && Date.now() - snapshot.fetchedAt < USAGE_MAX_STALE;
|
|
1089
|
+
latestUsage = fresh ? snapshot : null;
|
|
1090
|
+
tuiRef?.requestRender();
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** Show the shared cached usage, and fetch only if it's due. Coordination is best-effort:
|
|
1094
|
+
* the fetcher pushes nextFetchAt forward first, so other instances skip while it works. */
|
|
1095
|
+
function syncUsage(provider: string): void {
|
|
1096
|
+
const credential = getCredential(provider);
|
|
1097
|
+
if (!credential) {
|
|
1098
|
+
showSnapshot(provider, undefined);
|
|
994
1099
|
return;
|
|
995
1100
|
}
|
|
996
1101
|
|
|
997
|
-
|
|
1102
|
+
const path = sharedUsagePath(provider, credential);
|
|
1103
|
+
const shared = readSharedUsage(path);
|
|
1104
|
+
showSnapshot(provider, shared?.snapshot);
|
|
1105
|
+
if (shared && Date.now() < shared.nextFetchAt) return;
|
|
998
1106
|
|
|
999
|
-
|
|
1000
|
-
const cached = usageCache.get(provider);
|
|
1001
|
-
if (cached && cached.windows.length > 0) {
|
|
1002
|
-
latestUsage = cached;
|
|
1003
|
-
tuiRef?.requestRender();
|
|
1004
|
-
}
|
|
1107
|
+
writeSharedUsage(path, { snapshot: shared?.snapshot, nextFetchAt: Date.now() + USAGE_CLAIM });
|
|
1005
1108
|
|
|
1006
|
-
// Fetch fresh in background — keep cached data on transient errors
|
|
1007
1109
|
fetchUsageForProvider(provider)
|
|
1008
1110
|
.then((u) => {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1111
|
+
const now = Date.now();
|
|
1112
|
+
const latest = readSharedUsage(path);
|
|
1113
|
+
const next: SharedUsage = u.error
|
|
1114
|
+
? {
|
|
1115
|
+
snapshot: latest?.snapshot,
|
|
1116
|
+
// Never shorten a backoff another instance may have recorded meanwhile.
|
|
1117
|
+
nextFetchAt: Math.max(now + (u.retryAfterMs ?? USAGE_ERROR_RETRY), latest?.nextFetchAt ?? 0),
|
|
1118
|
+
}
|
|
1119
|
+
: { snapshot: u, nextFetchAt: now + USAGE_TTL };
|
|
1120
|
+
writeSharedUsage(path, next);
|
|
1121
|
+
showSnapshot(provider, next.snapshot);
|
|
1014
1122
|
})
|
|
1015
1123
|
.catch(() => {});
|
|
1016
1124
|
}
|
|
1017
1125
|
|
|
1018
|
-
|
|
1126
|
+
function selectProvider(modelProvider: string): void {
|
|
1127
|
+
activeProvider = detectProvider(modelProvider);
|
|
1128
|
+
if (activeProvider) {
|
|
1129
|
+
syncUsage(activeProvider);
|
|
1130
|
+
} else {
|
|
1131
|
+
latestUsage = null;
|
|
1132
|
+
tuiRef?.requestRender();
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Re-read the shared cache periodically; also keeps reset countdowns ticking. */
|
|
1019
1137
|
function startRefreshTimer(): void {
|
|
1020
1138
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
1021
1139
|
refreshTimer = setInterval(() => {
|
|
1022
|
-
if (activeProvider)
|
|
1023
|
-
|
|
1024
|
-
const cached = usageCache.get(provider);
|
|
1025
|
-
fetchUsageForProvider(provider)
|
|
1026
|
-
.then((u) => {
|
|
1027
|
-
if (!u || activeProvider !== provider) return;
|
|
1028
|
-
if (u.windows.length === 0 && u.error && cached?.windows.length) return;
|
|
1029
|
-
usageCache.set(provider, u);
|
|
1030
|
-
latestUsage = u;
|
|
1031
|
-
tuiRef?.requestRender();
|
|
1032
|
-
})
|
|
1033
|
-
.catch(() => {});
|
|
1034
|
-
}
|
|
1035
|
-
}, USAGE_REFRESH_INTERVAL);
|
|
1140
|
+
if (activeProvider) syncUsage(activeProvider);
|
|
1141
|
+
}, USAGE_CHECK_INTERVAL);
|
|
1036
1142
|
}
|
|
1037
1143
|
|
|
1038
1144
|
function stopRefreshTimer(): void {
|
|
@@ -1056,10 +1162,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1056
1162
|
|
|
1057
1163
|
// Initial fetch inside factory — tui is guaranteed available here,
|
|
1058
1164
|
// so requestRender() will work when the async fetch completes.
|
|
1059
|
-
if (ctx.model?.provider)
|
|
1060
|
-
|
|
1061
|
-
startRefreshTimer();
|
|
1062
|
-
}
|
|
1165
|
+
if (ctx.model?.provider) selectProvider(ctx.model.provider);
|
|
1166
|
+
startRefreshTimer();
|
|
1063
1167
|
|
|
1064
1168
|
return {
|
|
1065
1169
|
dispose: () => {
|
|
@@ -1156,10 +1260,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1156
1260
|
refreshGitFooter();
|
|
1157
1261
|
});
|
|
1158
1262
|
|
|
1159
|
-
// Refresh when model changes — fetch immediately, restart timer
|
|
1160
1263
|
pi.on("model_select", (event, _ctx) => {
|
|
1161
1264
|
if (!event.model?.provider) return;
|
|
1162
|
-
|
|
1163
|
-
startRefreshTimer(); // reset the 5min countdown since we just fetched
|
|
1265
|
+
selectProvider(event.model.provider);
|
|
1164
1266
|
});
|
|
1165
1267
|
}
|