@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.
- package/README.md +5 -8
- package/index.ts +240 -110
- 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
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
|
@@ -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
|
-
|
|
43
|
-
|
|
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(
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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
|
|
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:
|
|
373
|
-
|
|
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:
|
|
381
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
740
|
-
?
|
|
779
|
+
const resetsAt = Number.isFinite(data.rollingUsage.resetInSec)
|
|
780
|
+
? Date.now() + data.rollingUsage.resetInSec * 1000
|
|
741
781
|
: undefined;
|
|
742
|
-
windows.push({ label: "5h", usedPercent,
|
|
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
|
|
748
|
-
?
|
|
787
|
+
const resetsAt = Number.isFinite(data.weeklyUsage.resetInSec)
|
|
788
|
+
? Date.now() + data.weeklyUsage.resetInSec * 1000
|
|
749
789
|
: undefined;
|
|
750
|
-
windows.push({ label: "Week", usedPercent,
|
|
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
|
|
756
|
-
?
|
|
795
|
+
const resetsAt = Number.isFinite(data.monthlyUsage.resetInSec)
|
|
796
|
+
? Date.now() + data.monthlyUsage.resetInSec * 1000
|
|
757
797
|
: undefined;
|
|
758
|
-
windows.push({ label: "Month", usedPercent,
|
|
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
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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 =
|
|
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):
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1165
|
-
startRefreshTimer(); // reset the 5min countdown since we just fetched
|
|
1295
|
+
selectProvider(event.model.provider);
|
|
1166
1296
|
});
|
|
1167
1297
|
}
|