pi-better-openai 0.1.18 → 0.1.21

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.
@@ -0,0 +1,299 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ResolvedConfig } from "./config.ts";
3
+ import { maskIdentifier, sanitizeDiagnosticError } from "./format.ts";
4
+ import {
5
+ AUTH_FILE,
6
+ type UsageSnapshot,
7
+ formatResetCountdown,
8
+ formatUsageSnapshot,
9
+ parseUsageSnapshot,
10
+ readCodexAuth,
11
+ requestCodexUsage,
12
+ usageScopeForModel,
13
+ } from "./usage.ts";
14
+ import { currentModelKey } from "./fast-controller.ts";
15
+
16
+ export function isOpenAISubscriptionModel(
17
+ ctx: ExtensionContext,
18
+ cfg: ResolvedConfig,
19
+ isUsingOAuth?: boolean,
20
+ ): boolean {
21
+ const model = ctx.model;
22
+ if (!model || (model.provider !== "openai" && model.provider !== "openai-codex")) return false;
23
+ return (
24
+ !cfg.usage.showOnlyOnSubscriptionModels ||
25
+ (isUsingOAuth ?? ctx.modelRegistry.isUsingOAuth(model))
26
+ );
27
+ }
28
+
29
+ const STALE_EXTENSION_CONTEXT_MESSAGE = "This extension ctx is stale";
30
+
31
+ function isStaleExtensionContextError(error: unknown): boolean {
32
+ return error instanceof Error && error.message.includes(STALE_EXTENSION_CONTEXT_MESSAGE);
33
+ }
34
+
35
+ type UsageRefreshOptions = { notify?: boolean; force?: boolean };
36
+
37
+ type QueuedUsageRefresh = {
38
+ ctx: ExtensionContext;
39
+ generation: number;
40
+ modelId?: string;
41
+ notify?: boolean;
42
+ force?: boolean;
43
+ };
44
+
45
+ export class UsageController {
46
+ private usageSnapshot: UsageSnapshot | undefined;
47
+ private usageUpdatedAt: number | undefined;
48
+ private usageError: string | undefined;
49
+ private usageLastFetchAt: number | undefined;
50
+ private usageTimer: ReturnType<typeof setInterval> | undefined;
51
+ private usageRefreshInFlight = false;
52
+ private queuedUsageRefresh: QueuedUsageRefresh | undefined;
53
+ private shuttingDown = false;
54
+ private usageAbortController: AbortController | undefined;
55
+ private sessionAbortSignal: AbortSignal | undefined;
56
+ private sessionAbortHandler: (() => void) | undefined;
57
+ private sessionGeneration = 0;
58
+
59
+ constructor(
60
+ private readonly getConfig: (ctx: ExtensionContext) => ResolvedConfig,
61
+ private readonly updateFooter: (ctx: ExtensionContext) => void,
62
+ ) {}
63
+
64
+ get snapshot(): UsageSnapshot | undefined {
65
+ return this.usageSnapshot;
66
+ }
67
+
68
+ statusLine(
69
+ ctx: ExtensionContext,
70
+ cfg = this.getConfig(ctx),
71
+ isUsingOAuth?: boolean,
72
+ ): string | undefined {
73
+ return this.usageSnapshot &&
74
+ !this.usageError &&
75
+ this.usageSnapshot.scope === usageScopeForModel(ctx.model?.id) &&
76
+ cfg.usage.enabled &&
77
+ isOpenAISubscriptionModel(ctx, cfg, isUsingOAuth)
78
+ ? formatUsageSnapshot(this.usageSnapshot, cfg.usage)
79
+ : undefined;
80
+ }
81
+
82
+ formatStatus(ctx: ExtensionContext): string {
83
+ const cfg = this.getConfig(ctx);
84
+ if (!cfg.usage.enabled) return "Usage display is disabled.";
85
+ if (!isOpenAISubscriptionModel(ctx, cfg))
86
+ return "Usage hidden: current model is not an OpenAI subscription model.";
87
+ if (this.usageError) return `Usage unavailable: ${this.usageError}`;
88
+ if (!this.usageSnapshot || this.usageSnapshot.scope !== usageScopeForModel(ctx.model?.id))
89
+ return "Usage unavailable.";
90
+ const stale =
91
+ this.usageUpdatedAt && Date.now() - this.usageUpdatedAt > cfg.usage.refreshIntervalMs * 2
92
+ ? ` | stale ${formatResetCountdown((Date.now() - this.usageUpdatedAt) / 1000)}`
93
+ : "";
94
+ return `${formatUsageSnapshot(this.usageSnapshot, cfg.usage)}${stale}`;
95
+ }
96
+
97
+ formatDebug(ctx: ExtensionContext): string {
98
+ const cfg = this.getConfig(ctx);
99
+ const auth = readCodexAuth();
100
+ return [
101
+ `Usage enabled: ${cfg.usage.enabled}`,
102
+ `Current model: ${currentModelKey(ctx)}`,
103
+ `Current model eligible: ${isOpenAISubscriptionModel(ctx, cfg)}`,
104
+ `Requires subscription model: ${cfg.usage.showOnlyOnSubscriptionModels}`,
105
+ `Auth: ${auth ? "found" : "missing"}`,
106
+ `Account ID: ${maskIdentifier(auth?.accountId) ?? "none"}`,
107
+ `Last fetch: ${this.usageLastFetchAt ? new Date(this.usageLastFetchAt).toLocaleTimeString() : "never"}`,
108
+ `Last successful update: ${this.usageUpdatedAt ? new Date(this.usageUpdatedAt).toLocaleTimeString() : "never"}`,
109
+ `Last error: ${this.usageError ?? "none"}`,
110
+ `Refresh interval: ${cfg.usage.refreshIntervalMs}ms`,
111
+ `Endpoint: https://chatgpt.com/backend-api/wham/usage`,
112
+ ].join("\n");
113
+ }
114
+
115
+ private isGenerationCurrent(generation: number): boolean {
116
+ return !this.shuttingDown && generation === this.sessionGeneration;
117
+ }
118
+
119
+ private deactivateGeneration(generation: number): void {
120
+ if (generation !== this.sessionGeneration) return;
121
+ this.shuttingDown = true;
122
+ this.sessionGeneration++;
123
+ this.queuedUsageRefresh = undefined;
124
+ this.usageAbortController?.abort();
125
+ this.usageAbortController = undefined;
126
+ this.stopTimer();
127
+ }
128
+
129
+ private handleStaleContextError(error: unknown, generation: number): boolean {
130
+ if (!isStaleExtensionContextError(error)) return false;
131
+ this.deactivateGeneration(generation);
132
+ return true;
133
+ }
134
+
135
+ async refresh(
136
+ ctx: ExtensionContext,
137
+ modelId?: string,
138
+ options?: UsageRefreshOptions,
139
+ generation = this.sessionGeneration,
140
+ ): Promise<void> {
141
+ if (!this.isGenerationCurrent(generation)) return;
142
+
143
+ let resolvedModelId = modelId;
144
+ try {
145
+ if (!ctx.hasUI) return;
146
+ resolvedModelId ??= ctx.model?.id;
147
+ } catch (error) {
148
+ this.handleStaleContextError(error, generation);
149
+ return;
150
+ }
151
+
152
+ if (this.usageRefreshInFlight) {
153
+ const queued =
154
+ this.queuedUsageRefresh?.generation === generation ? this.queuedUsageRefresh : undefined;
155
+ this.queuedUsageRefresh = {
156
+ ctx,
157
+ generation,
158
+ modelId: resolvedModelId,
159
+ notify: queued?.notify || options?.notify,
160
+ force: queued?.force || options?.force,
161
+ };
162
+ return;
163
+ }
164
+
165
+ this.usageRefreshInFlight = true;
166
+ try {
167
+ const cfg = this.getConfig(ctx);
168
+ if (!this.isGenerationCurrent(generation)) return;
169
+
170
+ if (!cfg.usage.enabled) {
171
+ this.usageSnapshot = undefined;
172
+ this.usageError = "Usage display is disabled.";
173
+ this.updateFooter(ctx);
174
+ if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
175
+ return;
176
+ }
177
+ if (!isOpenAISubscriptionModel(ctx, cfg)) {
178
+ this.updateFooter(ctx);
179
+ if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
180
+ return;
181
+ }
182
+ const shouldThrottle =
183
+ !options?.force &&
184
+ !options?.notify &&
185
+ this.usageLastFetchAt !== undefined &&
186
+ Date.now() - this.usageLastFetchAt < cfg.usage.refreshIntervalMs;
187
+ if (shouldThrottle) return;
188
+ this.usageLastFetchAt = Date.now();
189
+ this.usageAbortController = new AbortController();
190
+ const timeoutSignal = AbortSignal.timeout(10_000);
191
+ const signal = ctx.signal
192
+ ? AbortSignal.any([ctx.signal, timeoutSignal, this.usageAbortController.signal])
193
+ : AbortSignal.any([timeoutSignal, this.usageAbortController.signal]);
194
+ const data = await requestCodexUsage(ctx, signal);
195
+ if (!this.isGenerationCurrent(generation)) return;
196
+
197
+ this.usageSnapshot = data ? parseUsageSnapshot(data, resolvedModelId) : undefined;
198
+ this.usageUpdatedAt = this.usageSnapshot ? Date.now() : undefined;
199
+ this.usageError = data
200
+ ? undefined
201
+ : `Missing openai-codex OAuth credentials in ${AUTH_FILE}.`;
202
+ this.updateFooter(ctx);
203
+ if (options?.notify)
204
+ ctx.ui.notify(this.formatStatus(ctx), this.usageSnapshot ? "info" : "warning");
205
+ } catch (error) {
206
+ if (this.handleStaleContextError(error, generation) || !this.isGenerationCurrent(generation))
207
+ return;
208
+ this.usageError = sanitizeDiagnosticError(
209
+ error instanceof Error ? error.message : String(error),
210
+ );
211
+ try {
212
+ this.updateFooter(ctx);
213
+ if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
214
+ } catch (secondaryError) {
215
+ if (!this.handleStaleContextError(secondaryError, generation)) {
216
+ this.usageError = sanitizeDiagnosticError(
217
+ secondaryError instanceof Error ? secondaryError.message : String(secondaryError),
218
+ );
219
+ }
220
+ }
221
+ } finally {
222
+ this.usageAbortController = undefined;
223
+ this.usageRefreshInFlight = false;
224
+ const next = this.queuedUsageRefresh;
225
+ this.queuedUsageRefresh = undefined;
226
+ if (next && !this.shuttingDown && next.generation === this.sessionGeneration) {
227
+ void this.refresh(
228
+ next.ctx,
229
+ next.modelId,
230
+ { notify: next.notify, force: next.force },
231
+ next.generation,
232
+ );
233
+ }
234
+ }
235
+ }
236
+
237
+ private stopTimer(): void {
238
+ if (this.usageTimer) clearInterval(this.usageTimer);
239
+ this.usageTimer = undefined;
240
+ if (this.sessionAbortSignal && this.sessionAbortHandler) {
241
+ this.sessionAbortSignal.removeEventListener("abort", this.sessionAbortHandler);
242
+ }
243
+ this.sessionAbortSignal = undefined;
244
+ this.sessionAbortHandler = undefined;
245
+ }
246
+
247
+ start(ctx: ExtensionContext): void {
248
+ this.usageAbortController?.abort();
249
+ this.queuedUsageRefresh = undefined;
250
+ this.stopTimer();
251
+ const generation = ++this.sessionGeneration;
252
+ this.shuttingDown = false;
253
+
254
+ const cfg = this.getConfig(ctx);
255
+ if (!cfg.usage.enabled) return;
256
+ const sessionSignal = ctx.signal;
257
+ if (sessionSignal?.aborted) {
258
+ this.deactivateGeneration(generation);
259
+ return;
260
+ }
261
+ this.sessionAbortSignal = sessionSignal;
262
+ this.sessionAbortHandler = () => {
263
+ this.deactivateGeneration(generation);
264
+ };
265
+ sessionSignal?.addEventListener("abort", this.sessionAbortHandler, { once: true });
266
+ void this.refresh(ctx, undefined, { force: true }, generation);
267
+ this.usageTimer = setInterval(() => {
268
+ if (!this.isGenerationCurrent(generation)) return;
269
+ if (sessionSignal?.aborted) {
270
+ this.deactivateGeneration(generation);
271
+ return;
272
+ }
273
+ void this.refresh(ctx, undefined, undefined, generation);
274
+ }, cfg.usage.refreshIntervalMs);
275
+ this.usageTimer.unref?.();
276
+ }
277
+
278
+ restartAfterSettingsChange(ctx: ExtensionContext, cfg: ResolvedConfig): void {
279
+ this.usageAbortController?.abort();
280
+ this.queuedUsageRefresh = undefined;
281
+ this.stopTimer();
282
+ this.sessionGeneration++;
283
+ this.shuttingDown = false;
284
+ if (cfg.usage.enabled) this.start(ctx);
285
+ else {
286
+ this.usageSnapshot = undefined;
287
+ this.usageError = "Usage display is disabled.";
288
+ }
289
+ }
290
+
291
+ shutdown(): void {
292
+ this.shuttingDown = true;
293
+ this.sessionGeneration++;
294
+ this.queuedUsageRefresh = undefined;
295
+ this.usageAbortController?.abort();
296
+ this.usageAbortController = undefined;
297
+ this.stopTimer();
298
+ }
299
+ }
package/src/usage.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getCodexCredentials } from "./codex-auth.ts";
3
+ export { AUTH_FILE, readCodexAuth } from "./codex-auth.ts";
4
4
 
5
5
  export type UsageWindow = {
6
6
  used_percent?: number | null;
@@ -21,6 +21,8 @@ export type CodexUsageResponse = {
21
21
  };
22
22
 
23
23
  export type UsageSnapshot = {
24
+ capturedAt: number;
25
+ scope: UsageScope;
24
26
  fiveHourLeftPercent: number | null;
25
27
  sevenDayLeftPercent: number | null;
26
28
  fiveHourResetInSeconds: number | null;
@@ -28,11 +30,42 @@ export type UsageSnapshot = {
28
30
  isLimited: boolean;
29
31
  };
30
32
 
31
- const AGENT_DIR = process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
32
- export const AUTH_FILE = join(AGENT_DIR, "auth.json");
33
+ export type UsageScope = "default" | "spark";
34
+
33
35
  export const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
34
36
  const SPARK_MODEL_ID = "gpt-5.3-codex-spark";
35
37
  const SPARK_LIMIT_NAME = "GPT-5.3-Codex-Spark";
38
+ type ResetClockFormatters = {
39
+ time: Intl.DateTimeFormat;
40
+ weekday: Intl.DateTimeFormat;
41
+ date: Intl.DateTimeFormat;
42
+ };
43
+ const RESET_CLOCK_FORMATTER_CACHE_LIMIT = 4;
44
+ const resetClockFormatters = new Map<string, ResetClockFormatters>();
45
+
46
+ function currentTimeZoneKey(date: Date): string {
47
+ const zoneLabel = /\(([^)]+)\)$/.exec(date.toString())?.[1] ?? "";
48
+ return `${process.env.TZ ?? ""}:${date.getTimezoneOffset()}:${zoneLabel}`;
49
+ }
50
+
51
+ function getResetClockFormatters(now: Date, reset: Date): ResetClockFormatters {
52
+ const timeZoneKey = `${currentTimeZoneKey(now)}:${reset.getTimezoneOffset()}`;
53
+ let formatters = resetClockFormatters.get(timeZoneKey);
54
+ if (!formatters) {
55
+ formatters = {
56
+ time: new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }),
57
+ weekday: new Intl.DateTimeFormat(undefined, { weekday: "short" }),
58
+ date: new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric" }),
59
+ };
60
+ resetClockFormatters.set(timeZoneKey, formatters);
61
+ while (resetClockFormatters.size > RESET_CLOCK_FORMATTER_CACHE_LIMIT) {
62
+ const oldestKey = resetClockFormatters.keys().next().value;
63
+ if (oldestKey === undefined) break;
64
+ resetClockFormatters.delete(oldestKey);
65
+ }
66
+ }
67
+ return formatters;
68
+ }
36
69
 
37
70
  function asObject(value: unknown): Record<string, unknown> | null {
38
71
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
@@ -44,12 +77,12 @@ function clampPercent(value: number): number {
44
77
  }
45
78
 
46
79
  function usedToLeftPercent(value: number | null | undefined): number | null {
47
- if (typeof value !== "number" || Number.isNaN(value)) return null;
80
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
48
81
  return clampPercent(100 - value);
49
82
  }
50
83
 
51
84
  export function formatResetCountdown(seconds: number | null): string | null {
52
- if (typeof seconds !== "number" || Number.isNaN(seconds)) return null;
85
+ if (typeof seconds !== "number" || !Number.isFinite(seconds)) return null;
53
86
  const total = Math.max(0, Math.round(seconds));
54
87
  const days = Math.floor(total / 86_400);
55
88
  const hours = Math.floor((total % 86_400) / 3_600);
@@ -64,15 +97,17 @@ export function formatResetCountdown(seconds: number | null): string | null {
64
97
  function formatResetClock(
65
98
  seconds: number | null,
66
99
  options?: { includeDate?: boolean },
100
+ now = Date.now(),
67
101
  ): string | null {
68
- if (typeof seconds !== "number" || Number.isNaN(seconds)) return null;
69
- const resetDate = new Date(Date.now() + Math.max(0, seconds) * 1000);
70
- const now = new Date();
71
- const time = resetDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
72
- if (!options?.includeDate && resetDate.toDateString() === now.toDateString()) return time;
73
- const weekday = resetDate.toLocaleDateString(undefined, { weekday: "short" });
102
+ if (typeof seconds !== "number" || !Number.isFinite(seconds)) return null;
103
+ const resetDate = new Date(now + seconds * 1000);
104
+ const currentDate = new Date(now);
105
+ const formatters = getResetClockFormatters(currentDate, resetDate);
106
+ const time = formatters.time.format(resetDate);
107
+ if (!options?.includeDate && resetDate.toDateString() === currentDate.toDateString()) return time;
108
+ const weekday = formatters.weekday.format(resetDate);
74
109
  if (!options?.includeDate) return `${weekday} ${time}`;
75
- const date = resetDate.toLocaleDateString(undefined, { month: "numeric", day: "numeric" });
110
+ const date = formatters.date.format(resetDate);
76
111
  return `${weekday} ${date} ${time}`;
77
112
  }
78
113
 
@@ -80,38 +115,24 @@ function formatCompactReset(
80
115
  label: string,
81
116
  seconds: number | null,
82
117
  options?: { includeDate?: boolean },
118
+ now = Date.now(),
83
119
  ): string | null {
84
120
  const countdown = formatResetCountdown(seconds);
85
- const clock = formatResetClock(seconds, options);
121
+ const clock = formatResetClock(seconds, options, now);
86
122
  return countdown && clock ? `${label} ↺ ${countdown} - ${clock}` : null;
87
123
  }
88
124
 
89
- export function readCodexAuth(): { accessToken: string; accountId: string } | undefined {
90
- try {
91
- const auth = JSON.parse(readFileSync(AUTH_FILE, "utf8")) as Record<
92
- string,
93
- | {
94
- type?: string;
95
- access?: string | null;
96
- accountId?: string | null;
97
- account_id?: string | null;
98
- }
99
- | undefined
100
- >;
101
- const entry = auth["openai-codex"];
102
- if (entry?.type !== "oauth") return undefined;
103
- const accessToken = entry.access?.trim();
104
- const accountId = (entry.accountId ?? entry.account_id)?.trim();
105
- return accessToken && accountId ? { accessToken, accountId } : undefined;
106
- } catch {
107
- return undefined;
108
- }
125
+ function isAbortSignal(value: unknown): value is AbortSignal {
126
+ return typeof value === "object" && value !== null && "aborted" in value;
109
127
  }
110
128
 
111
129
  export async function requestCodexUsage(
130
+ ctxOrSignal?: ExtensionContext | AbortSignal,
112
131
  signal?: AbortSignal,
113
132
  ): Promise<CodexUsageResponse | undefined> {
114
- const credentials = readCodexAuth();
133
+ const ctx = isAbortSignal(ctxOrSignal) ? undefined : ctxOrSignal;
134
+ const requestSignal = isAbortSignal(ctxOrSignal) ? ctxOrSignal : signal;
135
+ const credentials = await getCodexCredentials(ctx, requestSignal);
115
136
  if (!credentials) return undefined;
116
137
  const response = await fetch(USAGE_URL, {
117
138
  headers: {
@@ -119,7 +140,7 @@ export async function requestCodexUsage(
119
140
  authorization: `Bearer ${credentials.accessToken}`,
120
141
  "chatgpt-account-id": credentials.accountId,
121
142
  },
122
- signal,
143
+ signal: requestSignal,
123
144
  });
124
145
  if (!response.ok) throw new Error(`Codex usage request failed (${response.status})`);
125
146
  return (await response.json()) as CodexUsageResponse;
@@ -165,34 +186,45 @@ function findSparkRateLimitBucket(data: CodexUsageResponse): RateLimitBucket | n
165
186
  return null;
166
187
  }
167
188
 
168
- function getResetSeconds(window: UsageWindow | null | undefined): number | null {
169
- if (typeof window?.reset_after_seconds === "number" && !Number.isNaN(window.reset_after_seconds))
189
+ function getResetSeconds(window: UsageWindow | null | undefined, now: number): number | null {
190
+ if (
191
+ typeof window?.reset_after_seconds === "number" &&
192
+ Number.isFinite(window.reset_after_seconds)
193
+ )
170
194
  return window.reset_after_seconds;
171
- if (typeof window?.reset_at !== "number" || Number.isNaN(window.reset_at)) return null;
195
+ if (typeof window?.reset_at !== "number" || !Number.isFinite(window.reset_at)) return null;
172
196
  const resetAtSeconds =
173
197
  window.reset_at > 100_000_000_000 ? window.reset_at / 1000 : window.reset_at;
174
- return Math.max(0, resetAtSeconds - Date.now() / 1000);
198
+ return Math.max(0, resetAtSeconds - now / 1000);
199
+ }
200
+
201
+ export function usageScopeForModel(modelId: string | undefined): UsageScope {
202
+ return modelId === SPARK_MODEL_ID ? "spark" : "default";
175
203
  }
176
204
 
177
205
  export function parseUsageSnapshot(
178
206
  data: CodexUsageResponse,
179
207
  modelId: string | undefined,
208
+ now = Date.now(),
180
209
  ): UsageSnapshot {
210
+ const scope = usageScopeForModel(modelId);
181
211
  const bucket =
182
- modelId === SPARK_MODEL_ID
183
- ? findSparkRateLimitBucket(data)
212
+ scope === "spark"
213
+ ? (findSparkRateLimitBucket(data) ?? normalizeRateLimitBucket(data.rate_limit))
184
214
  : normalizeRateLimitBucket(data.rate_limit);
185
215
  return {
216
+ capturedAt: now,
217
+ scope,
186
218
  fiveHourLeftPercent: usedToLeftPercent(bucket?.primary_window?.used_percent),
187
219
  sevenDayLeftPercent: usedToLeftPercent(bucket?.secondary_window?.used_percent),
188
- fiveHourResetInSeconds: getResetSeconds(bucket?.primary_window),
189
- sevenDayResetInSeconds: getResetSeconds(bucket?.secondary_window),
220
+ fiveHourResetInSeconds: getResetSeconds(bucket?.primary_window, now),
221
+ sevenDayResetInSeconds: getResetSeconds(bucket?.secondary_window, now),
190
222
  isLimited: bucket?.limit_reached === true || bucket?.allowed === false,
191
223
  };
192
224
  }
193
225
 
194
226
  export function formatPercent(value: number | null): string {
195
- return typeof value === "number" && !Number.isNaN(value)
227
+ return typeof value === "number" && Number.isFinite(value)
196
228
  ? `${Math.round(clampPercent(value))}%`
197
229
  : "--";
198
230
  }
@@ -200,14 +232,33 @@ export function formatPercent(value: number | null): string {
200
232
  export function formatUsageSnapshot(
201
233
  snapshot: UsageSnapshot,
202
234
  options: { showResetTimes: boolean },
235
+ now = Date.now(),
203
236
  ): string {
204
237
  const fiveHour = formatPercent(snapshot.fiveHourLeftPercent);
205
238
  const sevenDay = formatPercent(snapshot.sevenDayLeftPercent);
206
239
  const resets = options.showResetTimes
207
240
  ? [
208
- formatCompactReset("5h", snapshot.fiveHourResetInSeconds),
209
- formatCompactReset("7d", snapshot.sevenDayResetInSeconds, { includeDate: true }),
241
+ formatCompactReset(
242
+ "5h",
243
+ remainingResetSeconds(snapshot.fiveHourResetInSeconds, snapshot.capturedAt, now),
244
+ undefined,
245
+ now,
246
+ ),
247
+ formatCompactReset(
248
+ "7d",
249
+ remainingResetSeconds(snapshot.sevenDayResetInSeconds, snapshot.capturedAt, now),
250
+ { includeDate: true },
251
+ now,
252
+ ),
210
253
  ].filter((value): value is string => value !== null)
211
254
  : [];
212
255
  return `Usage: 5h: ${fiveHour} | 7d: ${sevenDay}${resets.length ? ` | ${resets.join(" | ")}` : ""}`;
213
256
  }
257
+
258
+ function remainingResetSeconds(
259
+ seconds: number | null,
260
+ capturedAt: number,
261
+ now: number,
262
+ ): number | null {
263
+ return seconds === null ? null : seconds - (now - capturedAt) / 1000;
264
+ }