@hk_net/pi-usage-bars 0.4.0 → 0.4.2
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/CHANGELOG.md +103 -77
- package/LICENSE +21 -21
- package/README.md +127 -126
- package/docs/releasing.md +84 -106
- package/extensions/usage-bars/core.ts +1274 -1240
- package/extensions/usage-bars/index.ts +665 -656
- package/package.json +66 -67
- package/docs/financial-metrics-plan.md +0 -75
|
@@ -1,656 +1,665 @@
|
|
|
1
|
-
/** Quota, balance, and spend indicators for providers supported by current Pi releases. */
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
DynamicBorder,
|
|
5
|
-
type ExtensionAPI,
|
|
6
|
-
type ExtensionContext,
|
|
7
|
-
type KeybindingsManager,
|
|
8
|
-
type Theme,
|
|
9
|
-
} from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import {
|
|
11
|
-
Container,
|
|
12
|
-
Input,
|
|
13
|
-
Spacer,
|
|
14
|
-
Text,
|
|
15
|
-
type Focusable,
|
|
16
|
-
type TUI,
|
|
17
|
-
} from "@earendil-works/pi-tui";
|
|
18
|
-
import {
|
|
19
|
-
clampPercent,
|
|
20
|
-
colorForPercent,
|
|
21
|
-
detectProvider,
|
|
22
|
-
fetchAllUsages,
|
|
23
|
-
fetchClaudeUsageWithFallback,
|
|
24
|
-
fetchCodexUsage,
|
|
25
|
-
fetchDeepSeekBalance,
|
|
26
|
-
fetchKimiUsage,
|
|
27
|
-
fetchMiniMaxUsage,
|
|
28
|
-
fetchMoonshotBalance,
|
|
29
|
-
fetchOpenRouterUsage,
|
|
30
|
-
fetchZaiUsage,
|
|
31
|
-
providerToPiProviderId,
|
|
32
|
-
resolveUsageEndpoints,
|
|
33
|
-
type AccountBalance,
|
|
34
|
-
type AccountSpend,
|
|
35
|
-
type ProviderKey,
|
|
36
|
-
type UsageByProvider,
|
|
37
|
-
type UsageData,
|
|
38
|
-
type UsageTokens,
|
|
39
|
-
} from "./core";
|
|
40
|
-
|
|
41
|
-
const POLL_INTERVAL_MS = 2 * 60 * 1000;
|
|
42
|
-
const STATUS_KEY = "usage-bars";
|
|
43
|
-
const PROVIDERS: readonly ProviderKey[] = [
|
|
44
|
-
"codex",
|
|
45
|
-
"claude",
|
|
46
|
-
"zai",
|
|
47
|
-
"zai-cn",
|
|
48
|
-
"kimi",
|
|
49
|
-
"minimax",
|
|
50
|
-
"minimax-cn",
|
|
51
|
-
"openrouter",
|
|
52
|
-
"deepseek",
|
|
53
|
-
"moonshot",
|
|
54
|
-
"moonshot-cn",
|
|
55
|
-
];
|
|
56
|
-
|
|
57
|
-
const PROVIDER_LABELS: Record<ProviderKey, string> = {
|
|
58
|
-
codex: "Codex",
|
|
59
|
-
claude: "Claude",
|
|
60
|
-
zai: "ZAI Coding Plan (Global)",
|
|
61
|
-
"zai-cn": "ZAI Coding Plan (China)",
|
|
62
|
-
kimi: "Kimi For Coding",
|
|
63
|
-
minimax: "MiniMax Coding Plan (Global)",
|
|
64
|
-
"minimax-cn": "MiniMax Coding Plan (China)",
|
|
65
|
-
openrouter: "OpenRouter",
|
|
66
|
-
deepseek: "DeepSeek",
|
|
67
|
-
moonshot: "Moonshot/Kimi API (Global)",
|
|
68
|
-
"moonshot-cn": "Moonshot/Kimi API (China)",
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
function formatFinancialAmount(amount: number, unit: string): string {
|
|
72
|
-
if (/^[A-Z]{3}$/.test(unit)) {
|
|
73
|
-
return new Intl.NumberFormat("en-US", {
|
|
74
|
-
style: "currency",
|
|
75
|
-
currency: unit,
|
|
76
|
-
minimumFractionDigits: 2,
|
|
77
|
-
maximumFractionDigits: 5,
|
|
78
|
-
}).format(amount);
|
|
79
|
-
}
|
|
80
|
-
const formatted = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(amount);
|
|
81
|
-
return `${formatted} ${unit}`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function formatAccountBalance(balance: AccountBalance): string {
|
|
85
|
-
return `${balance.label} · ${formatFinancialAmount(balance.amount, balance.unit)}`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function formatAccountSpend(spend: AccountSpend): string {
|
|
89
|
-
const values = [
|
|
90
|
-
spend.daily === undefined ? undefined : `today ${formatFinancialAmount(spend.daily, spend.unit)}`,
|
|
91
|
-
spend.weekly === undefined ? undefined : `week ${formatFinancialAmount(spend.weekly, spend.unit)}`,
|
|
92
|
-
spend.monthly === undefined ? undefined : `month ${formatFinancialAmount(spend.monthly, spend.unit)}`,
|
|
93
|
-
].filter((value): value is string => Boolean(value));
|
|
94
|
-
if (values.length === 0 && spend.lifetime !== undefined) {
|
|
95
|
-
values.push(`lifetime ${formatFinancialAmount(spend.lifetime, spend.unit)}`);
|
|
96
|
-
}
|
|
97
|
-
return `Spent · ${values.join(" · ")}`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
interface SubscriptionItem {
|
|
101
|
-
name: string;
|
|
102
|
-
provider: ProviderKey;
|
|
103
|
-
data: UsageData;
|
|
104
|
-
isActive: boolean;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
interface CredentialResolution {
|
|
108
|
-
token?: string;
|
|
109
|
-
error?: string;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
class UsageSelectorComponent extends Container implements Focusable {
|
|
113
|
-
private readonly searchInput: Input;
|
|
114
|
-
private readonly listContainer: Container;
|
|
115
|
-
private readonly hintText: Text;
|
|
116
|
-
private readonly requestController = new AbortController();
|
|
117
|
-
private readonly tui: TUI;
|
|
118
|
-
private readonly theme: Theme;
|
|
119
|
-
private readonly keybindings: KeybindingsManager;
|
|
120
|
-
private readonly onCancelCallback: () => void;
|
|
121
|
-
private readonly activeProvider: ProviderKey | null;
|
|
122
|
-
private readonly fetchAllFn: (signal: AbortSignal) => Promise<UsageByProvider>;
|
|
123
|
-
private allItems: SubscriptionItem[] = [];
|
|
124
|
-
private filteredItems: SubscriptionItem[] = [];
|
|
125
|
-
private selectedIndex = 0;
|
|
126
|
-
private loading = true;
|
|
127
|
-
private hint: "loading" | "ready" | "error" = "loading";
|
|
128
|
-
private disposed = false;
|
|
129
|
-
private _focused = false;
|
|
130
|
-
|
|
131
|
-
get focused(): boolean {
|
|
132
|
-
return this._focused;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
set focused(value: boolean) {
|
|
136
|
-
this._focused = value;
|
|
137
|
-
this.searchInput.focused = value;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
constructor(
|
|
141
|
-
tui: TUI,
|
|
142
|
-
theme: Theme,
|
|
143
|
-
keybindings: KeybindingsManager,
|
|
144
|
-
activeProvider: ProviderKey | null,
|
|
145
|
-
fetchAll: (signal: AbortSignal) => Promise<UsageByProvider>,
|
|
146
|
-
onCancel: () => void,
|
|
147
|
-
) {
|
|
148
|
-
super();
|
|
149
|
-
this.tui = tui;
|
|
150
|
-
this.theme = theme;
|
|
151
|
-
this.keybindings = keybindings;
|
|
152
|
-
this.activeProvider = activeProvider;
|
|
153
|
-
this.fetchAllFn = fetchAll;
|
|
154
|
-
this.onCancelCallback = onCancel;
|
|
155
|
-
|
|
156
|
-
this.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
157
|
-
this.addChild(new Spacer(1));
|
|
158
|
-
this.hintText = new Text("", 0, 0);
|
|
159
|
-
this.addChild(this.hintText);
|
|
160
|
-
this.addChild(new Spacer(1));
|
|
161
|
-
this.searchInput = new Input();
|
|
162
|
-
this.addChild(this.searchInput);
|
|
163
|
-
this.addChild(new Spacer(1));
|
|
164
|
-
this.listContainer = new Container();
|
|
165
|
-
this.addChild(this.listContainer);
|
|
166
|
-
this.addChild(new Spacer(1));
|
|
167
|
-
this.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
168
|
-
|
|
169
|
-
this.updateHint();
|
|
170
|
-
this.updateList();
|
|
171
|
-
void this.load();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
private async load(): Promise<void> {
|
|
175
|
-
try {
|
|
176
|
-
const results = await this.fetchAllFn(this.requestController.signal);
|
|
177
|
-
if (this.disposed || this.requestController.signal.aborted) return;
|
|
178
|
-
this.loading = false;
|
|
179
|
-
this.hint = "ready";
|
|
180
|
-
this.buildItems(results);
|
|
181
|
-
} catch {
|
|
182
|
-
if (this.disposed || this.requestController.signal.aborted) return;
|
|
183
|
-
this.loading = false;
|
|
184
|
-
this.hint = "error";
|
|
185
|
-
}
|
|
186
|
-
this.updateHint();
|
|
187
|
-
this.updateList();
|
|
188
|
-
this.tui.requestRender();
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
private updateHint(): void {
|
|
192
|
-
if (this.hint === "loading") {
|
|
193
|
-
this.hintText.setText(this.theme.fg("dim", "Fetching quota, balance, and spend from configured providers…"));
|
|
194
|
-
} else if (this.hint === "error") {
|
|
195
|
-
this.hintText.setText(this.theme.fg("error", "Failed to fetch usage data"));
|
|
196
|
-
} else {
|
|
197
|
-
this.hintText.setText(
|
|
198
|
-
this.theme.fg("muted", "Only showing configured usage providers. ") +
|
|
199
|
-
this.theme.fg("dim", "✓ = active provider"),
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
private buildItems(results: UsageByProvider): void {
|
|
205
|
-
this.allItems = PROVIDERS.flatMap((provider) => {
|
|
206
|
-
const data = results[provider];
|
|
207
|
-
return data
|
|
208
|
-
? [{
|
|
209
|
-
name: PROVIDER_LABELS[provider],
|
|
210
|
-
provider,
|
|
211
|
-
data,
|
|
212
|
-
isActive: this.activeProvider === provider,
|
|
213
|
-
}]
|
|
214
|
-
: [];
|
|
215
|
-
});
|
|
216
|
-
this.filteredItems = this.allItems;
|
|
217
|
-
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
private filterItems(query: string): void {
|
|
221
|
-
const normalized = query.trim().toLowerCase();
|
|
222
|
-
this.filteredItems = normalized
|
|
223
|
-
? this.allItems.filter((item) =>
|
|
224
|
-
item.name.toLowerCase().includes(normalized) || item.provider.includes(normalized))
|
|
225
|
-
: this.allItems;
|
|
226
|
-
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
private renderBar(percent: number, width = 16): string {
|
|
230
|
-
const value = clampPercent(percent);
|
|
231
|
-
const filled = Math.round((value / 100) * width);
|
|
232
|
-
return this.theme.fg(colorForPercent(value), "█".repeat(filled)) +
|
|
233
|
-
this.theme.fg("dim", "░".repeat(width - filled));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private renderItem(item: SubscriptionItem, selected: boolean): void {
|
|
237
|
-
const theme = this.theme;
|
|
238
|
-
const pointer = selected ? theme.fg("accent", "→ ") : " ";
|
|
239
|
-
const activeBadge = item.isActive ? theme.fg("success", " ✓") : "";
|
|
240
|
-
const name = selected ? theme.fg("accent", theme.bold(item.name)) : item.name;
|
|
241
|
-
this.listContainer.addChild(new Text(`${pointer}${name}${activeBadge}`, 0, 0));
|
|
242
|
-
|
|
243
|
-
const indent = " ";
|
|
244
|
-
if (item.data.error) {
|
|
245
|
-
this.listContainer.addChild(new Text(indent + theme.fg("error", item.data.error), 0, 0));
|
|
246
|
-
} else {
|
|
247
|
-
const session = clampPercent(item.data.session);
|
|
248
|
-
const weekly = clampPercent(item.data.weekly);
|
|
249
|
-
const sessionReset = item.data.sessionResetsIn
|
|
250
|
-
? theme.fg("dim", ` resets in ${item.data.sessionResetsIn}`)
|
|
251
|
-
: "";
|
|
252
|
-
const weeklyReset = item.data.weeklyResetsIn
|
|
253
|
-
? theme.fg("dim", ` resets in ${item.data.weeklyResetsIn}`)
|
|
254
|
-
: "";
|
|
255
|
-
const sessionLabel = (item.data.sessionLabel ?? "Session").slice(0, 9).padEnd(10);
|
|
256
|
-
const weeklyLabel = (item.data.weeklyLabel ?? "Weekly").slice(0, 9).padEnd(10);
|
|
257
|
-
|
|
258
|
-
if (!item.data.quotaHidden) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
theme.fg(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
this.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
let
|
|
402
|
-
let
|
|
403
|
-
let
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
if (!
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
(
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if (
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if (
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
}
|
|
534
|
-
if (provider === "
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
if (provider === "
|
|
538
|
-
state.
|
|
539
|
-
}
|
|
540
|
-
if (provider === "
|
|
541
|
-
state
|
|
542
|
-
}
|
|
543
|
-
if (provider === "
|
|
544
|
-
state
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
currentContext =
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
void
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1
|
+
/** Quota, balance, and spend indicators for providers supported by current Pi releases. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DynamicBorder,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
type KeybindingsManager,
|
|
8
|
+
type Theme,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
Container,
|
|
12
|
+
Input,
|
|
13
|
+
Spacer,
|
|
14
|
+
Text,
|
|
15
|
+
type Focusable,
|
|
16
|
+
type TUI,
|
|
17
|
+
} from "@earendil-works/pi-tui";
|
|
18
|
+
import {
|
|
19
|
+
clampPercent,
|
|
20
|
+
colorForPercent,
|
|
21
|
+
detectProvider,
|
|
22
|
+
fetchAllUsages,
|
|
23
|
+
fetchClaudeUsageWithFallback,
|
|
24
|
+
fetchCodexUsage,
|
|
25
|
+
fetchDeepSeekBalance,
|
|
26
|
+
fetchKimiUsage,
|
|
27
|
+
fetchMiniMaxUsage,
|
|
28
|
+
fetchMoonshotBalance,
|
|
29
|
+
fetchOpenRouterUsage,
|
|
30
|
+
fetchZaiUsage,
|
|
31
|
+
providerToPiProviderId,
|
|
32
|
+
resolveUsageEndpoints,
|
|
33
|
+
type AccountBalance,
|
|
34
|
+
type AccountSpend,
|
|
35
|
+
type ProviderKey,
|
|
36
|
+
type UsageByProvider,
|
|
37
|
+
type UsageData,
|
|
38
|
+
type UsageTokens,
|
|
39
|
+
} from "./core";
|
|
40
|
+
|
|
41
|
+
const POLL_INTERVAL_MS = 2 * 60 * 1000;
|
|
42
|
+
const STATUS_KEY = "usage-bars";
|
|
43
|
+
const PROVIDERS: readonly ProviderKey[] = [
|
|
44
|
+
"codex",
|
|
45
|
+
"claude",
|
|
46
|
+
"zai",
|
|
47
|
+
"zai-cn",
|
|
48
|
+
"kimi",
|
|
49
|
+
"minimax",
|
|
50
|
+
"minimax-cn",
|
|
51
|
+
"openrouter",
|
|
52
|
+
"deepseek",
|
|
53
|
+
"moonshot",
|
|
54
|
+
"moonshot-cn",
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const PROVIDER_LABELS: Record<ProviderKey, string> = {
|
|
58
|
+
codex: "Codex",
|
|
59
|
+
claude: "Claude",
|
|
60
|
+
zai: "ZAI Coding Plan (Global)",
|
|
61
|
+
"zai-cn": "ZAI Coding Plan (China)",
|
|
62
|
+
kimi: "Kimi For Coding",
|
|
63
|
+
minimax: "MiniMax Coding Plan (Global)",
|
|
64
|
+
"minimax-cn": "MiniMax Coding Plan (China)",
|
|
65
|
+
openrouter: "OpenRouter",
|
|
66
|
+
deepseek: "DeepSeek",
|
|
67
|
+
moonshot: "Moonshot/Kimi API (Global)",
|
|
68
|
+
"moonshot-cn": "Moonshot/Kimi API (China)",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function formatFinancialAmount(amount: number, unit: string): string {
|
|
72
|
+
if (/^[A-Z]{3}$/.test(unit)) {
|
|
73
|
+
return new Intl.NumberFormat("en-US", {
|
|
74
|
+
style: "currency",
|
|
75
|
+
currency: unit,
|
|
76
|
+
minimumFractionDigits: 2,
|
|
77
|
+
maximumFractionDigits: 5,
|
|
78
|
+
}).format(amount);
|
|
79
|
+
}
|
|
80
|
+
const formatted = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(amount);
|
|
81
|
+
return `${formatted} ${unit}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatAccountBalance(balance: AccountBalance): string {
|
|
85
|
+
return `${balance.label} · ${formatFinancialAmount(balance.amount, balance.unit)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatAccountSpend(spend: AccountSpend): string {
|
|
89
|
+
const values = [
|
|
90
|
+
spend.daily === undefined ? undefined : `today ${formatFinancialAmount(spend.daily, spend.unit)}`,
|
|
91
|
+
spend.weekly === undefined ? undefined : `week ${formatFinancialAmount(spend.weekly, spend.unit)}`,
|
|
92
|
+
spend.monthly === undefined ? undefined : `month ${formatFinancialAmount(spend.monthly, spend.unit)}`,
|
|
93
|
+
].filter((value): value is string => Boolean(value));
|
|
94
|
+
if (values.length === 0 && spend.lifetime !== undefined) {
|
|
95
|
+
values.push(`lifetime ${formatFinancialAmount(spend.lifetime, spend.unit)}`);
|
|
96
|
+
}
|
|
97
|
+
return `Spent · ${values.join(" · ")}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface SubscriptionItem {
|
|
101
|
+
name: string;
|
|
102
|
+
provider: ProviderKey;
|
|
103
|
+
data: UsageData;
|
|
104
|
+
isActive: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface CredentialResolution {
|
|
108
|
+
token?: string;
|
|
109
|
+
error?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class UsageSelectorComponent extends Container implements Focusable {
|
|
113
|
+
private readonly searchInput: Input;
|
|
114
|
+
private readonly listContainer: Container;
|
|
115
|
+
private readonly hintText: Text;
|
|
116
|
+
private readonly requestController = new AbortController();
|
|
117
|
+
private readonly tui: TUI;
|
|
118
|
+
private readonly theme: Theme;
|
|
119
|
+
private readonly keybindings: KeybindingsManager;
|
|
120
|
+
private readonly onCancelCallback: () => void;
|
|
121
|
+
private readonly activeProvider: ProviderKey | null;
|
|
122
|
+
private readonly fetchAllFn: (signal: AbortSignal) => Promise<UsageByProvider>;
|
|
123
|
+
private allItems: SubscriptionItem[] = [];
|
|
124
|
+
private filteredItems: SubscriptionItem[] = [];
|
|
125
|
+
private selectedIndex = 0;
|
|
126
|
+
private loading = true;
|
|
127
|
+
private hint: "loading" | "ready" | "error" = "loading";
|
|
128
|
+
private disposed = false;
|
|
129
|
+
private _focused = false;
|
|
130
|
+
|
|
131
|
+
get focused(): boolean {
|
|
132
|
+
return this._focused;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
set focused(value: boolean) {
|
|
136
|
+
this._focused = value;
|
|
137
|
+
this.searchInput.focused = value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
constructor(
|
|
141
|
+
tui: TUI,
|
|
142
|
+
theme: Theme,
|
|
143
|
+
keybindings: KeybindingsManager,
|
|
144
|
+
activeProvider: ProviderKey | null,
|
|
145
|
+
fetchAll: (signal: AbortSignal) => Promise<UsageByProvider>,
|
|
146
|
+
onCancel: () => void,
|
|
147
|
+
) {
|
|
148
|
+
super();
|
|
149
|
+
this.tui = tui;
|
|
150
|
+
this.theme = theme;
|
|
151
|
+
this.keybindings = keybindings;
|
|
152
|
+
this.activeProvider = activeProvider;
|
|
153
|
+
this.fetchAllFn = fetchAll;
|
|
154
|
+
this.onCancelCallback = onCancel;
|
|
155
|
+
|
|
156
|
+
this.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
157
|
+
this.addChild(new Spacer(1));
|
|
158
|
+
this.hintText = new Text("", 0, 0);
|
|
159
|
+
this.addChild(this.hintText);
|
|
160
|
+
this.addChild(new Spacer(1));
|
|
161
|
+
this.searchInput = new Input();
|
|
162
|
+
this.addChild(this.searchInput);
|
|
163
|
+
this.addChild(new Spacer(1));
|
|
164
|
+
this.listContainer = new Container();
|
|
165
|
+
this.addChild(this.listContainer);
|
|
166
|
+
this.addChild(new Spacer(1));
|
|
167
|
+
this.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
168
|
+
|
|
169
|
+
this.updateHint();
|
|
170
|
+
this.updateList();
|
|
171
|
+
void this.load();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async load(): Promise<void> {
|
|
175
|
+
try {
|
|
176
|
+
const results = await this.fetchAllFn(this.requestController.signal);
|
|
177
|
+
if (this.disposed || this.requestController.signal.aborted) return;
|
|
178
|
+
this.loading = false;
|
|
179
|
+
this.hint = "ready";
|
|
180
|
+
this.buildItems(results);
|
|
181
|
+
} catch {
|
|
182
|
+
if (this.disposed || this.requestController.signal.aborted) return;
|
|
183
|
+
this.loading = false;
|
|
184
|
+
this.hint = "error";
|
|
185
|
+
}
|
|
186
|
+
this.updateHint();
|
|
187
|
+
this.updateList();
|
|
188
|
+
this.tui.requestRender();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private updateHint(): void {
|
|
192
|
+
if (this.hint === "loading") {
|
|
193
|
+
this.hintText.setText(this.theme.fg("dim", "Fetching quota, balance, and spend from configured providers…"));
|
|
194
|
+
} else if (this.hint === "error") {
|
|
195
|
+
this.hintText.setText(this.theme.fg("error", "Failed to fetch usage data"));
|
|
196
|
+
} else {
|
|
197
|
+
this.hintText.setText(
|
|
198
|
+
this.theme.fg("muted", "Only showing configured usage providers. ") +
|
|
199
|
+
this.theme.fg("dim", "✓ = active provider"),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private buildItems(results: UsageByProvider): void {
|
|
205
|
+
this.allItems = PROVIDERS.flatMap((provider) => {
|
|
206
|
+
const data = results[provider];
|
|
207
|
+
return data
|
|
208
|
+
? [{
|
|
209
|
+
name: PROVIDER_LABELS[provider],
|
|
210
|
+
provider,
|
|
211
|
+
data,
|
|
212
|
+
isActive: this.activeProvider === provider,
|
|
213
|
+
}]
|
|
214
|
+
: [];
|
|
215
|
+
});
|
|
216
|
+
this.filteredItems = this.allItems;
|
|
217
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private filterItems(query: string): void {
|
|
221
|
+
const normalized = query.trim().toLowerCase();
|
|
222
|
+
this.filteredItems = normalized
|
|
223
|
+
? this.allItems.filter((item) =>
|
|
224
|
+
item.name.toLowerCase().includes(normalized) || item.provider.includes(normalized))
|
|
225
|
+
: this.allItems;
|
|
226
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private renderBar(percent: number, width = 16): string {
|
|
230
|
+
const value = clampPercent(percent);
|
|
231
|
+
const filled = Math.round((value / 100) * width);
|
|
232
|
+
return this.theme.fg(colorForPercent(value), "█".repeat(filled)) +
|
|
233
|
+
this.theme.fg("dim", "░".repeat(width - filled));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private renderItem(item: SubscriptionItem, selected: boolean): void {
|
|
237
|
+
const theme = this.theme;
|
|
238
|
+
const pointer = selected ? theme.fg("accent", "→ ") : " ";
|
|
239
|
+
const activeBadge = item.isActive ? theme.fg("success", " ✓") : "";
|
|
240
|
+
const name = selected ? theme.fg("accent", theme.bold(item.name)) : item.name;
|
|
241
|
+
this.listContainer.addChild(new Text(`${pointer}${name}${activeBadge}`, 0, 0));
|
|
242
|
+
|
|
243
|
+
const indent = " ";
|
|
244
|
+
if (item.data.error) {
|
|
245
|
+
this.listContainer.addChild(new Text(indent + theme.fg("error", item.data.error), 0, 0));
|
|
246
|
+
} else {
|
|
247
|
+
const session = clampPercent(item.data.session);
|
|
248
|
+
const weekly = clampPercent(item.data.weekly);
|
|
249
|
+
const sessionReset = item.data.sessionResetsIn
|
|
250
|
+
? theme.fg("dim", ` resets in ${item.data.sessionResetsIn}`)
|
|
251
|
+
: "";
|
|
252
|
+
const weeklyReset = item.data.weeklyResetsIn
|
|
253
|
+
? theme.fg("dim", ` resets in ${item.data.weeklyResetsIn}`)
|
|
254
|
+
: "";
|
|
255
|
+
const sessionLabel = (item.data.sessionLabel ?? "Session").slice(0, 9).padEnd(10);
|
|
256
|
+
const weeklyLabel = (item.data.weeklyLabel ?? "Weekly").slice(0, 9).padEnd(10);
|
|
257
|
+
|
|
258
|
+
if (!item.data.quotaHidden) {
|
|
259
|
+
if (!item.data.sessionHidden) {
|
|
260
|
+
this.listContainer.addChild(new Text(
|
|
261
|
+
indent + theme.fg("muted", sessionLabel) + this.renderBar(session) + " " +
|
|
262
|
+
theme.fg(colorForPercent(session), `${session}%`.padStart(4)) + sessionReset,
|
|
263
|
+
0,
|
|
264
|
+
0,
|
|
265
|
+
));
|
|
266
|
+
}
|
|
267
|
+
if (!item.data.weeklyHidden) {
|
|
268
|
+
this.listContainer.addChild(new Text(
|
|
269
|
+
indent + theme.fg("muted", weeklyLabel) + this.renderBar(weekly) + " " +
|
|
270
|
+
theme.fg(colorForPercent(weekly), `${weekly}%`.padStart(4)) + weeklyReset,
|
|
271
|
+
0,
|
|
272
|
+
0,
|
|
273
|
+
));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (item.data.accountBalance) {
|
|
277
|
+
this.listContainer.addChild(new Text(
|
|
278
|
+
indent + theme.fg("muted", formatAccountBalance(item.data.accountBalance)),
|
|
279
|
+
0,
|
|
280
|
+
0,
|
|
281
|
+
));
|
|
282
|
+
}
|
|
283
|
+
for (const balance of item.data.accountBalanceDetails ?? []) {
|
|
284
|
+
this.listContainer.addChild(new Text(
|
|
285
|
+
indent + theme.fg("dim", formatAccountBalance(balance)),
|
|
286
|
+
0,
|
|
287
|
+
0,
|
|
288
|
+
));
|
|
289
|
+
}
|
|
290
|
+
if (item.data.accountSpend) {
|
|
291
|
+
this.listContainer.addChild(new Text(
|
|
292
|
+
indent + theme.fg("muted", formatAccountSpend(item.data.accountSpend)),
|
|
293
|
+
0,
|
|
294
|
+
0,
|
|
295
|
+
));
|
|
296
|
+
}
|
|
297
|
+
if (item.data.notice) {
|
|
298
|
+
this.listContainer.addChild(new Text(indent + theme.fg("muted", item.data.notice), 0, 0));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (typeof item.data.extraSpend === "number" && typeof item.data.extraLimit === "number") {
|
|
302
|
+
this.listContainer.addChild(new Text(
|
|
303
|
+
indent + theme.fg("muted", "Extra ") +
|
|
304
|
+
theme.fg("dim", `$${item.data.extraSpend.toFixed(2)} / $${item.data.extraLimit}`),
|
|
305
|
+
0,
|
|
306
|
+
0,
|
|
307
|
+
));
|
|
308
|
+
}
|
|
309
|
+
if (item.data.warning) {
|
|
310
|
+
this.listContainer.addChild(new Text(indent + theme.fg("warning", `⚠ ${item.data.warning}`), 0, 0));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
this.listContainer.addChild(new Spacer(1));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private updateList(): void {
|
|
317
|
+
this.listContainer.clear();
|
|
318
|
+
if (this.loading) {
|
|
319
|
+
this.listContainer.addChild(new Text(this.theme.fg("muted", " Loading…"), 0, 0));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (this.filteredItems.length === 0) {
|
|
323
|
+
this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching configured providers"), 0, 0));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this.filteredItems.forEach((item, index) => this.renderItem(item, index === this.selectedIndex));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private refresh(): void {
|
|
330
|
+
this.updateList();
|
|
331
|
+
this.tui.requestRender();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
handleInput(keyData: string): void {
|
|
335
|
+
if (this.keybindings.matches(keyData, "tui.select.up")) {
|
|
336
|
+
if (this.filteredItems.length > 0) {
|
|
337
|
+
this.selectedIndex = this.selectedIndex === 0
|
|
338
|
+
? this.filteredItems.length - 1
|
|
339
|
+
: this.selectedIndex - 1;
|
|
340
|
+
this.refresh();
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (this.keybindings.matches(keyData, "tui.select.down")) {
|
|
345
|
+
if (this.filteredItems.length > 0) {
|
|
346
|
+
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1
|
|
347
|
+
? 0
|
|
348
|
+
: this.selectedIndex + 1;
|
|
349
|
+
this.refresh();
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (
|
|
354
|
+
this.keybindings.matches(keyData, "tui.select.cancel") ||
|
|
355
|
+
this.keybindings.matches(keyData, "tui.select.confirm")
|
|
356
|
+
) {
|
|
357
|
+
this.onCancelCallback();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.searchInput.handleInput(keyData);
|
|
362
|
+
this.filterItems(this.searchInput.getValue());
|
|
363
|
+
this.refresh();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
override invalidate(): void {
|
|
367
|
+
super.invalidate();
|
|
368
|
+
this.updateHint();
|
|
369
|
+
this.updateList();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
dispose(): void {
|
|
373
|
+
this.disposed = true;
|
|
374
|
+
this.requestController.abort();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
interface UsageState extends UsageByProvider {
|
|
379
|
+
activeProvider: ProviderKey | null;
|
|
380
|
+
available: Partial<Record<ProviderKey, boolean>>;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export default function (pi: ExtensionAPI): void {
|
|
384
|
+
const endpoints = resolveUsageEndpoints();
|
|
385
|
+
const state: UsageState = {
|
|
386
|
+
codex: null,
|
|
387
|
+
claude: null,
|
|
388
|
+
zai: null,
|
|
389
|
+
"zai-cn": null,
|
|
390
|
+
kimi: null,
|
|
391
|
+
minimax: null,
|
|
392
|
+
"minimax-cn": null,
|
|
393
|
+
openrouter: null,
|
|
394
|
+
deepseek: null,
|
|
395
|
+
moonshot: null,
|
|
396
|
+
"moonshot-cn": null,
|
|
397
|
+
activeProvider: null,
|
|
398
|
+
available: {},
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
402
|
+
let pollInFlight: Promise<void> | undefined;
|
|
403
|
+
let pollQueued = false;
|
|
404
|
+
let currentContext: ExtensionContext | undefined;
|
|
405
|
+
let sessionController: AbortController | undefined;
|
|
406
|
+
|
|
407
|
+
const renderPercent = (theme: Theme, value: number) => {
|
|
408
|
+
const percent = clampPercent(value);
|
|
409
|
+
return theme.fg(colorForPercent(percent), `${percent}%`);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const renderBar = (theme: Theme, value: number) => {
|
|
413
|
+
const percent = clampPercent(value);
|
|
414
|
+
const width = 8;
|
|
415
|
+
const filled = Math.round((percent / 100) * width);
|
|
416
|
+
return theme.fg(colorForPercent(percent), "█".repeat(filled)) +
|
|
417
|
+
theme.fg("dim", "░".repeat(width - filled));
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
function updateStatus(): void {
|
|
421
|
+
const ctx = currentContext;
|
|
422
|
+
if (!ctx || ctx.mode !== "tui") return;
|
|
423
|
+
const provider = state.activeProvider;
|
|
424
|
+
if (!provider || state.available[provider] === false) {
|
|
425
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const data = state[provider];
|
|
430
|
+
const theme = ctx.ui.theme;
|
|
431
|
+
const label = PROVIDER_LABELS[provider];
|
|
432
|
+
if (!data) {
|
|
433
|
+
ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", `${label} usage: loading…`));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (data.error) {
|
|
437
|
+
ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `${label} usage unavailable (${data.error})`));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (data.quotaHidden) {
|
|
441
|
+
const financial = [
|
|
442
|
+
data.accountBalance ? formatAccountBalance(data.accountBalance) : undefined,
|
|
443
|
+
data.accountSpend?.monthly === undefined
|
|
444
|
+
? undefined
|
|
445
|
+
: `Month · ${formatFinancialAmount(data.accountSpend.monthly, data.accountSpend.unit)}`,
|
|
446
|
+
].filter((value): value is string => Boolean(value));
|
|
447
|
+
const summary = financial.length > 0 ? financial.join(" · ") : data.notice;
|
|
448
|
+
ctx.ui.setStatus(
|
|
449
|
+
STATUS_KEY,
|
|
450
|
+
summary ? theme.fg("dim", `${label} `) + theme.fg("muted", summary) : undefined,
|
|
451
|
+
);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const session = clampPercent(data.session);
|
|
456
|
+
const weekly = clampPercent(data.weekly);
|
|
457
|
+
const sessionPrefix = data.sessionLabel === "5-hour"
|
|
458
|
+
? "5h "
|
|
459
|
+
: data.sessionLabel === "Interval"
|
|
460
|
+
? "I "
|
|
461
|
+
: data.sessionLabel === "Key limit"
|
|
462
|
+
? "L "
|
|
463
|
+
: "S ";
|
|
464
|
+
const quotaLanes: string[] = [];
|
|
465
|
+
if (!data.sessionHidden) {
|
|
466
|
+
quotaLanes.push(
|
|
467
|
+
theme.fg("muted", sessionPrefix) + renderBar(theme, session) + " " + renderPercent(theme, session) +
|
|
468
|
+
(data.sessionResetsIn ? theme.fg("dim", ` ⟳ ${data.sessionResetsIn}`) : ""),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (!data.weeklyHidden) {
|
|
472
|
+
quotaLanes.push(
|
|
473
|
+
theme.fg("muted", "W ") + renderBar(theme, weekly) + " " + renderPercent(theme, weekly) +
|
|
474
|
+
(data.weeklyResetsIn ? theme.fg("dim", ` ⟳ ${data.weeklyResetsIn}`) : ""),
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const status =
|
|
478
|
+
theme.fg("dim", `${label} `) +
|
|
479
|
+
quotaLanes.join(" ") +
|
|
480
|
+
(data.accountBalance ? theme.fg("muted", ` · ${formatAccountBalance(data.accountBalance)}`) : "") +
|
|
481
|
+
(data.accountSpend?.monthly === undefined
|
|
482
|
+
? ""
|
|
483
|
+
: theme.fg("muted", ` · Month ${formatFinancialAmount(data.accountSpend.monthly, data.accountSpend.unit)}`)) +
|
|
484
|
+
(data.stale ? theme.fg("warning", " stale") : "") +
|
|
485
|
+
(data.warning && !data.stale ? theme.fg("warning", " ⚠") : "");
|
|
486
|
+
ctx.ui.setStatus(STATUS_KEY, status);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function updateProviderFrom(model: ExtensionContext["model"]): boolean {
|
|
490
|
+
const previous = state.activeProvider;
|
|
491
|
+
state.activeProvider = detectProvider(model);
|
|
492
|
+
if (previous !== state.activeProvider) {
|
|
493
|
+
updateStatus();
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function resolveCredential(ctx: ExtensionContext, provider: ProviderKey): Promise<CredentialResolution> {
|
|
500
|
+
const providerId = providerToPiProviderId(provider);
|
|
501
|
+
if (!ctx.modelRegistry.getProvider(providerId)) return {};
|
|
502
|
+
const status = ctx.modelRegistry.getProviderAuthStatus(providerId);
|
|
503
|
+
if (!status.configured) return {};
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const resolved = await ctx.modelRegistry.getProviderAuth(providerId);
|
|
507
|
+
if (provider === "claude" && resolved?.source !== "OAuth") return {};
|
|
508
|
+
const token = resolved?.auth.apiKey;
|
|
509
|
+
return token ? { token } : { error: "configured authentication did not resolve a token" };
|
|
510
|
+
} catch (error) {
|
|
511
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function fetchProvider(
|
|
516
|
+
ctx: ExtensionContext,
|
|
517
|
+
provider: ProviderKey,
|
|
518
|
+
signal: AbortSignal,
|
|
519
|
+
): Promise<void> {
|
|
520
|
+
const credential = await resolveCredential(ctx, provider);
|
|
521
|
+
if (signal.aborted) return;
|
|
522
|
+
state.available[provider] = Boolean(credential.token || credential.error);
|
|
523
|
+
if (credential.error) {
|
|
524
|
+
state[provider] = { session: 0, weekly: 0, error: `auth resolution failed (${credential.error})` };
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (!credential.token) {
|
|
528
|
+
state[provider] = null;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (provider === "codex") state.codex = await fetchCodexUsage(credential.token, { signal });
|
|
533
|
+
if (provider === "claude") state.claude = await fetchClaudeUsageWithFallback(credential.token, { signal });
|
|
534
|
+
if (provider === "zai") state.zai = await fetchZaiUsage(credential.token, "zai", { endpoints, signal });
|
|
535
|
+
if (provider === "zai-cn") state["zai-cn"] = await fetchZaiUsage(credential.token, "zai-cn", { endpoints, signal });
|
|
536
|
+
if (provider === "kimi") state.kimi = await fetchKimiUsage(credential.token, { endpoints, signal });
|
|
537
|
+
if (provider === "minimax") {
|
|
538
|
+
state.minimax = await fetchMiniMaxUsage(credential.token, "minimax", { endpoints, signal });
|
|
539
|
+
}
|
|
540
|
+
if (provider === "minimax-cn") {
|
|
541
|
+
state["minimax-cn"] = await fetchMiniMaxUsage(credential.token, "minimax-cn", { endpoints, signal });
|
|
542
|
+
}
|
|
543
|
+
if (provider === "openrouter") {
|
|
544
|
+
state.openrouter = await fetchOpenRouterUsage(credential.token, { endpoints, signal });
|
|
545
|
+
}
|
|
546
|
+
if (provider === "deepseek") {
|
|
547
|
+
state.deepseek = await fetchDeepSeekBalance(credential.token, { endpoints, signal });
|
|
548
|
+
}
|
|
549
|
+
if (provider === "moonshot") {
|
|
550
|
+
state.moonshot = await fetchMoonshotBalance(credential.token, "moonshot", { endpoints, signal });
|
|
551
|
+
}
|
|
552
|
+
if (provider === "moonshot-cn") {
|
|
553
|
+
state["moonshot-cn"] = await fetchMoonshotBalance(credential.token, "moonshot-cn", { endpoints, signal });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function runPoll(): Promise<void> {
|
|
558
|
+
const ctx = currentContext;
|
|
559
|
+
const signal = sessionController?.signal;
|
|
560
|
+
const provider = state.activeProvider;
|
|
561
|
+
if (!ctx || !signal || signal.aborted || ctx.mode !== "tui" || !provider) {
|
|
562
|
+
updateStatus();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
await fetchProvider(ctx, provider, signal);
|
|
567
|
+
if (signal.aborted) return;
|
|
568
|
+
const data = state[provider];
|
|
569
|
+
if (data && !data.error) pi.events.emit("usage:update", { provider, ...data });
|
|
570
|
+
updateStatus();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function poll(): Promise<void> {
|
|
574
|
+
if (pollInFlight) {
|
|
575
|
+
pollQueued = true;
|
|
576
|
+
return pollInFlight;
|
|
577
|
+
}
|
|
578
|
+
do {
|
|
579
|
+
pollQueued = false;
|
|
580
|
+
pollInFlight = runPoll().catch(() => undefined).finally(() => {
|
|
581
|
+
pollInFlight = undefined;
|
|
582
|
+
});
|
|
583
|
+
await pollInFlight;
|
|
584
|
+
} while (pollQueued && !sessionController?.signal.aborted);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function fetchAllForContext(ctx: ExtensionContext, signal: AbortSignal): Promise<UsageByProvider> {
|
|
588
|
+
const resolutions = await Promise.all(PROVIDERS.map(async (provider) =>
|
|
589
|
+
[provider, await resolveCredential(ctx, provider)] as const));
|
|
590
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
591
|
+
|
|
592
|
+
const tokens: UsageTokens = {};
|
|
593
|
+
const authErrors: Partial<Record<ProviderKey, string>> = {};
|
|
594
|
+
for (const [provider, resolution] of resolutions) {
|
|
595
|
+
if (resolution.token) tokens[provider] = resolution.token;
|
|
596
|
+
if (resolution.error) authErrors[provider] = resolution.error;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const results = await fetchAllUsages(tokens, { endpoints, signal });
|
|
600
|
+
for (const provider of PROVIDERS) {
|
|
601
|
+
const error = authErrors[provider];
|
|
602
|
+
if (error) results[provider] = { session: 0, weekly: 0, error: `auth resolution failed (${error})` };
|
|
603
|
+
}
|
|
604
|
+
return results;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
pi.on("session_start", (_event, ctx) => {
|
|
608
|
+
currentContext = ctx;
|
|
609
|
+
sessionController?.abort();
|
|
610
|
+
sessionController = new AbortController();
|
|
611
|
+
updateProviderFrom(ctx.model);
|
|
612
|
+
|
|
613
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
614
|
+
pollTimer = undefined;
|
|
615
|
+
if (ctx.mode !== "tui") return;
|
|
616
|
+
|
|
617
|
+
updateStatus();
|
|
618
|
+
void poll();
|
|
619
|
+
pollTimer = setInterval(() => void poll(), POLL_INTERVAL_MS);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
623
|
+
sessionController?.abort();
|
|
624
|
+
sessionController = undefined;
|
|
625
|
+
pollQueued = false;
|
|
626
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
627
|
+
pollTimer = undefined;
|
|
628
|
+
if (ctx.mode === "tui") ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
629
|
+
currentContext = undefined;
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
633
|
+
currentContext = ctx;
|
|
634
|
+
if (updateProviderFrom(ctx.model)) void poll();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
pi.on("model_select", (event, ctx) => {
|
|
638
|
+
currentContext = ctx;
|
|
639
|
+
updateProviderFrom(event.model);
|
|
640
|
+
void poll();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
pi.registerCommand("usage", {
|
|
644
|
+
description: "Show quota, balance, and spend for configured providers",
|
|
645
|
+
handler: async (_args, ctx) => {
|
|
646
|
+
currentContext = ctx;
|
|
647
|
+
updateProviderFrom(ctx.model);
|
|
648
|
+
if (ctx.mode !== "tui") {
|
|
649
|
+
if (ctx.hasUI) ctx.ui.notify("/usage is available in interactive mode", "warning");
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
await ctx.ui.custom<void>((tui, theme, keybindings, done) =>
|
|
654
|
+
new UsageSelectorComponent(
|
|
655
|
+
tui,
|
|
656
|
+
theme,
|
|
657
|
+
keybindings,
|
|
658
|
+
state.activeProvider,
|
|
659
|
+
(signal) => fetchAllForContext(ctx, signal),
|
|
660
|
+
() => done(),
|
|
661
|
+
));
|
|
662
|
+
void poll();
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
}
|