@hk_net/pi-usage-bars 0.5.0 → 0.6.0

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