@narumitw/pi-usage 0.65.0 → 0.65.1

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/docs/providers.md CHANGED
@@ -81,7 +81,7 @@ Contract fixtures derive from [Specode's pinned implementation](https://github.c
81
81
  - Source: the Codex usage and earned-reset endpoints using Pi's resolved runtime authorization
82
82
  - Displayed data: returned duration-based windows, resets, credits, earned usage-limit resets, and additional model buckets
83
83
  - Reset mutation: `POST /wham/rate-limit-reset-credits/consume` with a unique redemption request ID and, when available, the selected opaque credit ID
84
- - Statusline examples: `codex 59% ↻ 2h30m 61% ↻ 2d15m`, `codex fast 59% ↻ 2h30m`, or `codex spark 100% ↻ 2h30m`. Set `codexStatusPercentage` to `"used"` to show consumed percentages, or set `codexStatusResetCountdown` to `false` for the legacy `5h` and `wk` labels.
84
+ - Statusline examples: `codex 59% ↻ 2h30m · 61% ↻ 2d15m`, `codex fast 59% ↻ 2h30m`, or `codex spark 100% ↻ 2h30m`. Set `codexStatusPercentage` to `"used"` to show consumed percentages, or set `codexStatusResetCountdown` to `false` for static labels such as `codex 5h:59% · wk:61%`.
85
85
 
86
86
  The statusline selects a returned bucket that matches the current Codex model when one is available.
87
87
  The percentage preference affects only this compact status and defaults to remaining quota; it does not change the detailed report or returned usage data.
@@ -101,7 +101,7 @@ Opaque credit and account IDs are never shown or persisted by the extension.
101
101
  - Source: `GET https://api.kimi.com/coding/v1/usages` using Pi's freshly resolved runtime Bearer credential
102
102
  - Displayed plan data: count-based weekly summaries and returned sub-windows, plus reported ratio-based five-hour, weekly, or monthly quotas and valid reset times
103
103
  - Displayed wallet data: balance, monthly spend, and monthly charge limit when the wallet supplies consistent currency and amounts
104
- - Statusline examples: `kimi 99% 5h 96% wk`, `kimi 56% 5h 21% mo`, or `kimi 95% 1d`
104
+ - Statusline examples: `kimi 77% ↻ 2h52m · 87% ↻ 29d20h`, `kimi 77% ↻ 2h52m · mo:87%`, or static fallback `kimi 1d:95%`
105
105
 
106
106
  Both Pi API-key credentials and Pi OAuth credentials are accepted because current Pi resolves each form as Bearer authorization for the same official Kimi inference origin.
107
107
  The extension queries the fixed usage endpoint only when both the selected model origin and the effective resolved-auth origin are `https://api.kimi.com`.
@@ -148,7 +148,7 @@ The contracts were verified on 2026-08-30 against the official [Global balance r
148
148
  - Token Plan source: `GET {region-api-root}/v1/token_plan/remains`
149
149
  - Pay-as-you-go source: `GET {region-api-root}/account/query_balance`
150
150
  - Region API roots: `https://api.minimax.io` and `https://api.minimaxi.com`
151
- - Statusline examples: `minimax 15% 5h 80% wk` or `minimax USD 98.00001`
151
+ - Statusline examples: `minimax 15% ↻ 2h30m · 80% ↻ 6d`, static fallback `minimax 5h:15% · wk:80%`, unlimited `minimax cn 5h:unlimited · wk:unlimited`, or pay-as-you-go `minimax USD 98.00001`
152
152
 
153
153
  Pi's resolved MiniMax API key selects exactly one endpoint before network access.
154
154
  Keys with the first-party `sk-api-` prefix query pay-as-you-go balance; other MiniMax API keys query Token Plan quota.
@@ -166,7 +166,7 @@ The contract was verified on 2026-08-30 against MiniMax's [Token Plan FAQ](https
166
166
  - Allowance labels: AI credits for usage-based billing, premium requests for legacy annual billing, or chat requests for Copilot Free
167
167
  - Source: GitHub's undocumented `GET /copilot_internal/user` endpoint
168
168
  - Displayed data: entitlement, remaining allowance, percentage, reset time, plan, and any additional usage beyond the included allowance
169
- - Statusline examples: `copilot credits 1200/1500 80%`, `copilot 245/300 82%`, or `copilot chat 40/50 80%`
169
+ - Statusline examples: `copilot credits 1200/1500 80% ↻ 6d`, `copilot 245/300 82% ↻ 6d`, or static fallback `copilot chat 40/50 80%`
170
170
 
171
171
  GitHub's quota endpoint requires the original GitHub OAuth token rather than the short-lived Copilot inference token exposed by runtime auth.
172
172
  `pi-usage` supports Copilot accounts created through Pi's `/login` flow and named accounts offered by a compatible `oauth:credential-source:v1` owner.
@@ -260,7 +260,7 @@ The contract was verified on 2026-08-30 against Baseten's [Billing and usage](ht
260
260
  - Source: `GET https://opencode.ai/zen/go/v1/usage` using Pi's resolved inference API key
261
261
  - Displayed data: used percentage and reset time for each window
262
262
  - Status handling: `rate-limited` windows remain visible, while unknown statuses become unavailable notes
263
- - Statusline examples: `zen 0% r 4% w 2% m`
263
+ - Statusline examples: `zen 0% ↻ 2h30m · 4% ↻ 6d · m:2%` or static fallback `zen r:0% · w:4% · m:2%`
264
264
 
265
265
  The fixed endpoint is queried only when the OpenCode Go model uses the official `https://opencode.ai` origin.
266
266
  When resolved provider auth includes a base URL, that URL must use the same origin.
@@ -275,7 +275,7 @@ Other origins fail before the credential is sent.
275
275
  - Plan route: `GET https://api.commandcode.ai/alpha/billing/subscriptions`
276
276
  - Period route: `GET https://api.commandcode.ai/alpha/usage/summary`
277
277
  - Displayed data: five-hour and weekly spend caps in USD with reset times, the billing-period credit pool and remaining balance, plan name and status, request and token totals, and purchased or free credits when present
278
- - Statusline examples: `cmd 99% 5h 92% wk 96% mo`
278
+ - Statusline examples: `cmd 99% ↻ 2h30m · 92% ↻ 6d · 96% ↻ 20d` or static fallback `cmd 5h:99% · wk:92% · mo:96%`
279
279
 
280
280
  The official `@commandcode/pi-commandcode-provider` extension registers this provider through `pi.registerProvider()`, so `command-code` is not a Pi built-in provider.
281
281
  Pi still resolves its stored `/login` API key through the normal provider registry, and the extension sends it as Bearer authorization only when the selected model and resolved auth share the official `https://api.commandcode.ai` origin.
@@ -329,7 +329,7 @@ xAI identity and billing requests occur only after an explicit current, configur
329
329
  - Allowed origins: the model base URL must resolve to `https://api.z.ai` or `https://open.bigmodel.cn`
330
330
  - Displayed data: explicit used and remaining values, reset times, provider-reported per-tool MCP details, and the plan name with its renewal date
331
331
  - Percentage-only windows remain percent-based
332
- - Statusline: publishes remaining plan percentages such as `zai 87% 5h 76% wk`; monthly MCP details remain available through `/usage`
332
+ - Statusline: publishes remaining plan percentages such as `zai 87% ↻ 2h30m · 76% ↻ 6d`, with static fallback `zai 5h:87% · wk:76%`; monthly MCP details remain available through `/usage`
333
333
 
334
334
  The monitor endpoint is not a published API contract and may return legacy `TOKENS_LIMIT` or newer `CREDIT_LIMIT` window names.
335
335
  The extension classifies both forms by the provider's window unit and does not label provider-reported counts as tokens or calls.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.65.0",
3
+ "version": "0.65.1",
4
4
  "description": "Pi extension that shows current-account usage and DeepSeek API balance for supported providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/format.ts CHANGED
@@ -60,6 +60,36 @@ export function formatUsageStatusline(
60
60
  now = Date.now(),
61
61
  showCodexResetCountdown = true,
62
62
  codexStatusPercentage: CodexStatusPercentage = "remaining",
63
+ ): string | undefined {
64
+ return formatUsageStatuslineWithCountdown(report, model, now, showCodexResetCountdown, codexStatusPercentage).value;
65
+ }
66
+
67
+ interface StatusCountdown {
68
+ now: number;
69
+ active: boolean;
70
+ }
71
+
72
+ // Record new-provider eligibility while formatting the selected windows so repaint cannot drift from display policy.
73
+ // Codex and ChatGPT companion keep their existing repaint policy in usage.ts.
74
+ export function formatUsageStatuslineWithCountdown(
75
+ report: UsageReport,
76
+ model?: UsageModel,
77
+ now = Date.now(),
78
+ showCodexResetCountdown = true,
79
+ codexStatusPercentage: CodexStatusPercentage = "remaining",
80
+ ): { value: string | undefined; hasCountdown: boolean } {
81
+ const countdown: StatusCountdown = { now, active: false };
82
+ const value = formatStatusline(report, model, now, showCodexResetCountdown, codexStatusPercentage, countdown);
83
+ return { value, hasCountdown: countdown.active };
84
+ }
85
+
86
+ function formatStatusline(
87
+ report: UsageReport,
88
+ model: UsageModel | undefined,
89
+ now: number,
90
+ showCodexResetCountdown: boolean,
91
+ codexStatusPercentage: CodexStatusPercentage,
92
+ countdown: StatusCountdown,
63
93
  ): string | undefined {
64
94
  if (report.providerId === "openai" && report.source === "openai-chatgpt-auth") return "chatgpt usage: web only";
65
95
  if (report.source === "openai-chatgpt-companion") {
@@ -79,24 +109,24 @@ export function formatUsageStatusline(
79
109
  if (report.providerId === "deepseek") return formatDeepSeekStatusline(report);
80
110
  if (report.providerId === "fireworks") return formatFireworksStatusline(report);
81
111
  if (report.providerId === "vercel-ai-gateway") return formatVercelAIGatewayStatusline(report);
82
- if (report.providerId === "github-copilot") return formatGitHubCopilotStatusline(report);
112
+ if (report.providerId === "github-copilot") return formatGitHubCopilotStatusline(report, countdown);
83
113
  if (report.providerId === "openrouter") {
84
114
  const limit = report.buckets.find((bucket) => bucket.id === "key-limit");
85
115
  if (limit?.remaining !== undefined) return `openrouter ${formatUsd(limit.remaining)} left`;
86
116
  const total = report.metrics.find((metric) => metric.id === "usage-total");
87
117
  if (typeof total?.value === "number") return `openrouter ${formatUsd(total.value)} used`;
88
118
  }
89
- if (report.providerId === "opencode-go") return formatOpenCodeZenStatusline(report);
90
- if (report.providerId === "command-code") return formatCommandCodeStatusline(report);
91
- if (report.providerId === "kimi-coding") return formatKimiCodingStatusline(report);
119
+ if (report.providerId === "opencode-go") return formatOpenCodeZenStatusline(report, countdown);
120
+ if (report.providerId === "command-code") return formatCommandCodeStatusline(report, countdown);
121
+ if (report.providerId === "kimi-coding") return formatKimiCodingStatusline(report, countdown);
92
122
  if (report.providerId === "moonshotai" || report.providerId === "moonshotai-cn") {
93
123
  return formatMoonshotStatusline(report);
94
124
  }
95
125
  if (report.providerId === "minimax" || report.providerId === "minimax-cn") {
96
- return formatMiniMaxStatusline(report, model);
126
+ return formatMiniMaxStatusline(report, countdown, model);
97
127
  }
98
128
  if (report.providerId === "zai" || report.providerId === "zai-coding-cn") {
99
- return formatZaiStatusline(report);
129
+ return formatZaiStatusline(report, countdown);
100
130
  }
101
131
  return undefined;
102
132
  }
@@ -232,7 +262,7 @@ function formatGitHubCopilotReport(lines: string[], report: UsageReport): void {
232
262
  }
233
263
  }
234
264
 
235
- function formatGitHubCopilotStatusline(report: UsageReport): string {
265
+ function formatGitHubCopilotStatusline(report: UsageReport, countdown: StatusCountdown): string {
236
266
  const quota = findGitHubCopilotQuota(report);
237
267
  const kind = compactGitHubCopilotQuotaKind(quota);
238
268
  if (!quota || quota.limit === undefined || quota.remaining === undefined) {
@@ -240,7 +270,8 @@ function formatGitHubCopilotStatusline(report: UsageReport): string {
240
270
  }
241
271
  const overage = report.metrics.find((metric) => metric.id === "overage-used");
242
272
  const overageSuffix = typeof overage?.value === "number" && overage.value > 0 ? ` +${overage.value} over` : "";
243
- return `copilot ${kind === "premium" ? "" : `${kind} `}${quota.remaining}/${quota.limit} ${percentRemaining(quota)}%${overageSuffix}`;
273
+ const reset = formatQuotaCountdown(quota, countdown);
274
+ return `copilot ${kind === "premium" ? "" : `${kind} `}${quota.remaining}/${quota.limit} ${percentRemaining(quota)}%${overageSuffix}${reset ? ` ↻ ${reset}` : ""}`;
244
275
  }
245
276
 
246
277
  function findGitHubCopilotQuota(report: UsageReport): UsageBucket | undefined {
@@ -285,14 +316,14 @@ function formatOpenCodeZenReport(lines: string[], report: UsageReport): void {
285
316
  }
286
317
  }
287
318
 
288
- function formatOpenCodeZenStatusline(report: UsageReport): string | undefined {
289
- const parts = ["zen"];
319
+ function formatOpenCodeZenStatusline(report: UsageReport, countdown: StatusCountdown): string | undefined {
320
+ const parts: string[] = [];
290
321
  for (const bucket of report.buckets) {
291
322
  if (bucket.used === undefined) continue;
292
323
  const compact = bucket.id === "rolling" ? "r" : bucket.id === "weekly" ? "w" : "m";
293
- parts.push(`${clampPercent(bucket.used).toFixed(0)}% ${compact}`);
324
+ parts.push(formatQuotaWindow(bucket, `${clampPercent(bucket.used).toFixed(0)}%`, compact, countdown));
294
325
  }
295
- return parts.length > 1 ? parts.join(" ") : undefined;
326
+ return parts.length > 0 ? `zen ${parts.join(" · ")}` : undefined;
296
327
  }
297
328
 
298
329
  function formatCommandCodeReport(lines: string[], report: UsageReport): void {
@@ -311,8 +342,8 @@ function formatCommandCodeReport(lines: string[], report: UsageReport): void {
311
342
  }
312
343
  }
313
344
 
314
- function formatCommandCodeStatusline(report: UsageReport): string | undefined {
315
- const parts = ["cmd"];
345
+ function formatCommandCodeStatusline(report: UsageReport, countdown: StatusCountdown): string | undefined {
346
+ const parts: string[] = [];
316
347
  for (const [id, label] of [
317
348
  ["five-hour", "5h"],
318
349
  ["weekly", "wk"],
@@ -320,9 +351,9 @@ function formatCommandCodeStatusline(report: UsageReport): string | undefined {
320
351
  ] as const) {
321
352
  const bucket = report.buckets.find((candidate) => candidate.id === id);
322
353
  if (bucket?.unit !== "usd" || bucket.limit === undefined || bucket.remaining === undefined) continue;
323
- parts.push(`${percentRemaining(bucket)}% ${label}`);
354
+ parts.push(formatQuotaWindow(bucket, `${percentRemaining(bucket)}%`, label, countdown));
324
355
  }
325
- return parts.length > 1 ? parts.join(" ") : undefined;
356
+ return parts.length > 0 ? `cmd ${parts.join(" · ")}` : undefined;
326
357
  }
327
358
 
328
359
  function formatKimiCodingReport(lines: string[], report: UsageReport): void {
@@ -363,7 +394,7 @@ function formatKimiCodingReport(lines: string[], report: UsageReport): void {
363
394
  }
364
395
  }
365
396
 
366
- function formatKimiCodingStatusline(report: UsageReport): string | undefined {
397
+ function formatKimiCodingStatusline(report: UsageReport, countdown: StatusCountdown): string | undefined {
367
398
  const fiveHour = report.buckets.find((bucket) => bucket.id === "five-hour");
368
399
  const weekly = report.buckets.find((bucket) => bucket.id === "weekly");
369
400
  const monthly = report.buckets.find((bucket) => bucket.id === "monthly");
@@ -371,15 +402,15 @@ function formatKimiCodingStatusline(report: UsageReport): string | undefined {
371
402
  const selected = [subWindow, weekly, monthly].filter(
372
403
  (bucket, index, buckets): bucket is UsageBucket => bucket !== undefined && buckets.indexOf(bucket) === index,
373
404
  );
374
- const parts = ["kimi"];
405
+ const parts: string[] = [];
375
406
  for (const bucket of selected) {
376
407
  if (bucket.remaining === undefined || (bucket.unit !== "percent" && !bucket.limit)) continue;
377
408
  const fallback = bucket.id === "weekly" ? "weekly" : "5h";
378
409
  const window = bucket.id === "monthly" ? "mo" : formatWindowLabel(bucket.windowMinutes, fallback, true);
379
410
  const remaining = bucket.unit === "percent" ? Math.round(clampPercent(bucket.remaining)) : percentRemaining(bucket);
380
- parts.push(`${remaining}% ${window}`);
411
+ parts.push(formatQuotaWindow(bucket, `${remaining}%`, window, countdown));
381
412
  }
382
- return parts.length > 1 ? parts.join(" ") : undefined;
413
+ return parts.length > 0 ? `kimi ${parts.join(" · ")}` : undefined;
383
414
  }
384
415
 
385
416
  function formatMoonshotReport(lines: string[], report: UsageReport): void {
@@ -418,7 +449,11 @@ function formatMiniMaxReport(lines: string[], report: UsageReport): void {
418
449
  }
419
450
  }
420
451
 
421
- function formatMiniMaxStatusline(report: UsageReport, model?: UsageModel): string | undefined {
452
+ function formatMiniMaxStatusline(
453
+ report: UsageReport,
454
+ countdown: StatusCountdown,
455
+ model?: UsageModel,
456
+ ): string | undefined {
422
457
  const prefix = report.providerId === "minimax-cn" ? "minimax cn" : "minimax";
423
458
  if (report.source === "minimax-account-balance") {
424
459
  const available = report.metrics.find((metric) => metric.id === "available-balance");
@@ -427,22 +462,22 @@ function formatMiniMaxStatusline(report: UsageReport, model?: UsageModel): strin
427
462
  const selectedGroup = selectMiniMaxGroup(report, model);
428
463
  if (!selectedGroup) return undefined;
429
464
  const selected = report.buckets.filter((bucket) => bucket.groupId === selectedGroup);
430
- const parts = [prefix];
465
+ const parts: string[] = [];
431
466
  for (const bucket of selected) {
432
467
  const fallback = bucket.id.endsWith(":weekly") ? "weekly" : "5h";
433
468
  const window = formatWindowLabel(bucket.windowMinutes, fallback, true);
434
469
  if (bucket.period === "unlimited") {
435
- parts.push(`unlimited ${window}`);
470
+ parts.push(`${window}:unlimited`);
436
471
  continue;
437
472
  }
438
473
  if (bucket.unit === "percent" && bucket.remaining !== undefined) {
439
- parts.push(`${bucket.remaining}% ${window}`);
474
+ parts.push(formatQuotaWindow(bucket, `${bucket.remaining}%`, window, countdown));
440
475
  continue;
441
476
  }
442
477
  if (bucket.limit === undefined || bucket.remaining === undefined) continue;
443
- parts.push(`${percentRemaining(bucket)}% ${window}`);
478
+ parts.push(formatQuotaWindow(bucket, `${percentRemaining(bucket)}%`, window, countdown));
444
479
  }
445
- return parts.length > 1 ? parts.join(" ") : undefined;
480
+ return parts.length > 0 ? `${prefix} ${parts.join(" · ")}` : undefined;
446
481
  }
447
482
 
448
483
  function selectMiniMaxGroup(report: UsageReport, model?: UsageModel): string | undefined {
@@ -493,18 +528,25 @@ function wildcardKeyMatches(pattern: string, value: string): boolean {
493
528
  return pattern.endsWith("*") || (last !== undefined && value.endsWith(last));
494
529
  }
495
530
 
496
- function formatZaiStatusline(report: UsageReport): string | undefined {
531
+ function formatZaiStatusline(report: UsageReport, countdown: StatusCountdown): string | undefined {
497
532
  const selected = [
498
533
  report.buckets.find((bucket) => bucket.id === "five-hour"),
499
534
  report.buckets.find((bucket) => bucket.id === "weekly"),
500
535
  ];
501
- const parts = ["zai"];
536
+ const parts: string[] = [];
502
537
  for (const bucket of selected) {
503
538
  if (!bucket?.limit || bucket.remaining === undefined) continue;
504
539
  const fallback = bucket.id === "weekly" ? "weekly" : "5h";
505
- parts.push(`${percentRemaining(bucket)}% ${formatWindowLabel(bucket.windowMinutes, fallback, true)}`);
540
+ parts.push(
541
+ formatQuotaWindow(
542
+ bucket,
543
+ `${percentRemaining(bucket)}%`,
544
+ formatWindowLabel(bucket.windowMinutes, fallback, true),
545
+ countdown,
546
+ ),
547
+ );
506
548
  }
507
- return parts.length > 1 ? parts.join(" ") : undefined;
549
+ return parts.length > 0 ? `zai ${parts.join(" · ")}` : undefined;
508
550
  }
509
551
 
510
552
  function formatCurrencyMetric(metric: UsageReport["metrics"][number]): string {
@@ -604,7 +646,8 @@ function formatCodexStatusline(
604
646
  if (!group) return formatCodexCreditsStatus(report);
605
647
  const buckets = report.buckets.filter((bucket) => (bucket.groupId ?? bucket.id) === group);
606
648
  const labelBucket = buckets[0];
607
- const parts = [group === "codex" ? "codex" : `codex ${compactLimitLabel(labelBucket?.groupLabel ?? group)}`];
649
+ const prefix = group === "codex" ? "codex" : `codex ${compactLimitLabel(labelBucket?.groupLabel ?? group)}`;
650
+ const parts: string[] = [];
608
651
  for (const bucket of buckets) {
609
652
  if (bucket.remaining === undefined) continue;
610
653
  const displayedPercentage = percentage === "used" ? (bucket.used ?? 100 - bucket.remaining) : bucket.remaining;
@@ -612,13 +655,13 @@ function formatCodexStatusline(
612
655
  const fallback = bucket.id.endsWith(":secondary") ? "weekly" : "5h";
613
656
  const window = formatWindowLabel(bucket.windowMinutes, fallback, true);
614
657
  if (!showResetCountdown) {
615
- parts.push(`${percent} ${window}`);
658
+ parts.push(`${window}:${percent}`);
616
659
  continue;
617
660
  }
618
661
  const reset = formatResetCountdown(bucket.resetsAt, now);
619
- parts.push(`${percent} ${reset ? `↻ ${reset}` : window}`);
662
+ parts.push(reset ? `${percent} ↻ ${reset}` : `${window}:${percent}`);
620
663
  }
621
- return parts.length > 1 ? parts.join(" ") : formatCodexCreditsStatus(report);
664
+ return parts.length > 0 ? `${prefix} ${parts.join(" · ")}` : formatCodexCreditsStatus(report);
622
665
  }
623
666
 
624
667
  function formatCodexCreditsStatus(report: UsageReport): string {
@@ -711,6 +754,25 @@ function formatWindowLabel(minutes: number | undefined, fallback: "5h" | "weekly
711
754
  return `${minutes}m`;
712
755
  }
713
756
 
757
+ function formatQuotaWindow(bucket: UsageBucket, percent: string, window: string, countdown: StatusCountdown): string {
758
+ const reset = formatQuotaCountdown(bucket, countdown);
759
+ return reset ? `${percent} ↻ ${reset}` : `${window}:${percent}`;
760
+ }
761
+
762
+ function formatQuotaCountdown(bucket: UsageBucket, countdown: StatusCountdown): string | undefined {
763
+ if (
764
+ bucket.period === "unlimited" ||
765
+ bucket.resetsAt === undefined ||
766
+ !Number.isFinite(bucket.resetsAt * 1_000) ||
767
+ !Number.isFinite(countdown.now) ||
768
+ bucket.resetsAt * 1_000 <= countdown.now
769
+ )
770
+ return undefined;
771
+ const reset = formatResetCountdown(bucket.resetsAt, countdown.now);
772
+ if (reset !== undefined) countdown.active = true;
773
+ return reset;
774
+ }
775
+
714
776
  function formatResetCountdown(resetsAt: number | undefined, now: number): string | undefined {
715
777
  if (resetsAt === undefined || !Number.isFinite(resetsAt) || !Number.isFinite(now)) return undefined;
716
778
  const totalMinutes = Math.max(0, Math.ceil((resetsAt * 1_000 - now) / 60_000));
package/src/usage.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  resolveCodexResetAuth,
21
21
  } from "./codex-resets.js";
22
22
  import { abortError, awaitWithDeadline, errorMessage, runWithConcurrency, UsageCache } from "./core.js";
23
- import { formatProviderStates, formatUsageStatusline } from "./format.js";
23
+ import { formatProviderStates, formatUsageStatuslineWithCountdown } from "./format.js";
24
24
  import { createOAuthCredentialCandidateReader } from "./oauth-credential-source.js";
25
25
  import { UnsupportedOpenAIUsageAuthError } from "./providers/openai-chatgpt.js";
26
26
  import {
@@ -181,31 +181,33 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
181
181
  outcome.state.report.providerId === "openai-codex" && settings.codexStatusResetCountdown;
182
182
  const isCompanionReport = outcome.state.report.source === "openai-chatgpt-companion";
183
183
  const now = Date.now();
184
- const rawValue = formatUsageStatusline(
184
+ const status = formatUsageStatuslineWithCountdown(
185
185
  outcome.state.report,
186
186
  model,
187
187
  now,
188
188
  showCodexResetCountdown,
189
189
  settings.codexStatusPercentage,
190
190
  );
191
- const value = rawValue ? fastRuntime.decorateStatus(model, rawValue) : undefined;
191
+ const value = status.value ? fastRuntime.decorateStatus(model, status.value) : undefined;
192
192
  if (!safeSetStatus(ctx, value)) return;
193
193
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
194
194
  if (
195
195
  sessionActive &&
196
- (showCodexResetCountdown || isCompanionReport) &&
197
- outcome.state.report.buckets.some(
198
- (bucket) =>
199
- (!isCompanionReport || bucket.groupId === "chatgpt-plan") &&
200
- bucket.resetsAt !== undefined &&
201
- Number.isFinite(bucket.resetsAt) &&
202
- bucket.resetsAt * 1_000 > now,
203
- )
196
+ (status.hasCountdown ||
197
+ ((showCodexResetCountdown || isCompanionReport) &&
198
+ outcome.state.report.buckets.some(
199
+ (bucket) =>
200
+ (!isCompanionReport || bucket.groupId === "chatgpt-plan") &&
201
+ bucket.resetsAt !== undefined &&
202
+ Number.isFinite(bucket.resetsAt) &&
203
+ bucket.resetsAt * 1_000 > now,
204
+ )))
204
205
  ) {
205
206
  const generation = statusGeneration;
206
207
  statusCountdownTimer = setTimeout(() => {
207
- statusCountdownTimer = undefined;
208
+ // A queued obsolete callback must not detach its successor's owned timer.
208
209
  if (!sessionActive || generation !== statusGeneration) return;
210
+ statusCountdownTimer = undefined;
209
211
  publishStatus(ctx, outcome, model, false);
210
212
  }, STATUS_COUNTDOWN_REFRESH_MS);
211
213
  statusCountdownTimer.unref?.();