@narumitw/pi-usage 0.60.10 → 0.61.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.
package/docs/providers.md CHANGED
@@ -28,9 +28,10 @@ The README contains the capability overview and shared security requirements.
28
28
  - Source: the Codex usage and earned-reset endpoints using Pi's resolved runtime authorization
29
29
  - Displayed data: returned duration-based windows, resets, credits, earned usage-limit resets, and additional model buckets
30
30
  - Reset mutation: `POST /wham/rate-limit-reset-credits/consume` with a unique redemption request ID and, when available, the selected opaque credit ID
31
- - Statusline examples: `codex 59% ↻ 2h30m 61% ↻ 2d15m`, `codex fast 59% ↻ 2h30m`, or `codex spark 100% ↻ 2h30m`. Set `codexStatusResetCountdown` to `false` for the legacy `5h` and `wk` labels.
31
+ - 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.
32
32
 
33
33
  The statusline selects a returned bucket that matches the current Codex model when one is available.
34
+ The percentage preference affects only this compact status and defaults to remaining quota; it does not change the detailed report or returned usage data.
34
35
  Unlike `pi-codex-usage`, this successor intentionally has no Codex CLI fallback because the CLI may be logged into a different account than Pi's active runtime account.
35
36
 
36
37
  Reset redemption is available only when Codex is the current provider.
@@ -45,17 +46,21 @@ Opaque credit and account IDs are never shown or persisted by the extension.
45
46
  - Provider ID: `kimi-coding`
46
47
  - Semantics: Kimi Coding Plan request windows plus a separate Extra Usage booster wallet
47
48
  - Source: `GET https://api.kimi.com/coding/v1/usages` using Pi's freshly resolved runtime Bearer credential
48
- - Displayed plan data: the weekly summary, returned sub-windows, used and remaining request percentages, and valid reset times
49
- - Displayed wallet data: balance, monthly spend, and monthly charge limit
50
- - Statusline examples: `kimi 99% 5h 96% wk` or `kimi 95% 1d`
49
+ - 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
50
+ - Displayed wallet data: balance, monthly spend, and monthly charge limit when the wallet supplies consistent currency and amounts
51
+ - Statusline examples: `kimi 99% 5h 96% wk`, `kimi 56% 5h 21% mo`, or `kimi 95% 1d`
51
52
 
52
53
  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.
53
54
  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`.
54
55
  Custom and proxy origins fail before network access, redirects are rejected, and the credential is never sent to an override from Kimi Code's environment-specific development path.
55
56
 
56
- Plan buckets remain integer request counts and are rendered with their source-defined windows.
57
+ Count-based plan buckets remain integer request counts and are rendered with their source-defined windows.
57
58
  Live responses omit both `used` and `remaining` on untouched rows and may report only `remaining` once usage starts, so a row with a positive `limit` and no counters means zero usage, and when only one counter is present the other is derived from `limit`.
58
- Counters that are present but malformed, unknown units, duplicate windows, invalid timestamps, and malformed rows remain unavailable rather than receiving guessed semantics.
59
+ A monthly-plan response reported in [issue #1390](https://github.com/narumiruna/pi-extensions/issues/1390) supplies `usages.limit_month_total.used_ratio` without a count-based monthly row; the extension shows its remaining percentage and `reset_time` as a monthly bucket without inventing a fixed month duration.
60
+ The same map can supply `limit_5h` and `limit_week` when those windows are not present in `limits` or `usage`; `limit_month_code` is not a second total quota.
61
+ Explicit count-based windows with identifiable durations take precedence, including duplicate or malformed count rows, so ratio rows cannot mask their unavailable state.
62
+ Ratios must be finite numbers between zero and one; unknown map keys, malformed rows, invalid timestamps, and unknown count-based units do not receive guessed semantics.
63
+ The top-level wallet may be `boosterWallet` or `booster_wallet` (the former takes precedence when both are present); its nested fields retain their existing camelCase validation.
59
64
  Booster-wallet `amount` and `amountLeft` values use Kimi's first-party conversion of 1,000,000 fixed-point units per cent, while monthly values already arrive in cents.
60
65
  Wallet values retain their currency and stay separate from plan requests and percentages in reports and the statusline.
61
66
  Wallet fields remain unavailable unless the response supplies one consistent currency; missing monthly values are omitted, and an enabled zero cap is shown as zero.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.60.10",
3
+ "version": "0.61.0",
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/codex-fast.ts CHANGED
@@ -9,6 +9,7 @@ export const CODEX_FAST_MODEL_IDS: ReadonlySet<string> = new Set([
9
9
  "gpt-5.6-luna",
10
10
  "gpt-5.6-sol",
11
11
  "gpt-5.6-terra",
12
+ "gpt-6-sol",
12
13
  ]);
13
14
 
14
15
  export type CodexFastAvailability =
@@ -101,11 +101,18 @@ export async function resolveCodexResetAuth(
101
101
  const expectedModel = `${model.provider}/${model.id}`;
102
102
  const adapter = adapterForProvider("openai-codex");
103
103
  if (!adapter) throw new Error("OpenAI Codex usage support is unavailable.");
104
+ if (candidateReader?.waitUntilReady && !(await candidateReader.waitUntilReady(ctx, "openai-codex"))) {
105
+ throw new Error("OpenAI Codex OAuth credential readiness failed closed.");
106
+ }
104
107
  const auth = await resolveUsageAuth(ctx, adapter, salt, credentialReader);
105
108
  if (`${ctx.model?.provider}/${ctx.model?.id}` !== expectedModel) {
106
109
  throw new Error("The current model changed while resolving Codex reset authentication.");
107
110
  }
108
111
  if (!auth) throw new Error("No runtime credential is configured for OpenAI Codex.");
112
+ const offered = candidateReader
113
+ ? await candidateReader(ctx, "openai-codex")
114
+ : fallbackOAuthCredentialCandidates("openai-codex", credentialReader);
115
+ if (!offered.ok) throw new Error("OpenAI Codex OAuth credential discovery failed closed.");
109
116
 
110
117
  const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
111
118
  if (!resolvedAccess) throw new Error("OpenAI Codex OAuth credentials were incomplete.");
@@ -113,10 +120,6 @@ export async function resolveCodexResetAuth(
113
120
  if (!resolvedAccountId) {
114
121
  throw new Error("The active OpenAI Codex access token did not contain a valid account ID.");
115
122
  }
116
- const offered = candidateReader
117
- ? candidateReader(ctx, "openai-codex")
118
- : fallbackOAuthCredentialCandidates("openai-codex", credentialReader);
119
- if (!offered.ok) throw new Error("OpenAI Codex OAuth credential discovery failed closed.");
120
123
  const { accountId, storedAccess } = selectCodexResetCredential(
121
124
  offered.candidates,
122
125
  resolvedAccess,
package/src/format.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CodexStatusPercentage } from "./settings.js";
1
2
  import type { ProviderUsageState, UsageBucket, UsageDisplayState, UsageModel, UsageReport } from "./types.js";
2
3
 
3
4
  const BAR_SEGMENTS = 20;
@@ -54,10 +55,11 @@ export function formatUsageStatusline(
54
55
  model?: UsageModel,
55
56
  now = Date.now(),
56
57
  showCodexResetCountdown = true,
58
+ codexStatusPercentage: CodexStatusPercentage = "remaining",
57
59
  ): string | undefined {
58
60
  if (report.providerId === "baseten") return formatBasetenStatusline(report);
59
61
  if (report.providerId === "openai-codex") {
60
- return formatCodexStatusline(report, model, now, showCodexResetCountdown);
62
+ return formatCodexStatusline(report, model, now, showCodexResetCountdown, codexStatusPercentage);
61
63
  }
62
64
  if (report.providerId === "deepseek") return formatDeepSeekStatusline(report);
63
65
  if (report.providerId === "fireworks") return formatFireworksStatusline(report);
@@ -280,6 +282,15 @@ function formatOpenCodeZenStatusline(report: UsageReport): string | undefined {
280
282
  function formatKimiCodingReport(lines: string[], report: UsageReport): void {
281
283
  for (const bucket of report.buckets) {
282
284
  const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
285
+ if (bucket.unit === "percent") {
286
+ const remaining = bucket.remaining === undefined ? undefined : Math.round(clampPercent(bucket.remaining));
287
+ const value =
288
+ bucket.used === undefined || remaining === undefined
289
+ ? "unavailable"
290
+ : `${100 - remaining}% used · ${remaining}% left`;
291
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${value}${reset}`);
292
+ continue;
293
+ }
283
294
  if (bucket.used === undefined || bucket.limit === undefined) {
284
295
  lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}unavailable${reset}`);
285
296
  continue;
@@ -309,15 +320,18 @@ function formatKimiCodingReport(lines: string[], report: UsageReport): void {
309
320
  function formatKimiCodingStatusline(report: UsageReport): string | undefined {
310
321
  const fiveHour = report.buckets.find((bucket) => bucket.id === "five-hour");
311
322
  const weekly = report.buckets.find((bucket) => bucket.id === "weekly");
312
- const subWindow = fiveHour ?? report.buckets.find((bucket) => bucket.id !== "weekly");
313
- const selected = [subWindow, weekly].filter(
323
+ const monthly = report.buckets.find((bucket) => bucket.id === "monthly");
324
+ const subWindow = fiveHour ?? report.buckets.find((bucket) => bucket.id !== "weekly" && bucket.id !== "monthly");
325
+ const selected = [subWindow, weekly, monthly].filter(
314
326
  (bucket, index, buckets): bucket is UsageBucket => bucket !== undefined && buckets.indexOf(bucket) === index,
315
327
  );
316
328
  const parts = ["kimi"];
317
329
  for (const bucket of selected) {
318
- if (!bucket.limit || bucket.remaining === undefined) continue;
330
+ if (bucket.remaining === undefined || (bucket.unit !== "percent" && !bucket.limit)) continue;
319
331
  const fallback = bucket.id === "weekly" ? "weekly" : "5h";
320
- parts.push(`${percentRemaining(bucket)}% ${formatWindowLabel(bucket.windowMinutes, fallback, true)}`);
332
+ const window = bucket.id === "monthly" ? "mo" : formatWindowLabel(bucket.windowMinutes, fallback, true);
333
+ const remaining = bucket.unit === "percent" ? Math.round(clampPercent(bucket.remaining)) : percentRemaining(bucket);
334
+ parts.push(`${remaining}% ${window}`);
321
335
  }
322
336
  return parts.length > 1 ? parts.join(" ") : undefined;
323
337
  }
@@ -521,6 +535,7 @@ function formatCodexStatusline(
521
535
  model?: UsageModel,
522
536
  now = Date.now(),
523
537
  showResetCountdown = true,
538
+ percentage: CodexStatusPercentage = "remaining",
524
539
  ): string | undefined {
525
540
  const group = selectCodexGroup(report, model);
526
541
  if (!group) return formatCodexCreditsStatus(report);
@@ -529,7 +544,8 @@ function formatCodexStatusline(
529
544
  const parts = [group === "codex" ? "codex" : `codex ${compactLimitLabel(labelBucket?.groupLabel ?? group)}`];
530
545
  for (const bucket of buckets) {
531
546
  if (bucket.remaining === undefined) continue;
532
- const percent = `${clampPercent(bucket.remaining).toFixed(0)}%`;
547
+ const displayedPercentage = percentage === "used" ? (bucket.used ?? 100 - bucket.remaining) : bucket.remaining;
548
+ const percent = `${clampPercent(displayedPercentage).toFixed(0)}%`;
533
549
  const fallback = bucket.id.endsWith(":secondary") ? "weekly" : "5h";
534
550
  const window = formatWindowLabel(bucket.windowMinutes, fallback, true);
535
551
  if (!showResetCountdown) {
package/src/index.ts CHANGED
@@ -65,6 +65,7 @@ export {
65
65
  XAI_ADAPTER,
66
66
  } from "./query.js";
67
67
  export type {
68
+ CodexStatusPercentage,
68
69
  UsageSettings,
69
70
  UsageSettingsRuntime,
70
71
  UsageSettingsState,
@@ -2,6 +2,7 @@ import type { OAuthCredential } from "@earendil-works/pi-ai";
2
2
  import { type ExtensionAPI, type ExtensionContext, readStoredCredential } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  export const OAUTH_CREDENTIAL_SOURCE_CHANNEL = "oauth:credential-source:v1";
5
+ export const OAUTH_CREDENTIAL_READINESS_CHANNEL = "oauth:credential-readiness:v1";
5
6
 
6
7
  export type StoredCredentialReader = (providerId: string) => unknown;
7
8
 
@@ -9,13 +10,19 @@ export type OAuthCredentialCandidates =
9
10
  | { ok: true; candidates: readonly OAuthCredential[]; offeredCount?: number }
10
11
  | { ok: false };
11
12
 
12
- export type OAuthCredentialCandidateReader = (ctx: ExtensionContext, providerId: string) => OAuthCredentialCandidates;
13
+ export type OAuthCredentialCandidateReader = {
14
+ (ctx: ExtensionContext, providerId: string): OAuthCredentialCandidates | Promise<OAuthCredentialCandidates>;
15
+ waitUntilReady?(ctx: ExtensionContext, providerId: string): Promise<boolean>;
16
+ };
13
17
 
14
18
  export function createOAuthCredentialCandidateReader(
15
19
  pi: ExtensionAPI,
16
20
  credentialReader: StoredCredentialReader = readStoredCredential,
17
21
  ): OAuthCredentialCandidateReader {
18
- return (ctx, providerId) => collectOAuthCredentialCandidates(pi, ctx, providerId, credentialReader);
22
+ const reader: OAuthCredentialCandidateReader = (ctx, providerId) =>
23
+ collectOAuthCredentialCandidates(pi, ctx, providerId, credentialReader);
24
+ reader.waitUntilReady = (ctx, providerId) => waitForOAuthCredentialSources(pi, ctx, providerId);
25
+ return reader;
19
26
  }
20
27
 
21
28
  export function collectOAuthCredentialCandidates(
@@ -52,6 +59,36 @@ export function collectOAuthCredentialCandidates(
52
59
  return { ok: true, candidates, offeredCount };
53
60
  }
54
61
 
62
+ async function waitForOAuthCredentialSources(
63
+ pi: Pick<ExtensionAPI, "events">,
64
+ ctx: ExtensionContext,
65
+ providerId: string,
66
+ ): Promise<boolean> {
67
+ const pending: Promise<unknown>[] = [];
68
+ let collecting = true;
69
+ const request = Object.freeze({
70
+ session: ctx.sessionManager,
71
+ provider: providerId,
72
+ waitUntil(value: unknown) {
73
+ if (!collecting || !(value instanceof Promise)) return;
74
+ pending.push(value);
75
+ },
76
+ });
77
+ try {
78
+ pi.events.emit(OAUTH_CREDENTIAL_READINESS_CHANNEL, request);
79
+ } catch {
80
+ return false;
81
+ } finally {
82
+ collecting = false;
83
+ }
84
+ try {
85
+ await Promise.all(pending);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
55
92
  export function fallbackOAuthCredentialCandidates(
56
93
  providerId: string,
57
94
  credentialReader: StoredCredentialReader,
@@ -5,6 +5,11 @@ const FIVE_HOUR_WINDOW_MINUTES = 300;
5
5
  const DAILY_WINDOW_MINUTES = 1_440;
6
6
  const WEEKLY_WINDOW_MINUTES = 10_080;
7
7
  const FIXED_POINT_UNITS_PER_CENT = 1_000_000;
8
+ const RATIO_WINDOWS = [
9
+ { key: "limit_5h", id: "five-hour", label: "Five-hour window", windowMinutes: FIVE_HOUR_WINDOW_MINUTES },
10
+ { key: "limit_week", id: "weekly", label: "Weekly window", windowMinutes: WEEKLY_WINDOW_MINUTES },
11
+ { key: "limit_month_total", id: "monthly", label: "Monthly window" },
12
+ ] as const;
8
13
 
9
14
  /**
10
15
  * Source contract revalidated on 2026-08-27.
@@ -24,7 +29,9 @@ export function normalizeKimiCodingUsagePayload(payload: KimiCodingUsagePayload,
24
29
  if (!root) throw new Error("Kimi Coding usage response was not an object.");
25
30
 
26
31
  const candidates: UsageBucket[] = [];
32
+ const explicitWindows = new Set<number>();
27
33
  let omittedWindow = false;
34
+ if (root.usage !== undefined) explicitWindows.add(WEEKLY_WINDOW_MINUTES);
28
35
  const summary = parseUsageRow(root.usage, WEEKLY_WINDOW_MINUTES, "Weekly window");
29
36
  if (summary) candidates.push(summary);
30
37
  else if (root.usage !== undefined) omittedWindow = true;
@@ -33,6 +40,7 @@ export function normalizeKimiCodingUsagePayload(payload: KimiCodingUsagePayload,
33
40
  for (const raw of root.limits) {
34
41
  const item = asObject(raw);
35
42
  const windowMinutes = parseWindowMinutes(item?.window);
43
+ if (windowMinutes !== undefined) explicitWindows.add(windowMinutes);
36
44
  const label = sanitizedLabel(item?.name);
37
45
  const bucket =
38
46
  windowMinutes === undefined
@@ -55,9 +63,24 @@ export function normalizeKimiCodingUsagePayload(payload: KimiCodingUsagePayload,
55
63
  if (rows.length === 1) buckets.push(rows[0] as UsageBucket);
56
64
  else omittedWindow = true;
57
65
  }
58
- buckets.sort((left, right) => (left.windowMinutes ?? 0) - (right.windowMinutes ?? 0));
66
+ const usages = asObject(root.usages);
67
+ if (root.usages !== undefined && !usages) omittedWindow = true;
68
+ if (usages) {
69
+ for (const window of RATIO_WINDOWS) {
70
+ const raw = usages[window.key];
71
+ if (raw === undefined || ("windowMinutes" in window && explicitWindows.has(window.windowMinutes))) continue;
72
+ const bucket = parseRatioWindow(raw, window.id, window.label);
73
+ if (bucket) {
74
+ buckets.push({
75
+ ...bucket,
76
+ ...("windowMinutes" in window ? { windowMinutes: window.windowMinutes } : {}),
77
+ });
78
+ } else omittedWindow = true;
79
+ }
80
+ }
81
+ buckets.sort((left, right) => (left.windowMinutes ?? Infinity) - (right.windowMinutes ?? Infinity));
59
82
 
60
- const metrics = parseBoosterWallet(root.boosterWallet);
83
+ const metrics = parseBoosterWallet(root.boosterWallet === undefined ? root.booster_wallet : root.boosterWallet);
61
84
  if (buckets.length === 0 && metrics.length === 0) {
62
85
  throw new Error("Kimi Coding usage endpoint returned no displayable usage data.");
63
86
  }
@@ -104,6 +127,22 @@ function parseUsageRow(value: unknown, windowMinutes: number, label: string): Us
104
127
  };
105
128
  }
106
129
 
130
+ function parseRatioWindow(value: unknown, id: string, label: string): UsageBucket | undefined {
131
+ const row = asObject(value);
132
+ const ratio = row?.used_ratio;
133
+ if (typeof ratio !== "number" || !Number.isFinite(ratio) || ratio < 0 || ratio > 1) return undefined;
134
+ const resetsAt = asIsoEpochSeconds(row?.reset_time);
135
+ const used = ratio * 100;
136
+ return {
137
+ id,
138
+ label,
139
+ used,
140
+ remaining: 100 - used,
141
+ unit: "percent",
142
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
143
+ };
144
+ }
145
+
107
146
  function parseWindowMinutes(value: unknown): number | undefined {
108
147
  const window = asObject(value);
109
148
  if (!window) return undefined;
package/src/query.ts CHANGED
@@ -343,6 +343,13 @@ export async function resolveUsageAuth(
343
343
 
344
344
  const model = candidateModels(ctx, adapter.id).find((candidate) => hasOfficialOrigin(candidate, adapter.id));
345
345
  if (!model) return undefined;
346
+ if (
347
+ ["github-copilot", "xai"].includes(adapter.id) &&
348
+ candidateReader?.waitUntilReady &&
349
+ !(await candidateReader.waitUntilReady(ctx, adapter.id))
350
+ ) {
351
+ throw new Error(`${adapter.displayName} OAuth credential readiness failed closed.`);
352
+ }
346
353
  // SAFETY: Pi exposes the required auth methods at runtime, and checks below narrow them before use.
347
354
  const registry = ctx.modelRegistry as unknown as UsageAuthRegistry;
348
355
  const provider = registry.getProvider?.(adapter.id);
@@ -416,7 +423,7 @@ export async function resolveUsageAuth(
416
423
  };
417
424
  if (adapter.id === "github-copilot") {
418
425
  const offered = candidateReader
419
- ? candidateReader(ctx, adapter.id)
426
+ ? await candidateReader(ctx, adapter.id)
420
427
  : fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
421
428
  if (!offered.ok) {
422
429
  throw new Error("GitHub Copilot OAuth credential discovery failed closed.");
@@ -425,7 +432,7 @@ export async function resolveUsageAuth(
425
432
  }
426
433
  if (adapter.id === "xai") {
427
434
  const offered = candidateReader
428
- ? candidateReader(ctx, adapter.id)
435
+ ? await candidateReader(ctx, adapter.id)
429
436
  : fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
430
437
  if (!offered.ok) throw new Error("xAI OAuth credential discovery failed closed.");
431
438
  return finalize(resolveXaiUsageAuth(auth, model, salt, offered.candidates));
package/src/settings.ts CHANGED
@@ -9,15 +9,19 @@ import { isBoundedTargetId } from "./usage-targets.js";
9
9
  export const USAGE_SETTINGS_FILE = "pi-usage.json";
10
10
  export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
11
11
 
12
+ export type CodexStatusPercentage = "remaining" | "used";
13
+
12
14
  export interface UsageSettings {
13
15
  codexFastMode: boolean;
14
16
  codexStatusResetCountdown: boolean;
17
+ codexStatusPercentage: CodexStatusPercentage;
15
18
  selectedTargets: Record<string, string>;
16
19
  }
17
20
 
18
21
  export const DEFAULT_USAGE_SETTINGS: Readonly<UsageSettings> = Object.freeze({
19
22
  codexFastMode: false,
20
23
  codexStatusResetCountdown: true,
24
+ codexStatusPercentage: "remaining",
21
25
  selectedTargets: Object.freeze({}),
22
26
  });
23
27
 
@@ -66,6 +70,13 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
66
70
  if (Object.hasOwn(value, "codexStatusResetCountdown") && typeof value.codexStatusResetCountdown !== "boolean") {
67
71
  return undefined;
68
72
  }
73
+ if (
74
+ Object.hasOwn(value, "codexStatusPercentage") &&
75
+ value.codexStatusPercentage !== "remaining" &&
76
+ value.codexStatusPercentage !== "used"
77
+ ) {
78
+ return undefined;
79
+ }
69
80
  if (Object.hasOwn(value, "fireworksAccountId") && !isFireworksAccountId(value.fireworksAccountId)) {
70
81
  return undefined;
71
82
  }
@@ -82,6 +93,8 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
82
93
  typeof value.codexStatusResetCountdown === "boolean"
83
94
  ? value.codexStatusResetCountdown
84
95
  : DEFAULT_USAGE_SETTINGS.codexStatusResetCountdown,
96
+ codexStatusPercentage:
97
+ value.codexStatusPercentage === "used" ? "used" : DEFAULT_USAGE_SETTINGS.codexStatusPercentage,
85
98
  selectedTargets: effectiveTargets,
86
99
  };
87
100
  }
package/src/types.ts CHANGED
@@ -214,7 +214,9 @@ export interface ZaiPlanInfo {
214
214
  export type KimiCodingUsagePayload = {
215
215
  usage?: unknown;
216
216
  limits?: unknown;
217
+ usages?: unknown;
217
218
  boosterWallet?: unknown;
219
+ booster_wallet?: unknown;
218
220
  };
219
221
 
220
222
  export type XaiUserPayload = {
@@ -2,12 +2,19 @@ import { stripVTControlCharacters } from "node:util";
2
2
  import { type ExtensionCommandContext, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
3
  import { Key, matchesKey, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
4
4
  import { errorMessage } from "./core.js";
5
- import type { UsageSettingsRuntime } from "./settings.js";
5
+ import type { UsageSettings, UsageSettingsRuntime } from "./settings.js";
6
6
 
7
7
  const OFF = "Off";
8
8
  const ON = "On";
9
+ const REMAINING = "Remaining";
10
+ const USED = "Used";
9
11
 
10
- type UsageSettingId = "codexFastMode" | "codexStatusResetCountdown";
12
+ type UsageSettingId = "codexFastMode" | "codexStatusResetCountdown" | "codexStatusPercentage";
13
+
14
+ function settingValueLabel(id: UsageSettingId, value: UsageSettings[UsageSettingId]): string {
15
+ if (id === "codexStatusPercentage") return value === "used" ? USED : REMAINING;
16
+ return value === true ? ON : OFF;
17
+ }
11
18
 
12
19
  export async function showUsageSettings(
13
20
  ctx: ExtensionCommandContext,
@@ -45,6 +52,13 @@ export async function showUsageSettings(
45
52
  currentValue: state.settings.codexStatusResetCountdown ? ON : OFF,
46
53
  values: [OFF, ON],
47
54
  },
55
+ {
56
+ id: "codexStatusPercentage",
57
+ label: "Codex percentage",
58
+ description: "Show remaining or used Codex quota in the statusline.",
59
+ currentValue: settingValueLabel("codexStatusPercentage", state.settings.codexStatusPercentage),
60
+ values: [REMAINING, USED],
61
+ },
48
62
  ];
49
63
  const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
50
64
 
@@ -55,11 +69,11 @@ export async function showUsageSettings(
55
69
  localController.abort();
56
70
  done(changed);
57
71
  };
58
- const queueUpdate = (id: UsageSettingId, requested: boolean, display: string) => {
72
+ const queueUpdate = <Id extends UsageSettingId>(id: Id, requested: UsageSettings[Id], display: string) => {
59
73
  saveQueue = saveQueue.then(async () => {
60
74
  const previous = settingsRuntime.get().settings[id];
61
75
  if (settingsRuntime.get().kind === "invalid") {
62
- settingsList.updateValue(id, previous ? ON : OFF);
76
+ settingsList.updateValue(id, settingValueLabel(id, previous));
63
77
  if (!signal.aborted && isCurrent()) {
64
78
  ctx.ui.notify("Repair pi-usage.json and reload before changing settings.", "error");
65
79
  tui.requestRender();
@@ -67,10 +81,12 @@ export async function showUsageSettings(
67
81
  return;
68
82
  }
69
83
  try {
70
- await settingsRuntime.update({ [id]: requested }, signal);
84
+ const patch: Partial<UsageSettings> = {};
85
+ patch[id] = requested;
86
+ await settingsRuntime.update(patch, signal);
71
87
  } catch (error) {
72
88
  if (signal.aborted || !isCurrent()) return;
73
- settingsList.updateValue(id, previous ? ON : OFF);
89
+ settingsList.updateValue(id, settingValueLabel(id, previous));
74
90
  ctx.ui.notify(`Could not save pi-usage.json: ${errorMessage(error)}`, "error");
75
91
  tui.requestRender();
76
92
  return;
@@ -90,7 +106,11 @@ export async function showUsageSettings(
90
106
  getSettingsListTheme(),
91
107
  (id, value) => {
92
108
  if (closing || signal.aborted || !isCurrent()) return;
93
- queueUpdate(id as UsageSettingId, value !== OFF, value);
109
+ if (id === "codexStatusPercentage") {
110
+ queueUpdate(id, value === USED ? "used" : "remaining", value);
111
+ } else if (id === "codexFastMode" || id === "codexStatusResetCountdown") {
112
+ queueUpdate(id, value === ON, value);
113
+ }
94
114
  },
95
115
  cancel,
96
116
  );
package/src/usage.ts CHANGED
@@ -166,10 +166,17 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
166
166
  }
167
167
  return;
168
168
  }
169
+ const settings = settingsRuntime.get().settings;
169
170
  const showCodexResetCountdown =
170
- outcome.state.report.providerId === "openai-codex" && settingsRuntime.get().settings.codexStatusResetCountdown;
171
+ outcome.state.report.providerId === "openai-codex" && settings.codexStatusResetCountdown;
171
172
  const now = Date.now();
172
- const rawValue = formatUsageStatusline(outcome.state.report, model, now, showCodexResetCountdown);
173
+ const rawValue = formatUsageStatusline(
174
+ outcome.state.report,
175
+ model,
176
+ now,
177
+ showCodexResetCountdown,
178
+ settings.codexStatusPercentage,
179
+ );
173
180
  const value = rawValue ? fastRuntime.decorateStatus(model, rawValue) : undefined;
174
181
  if (!safeSetStatus(ctx, value)) return;
175
182
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
@@ -998,7 +1005,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
998
1005
  () => statusGeneration === menuGeneration && !controller.signal.aborted,
999
1006
  (id) => {
1000
1007
  if (
1001
- id === "codexStatusResetCountdown" &&
1008
+ (id === "codexStatusResetCountdown" || id === "codexStatusPercentage") &&
1002
1009
  stableCurrent &&
1003
1010
  statusGeneration === menuGeneration &&
1004
1011
  !controller.signal.aborted