@llblab/pi-codex-usage 0.8.1 → 0.9.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/AGENTS.md CHANGED
@@ -8,10 +8,10 @@
8
8
  - Trigger: Updating quota polling or error handling.
9
9
  - Action: Do not collapse the bar while a request is in flight; only show `n/a` or `error` after repeated failures or no usable quota.
10
10
 
11
- - `Compact dual bar`: Encode the 5-hour and weekly quota windows in the ten-character separated-sextant bar.
11
+ - `Adaptive compact bar`: Encode the server-provided quota windows in the ten-character separated-sextant bar.
12
12
  - Trigger: Changing statusline formatting.
13
- - Action: Keep a fixed-width `xxxxxxxxxx` bar where top sextants represent the 5-hour window and bottom sextants represent the weekly window, with 20 steps per window.
13
+ - Action: When both windows exist, keep the classic dual bar with 20 top steps for the 5-hour window and 20 bottom steps for the weekly window. When only one weekly window exists, combine both tiers into one 40-step bar.
14
14
 
15
- - `Weekly reset countdown`: Append the weekly reset countdown only when the secondary window exposes a reset time.
15
+ - `Weekly reset countdown`: Append the weekly reset countdown whenever the available weekly window exposes a reset time.
16
16
  - Trigger: Changing reset-time normalization or statusline refresh cadence.
17
- - Action: Keep `d` labels rounded upward in 144-minute day-tenth steps above 24h, show 24h..1h labels in upward-rounded 6-minute hour-tenth steps, keep `m`/`s` labels floored, and hold `0s` until a successful quota refresh reports the next window.
17
+ - Action: Treat the secondary window as weekly in dual-window responses and the sole window as weekly in single-window responses. Keep `d` labels rounded upward in 144-minute day-tenth steps above 24h, show 24h..1h labels in upward-rounded 6-minute hour-tenth steps, keep `m`/`s` labels floored, and hold `0s` until a successful quota refresh reports the next window.
package/BACKLOG.md CHANGED
@@ -5,3 +5,4 @@
5
5
  - [x] Fix stale extension context usage after session replacement or reload. Source: GitHub issue #1. Timers must not keep using captured stale `ctx` after Pi session lifecycle changes.
6
6
  - [x] Make usage refresh cache/in-flight state model-aware for Codex vs Spark. A regular Codex cache or request must not temporarily render Spark as `n/a` after model switching.
7
7
  - [x] Preserve the active `codex`/`spark` label for failure states. Spark failures should render `spark n/a` or `spark error`, not `codex ...`.
8
+ - [x] Replace the visually full startup/request placeholder with counter-moving upper/lower in-bar loading markers. Keep the last usable active-bucket bar visible during ordinary refreshes, and hold provisional zero-usage reports behind the loader until confirmed stable.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.0: Adaptive Weekly Quota Bar
6
+
7
+ - Added server-driven quota-window display selection. Impact: responses containing both 5-hour and weekly windows keep the classic dual-tier bar, while responses containing only the new weekly window use all 40 bar steps as one unified quota scale instead of leaving one tier empty.
8
+ - Added single-window reset countdown and redraw scheduling. Impact: the weekly reset time remains visible and updates at the correct countdown boundaries regardless of whether OpenAI returns the weekly limit as the primary or secondary window.
9
+ - Extended provisional full-quota stabilization to single-window responses. Impact: transient 0%-used weekly reports remain behind the loading indicator until confirmed, matching the existing dual-window safety behavior.
10
+
11
+ ## 0.8.2: Quota Loading Hotfix
12
+
13
+ - Replaced the static empty request placeholder with fixed-width upper and lower markers that move in opposite directions through their respective quota bars, reverse at the ends, and randomly start from either mirrored endpoint phase, using the normal themed bar background. Impact: motion distinguishes startup and model-bucket loading from 100% available quota while preserving the dual-window visual language.
14
+ - Kept the last usable active-bucket report visible while refreshing, including when returning from another model bucket, and stabilized first reports claiming both windows are completely unused for 15 seconds with one-second retries. Impact: routine polling remains visually stable while transient provider initialization values stay behind the loader instead of appearing as false 100% availability.
15
+
5
16
  ## 0.8.1: Stale Context Hotfix
6
17
 
7
18
  - Fixed stale extension context crashes from delayed statusline timers after Pi session replacement or reload. Impact: timers now ignore stale `ctx` failures instead of letting issue #1 crash Pi after idle/session lifecycle changes.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ![Codex Usage](./banner.jpg)
6
6
 
7
- This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-codex-usage`](https://github.com/narumiruna/pi-extensions/tree/main/extensions/pi-codex-usage). It keeps the auth and quota-fetching path, but intentionally narrows the interface to the primary Codex 5-hour and weekly limits.
7
+ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-codex-usage`](https://github.com/narumiruna/pi-extensions/tree/main/extensions/pi-codex-usage). It keeps the auth and quota-fetching path, but intentionally narrows the interface to the Codex quota windows returned by OpenAI.
8
8
 
9
9
  ## Start Here
10
10
 
@@ -14,7 +14,8 @@ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-co
14
14
 
15
15
  ## Features
16
16
 
17
- - Shows an empty statusline bar immediately, then refreshes every 30 seconds while the active Pi model uses `openai-codex`
17
+ - Shows two counter-moving half-height markers in the statusline bar while the active Codex/Spark quota bucket is loading, then refreshes every 30 seconds
18
+ - Keeps the last usable active-bucket bar visible during ordinary refreshes instead of replacing known quota with a loading state
18
19
  - Statusline output stays compact, with the active usage label accented and the quota bar plus weekly reset countdown drawn on a themed background
19
20
  - Regular Codex subscription models show the primary `codex` quota bucket
20
21
  - `GPT-5.3-Codex-Spark` shows the parallel Spark quota bucket with the `spark` label
@@ -55,9 +56,11 @@ Spark model usage:
55
56
  spark ██████████ 7d
56
57
  ```
57
58
 
58
- The ten-character bar encodes two twenty-step limits at once: 40 total bits of quota state in 10 terminal cells. Each step is 5%: the top quadrants are the 5-hour limit, and the bottom quadrants are the weekly limit. If either quota window is exhausted, the bar keeps the same shape but switches to the error background color.
59
+ The ten-character bar adapts to the quota windows returned by OpenAI. When both windows exist, it encodes two twenty-step limits at once: the top quadrants show the 5-hour limit and the bottom quadrants show the weekly limit, with each step representing 5%. When OpenAI returns only the weekly window, both tiers combine into one conventional 40-step bar, with each step representing 2.5%. If any available quota window is exhausted, the bar keeps its shape but switches to the error background color.
59
60
 
60
- When the weekly reset time is available, the bar is followed by a countdown. More than a day remains is shown in 144-minute day-tenth steps such as `7d`, `6.9d`, `6.6d`, `5.1d`, `5d`, `3.7d`, `3d`, `2d`, `1.9d`, `1.5d`, and `1.1d`, rounded upward to the next tenth. At 24 hours and below it switches to upward-rounded 6-minute hour-tenth steps such as `24h`, `23.7h`, `20.1h`, `20h`, `19.9h`, `1.4h`, `1.3h`, `1.2h`, `1.1h`, and `1h`. Under an hour it switches to floored minutes, and under a minute to seconds. After the reset timestamp passes, `0s` is held until the next successful quota refresh reports the new weekly window.
61
+ Before the first usable report for the active quota bucket arrives, two half-height markers move through the same fixed-width themed bar. The upper 5-hour marker travels opposite the lower weekly marker; both reverse smoothly at the ends, and each loader run randomly starts from one of the two mirrored endpoint phases. Their motion distinguishes loading from 100% remaining quota while preserving the normal bar background. A first report claiming both windows are completely unused is treated as provisional for 15 seconds and retried every second, because providers can briefly emit zeroed windows while initializing. Once a usable report exists, refresh requests preserve that last good bar.
62
+
63
+ When the weekly reset time is available, including in a single-window response, the bar is followed by a countdown. More than a day remains is shown in 144-minute day-tenth steps such as `7d`, `6.9d`, `6.6d`, `5.1d`, `5d`, `3.7d`, `3d`, `2d`, `1.9d`, `1.5d`, and `1.1d`, rounded upward to the next tenth. At 24 hours and below it switches to upward-rounded 6-minute hour-tenth steps such as `24h`, `23.7h`, `20.1h`, `20h`, `19.9h`, `1.4h`, `1.3h`, `1.2h`, `1.1h`, and `1h`. Under an hour it switches to floored minutes, and under a minute to seconds. After the reset timestamp passes, `0s` is held until the next successful quota refresh reports the new weekly window.
61
64
 
62
65
  When the 5-hour window is exhausted and exposes its own reset time, the statusline adds the 5-hour reset before the weekly reset:
63
66
 
package/index.ts CHANGED
@@ -17,6 +17,9 @@ const HOUR_TENTH_MS = 6 * MINUTE_MS;
17
17
  const DAY_MS = 24 * HOUR_MS;
18
18
  const DAY_TENTH_MS = 144 * MINUTE_MS;
19
19
  const REFRESH_INTERVAL_MS = 30 * SECOND_MS;
20
+ const PROVISIONAL_RETRY_MS = SECOND_MS;
21
+ const FULL_AVAILABILITY_CONFIRMATION_MS = 15 * SECOND_MS;
22
+ const LOADING_FRAME_MS = 30;
20
23
  const REDRAW_BLINK_MS = 150;
21
24
  const STATUS_KEY = "aa-codex-usage";
22
25
  const MAX_ERROR_BODY_CHARS = 600;
@@ -55,8 +58,7 @@ type PiModel = NonNullable<ExtensionContext["model"]>;
55
58
  export type CodexUsageModel = Pick<PiModel, "id" | "name" | "provider">;
56
59
  type CodexUsageTelegramStatusModel = Pick<PiModel, "id" | "name" | "provider">;
57
60
  type TelegramStatusLineProviderResult =
58
- | { label: string; value: string }
59
- | undefined;
61
+ { label: string; value: string } | undefined;
60
62
  type TelegramStatusLineProvider = (ctx: {
61
63
  activeModel?: CodexUsageTelegramStatusModel;
62
64
  }) => TelegramStatusLineProviderResult;
@@ -164,13 +166,17 @@ export default function codexUsage(pi: ExtensionAPI) {
164
166
  let cache: CachedReport | undefined;
165
167
  let failedRefreshes = 0;
166
168
  let inFlightUsageQuery:
167
- | { limitId: string; promise: Promise<QueryUsageResult> }
168
- | undefined;
169
+ { limitId: string; promise: Promise<QueryUsageResult> } | undefined;
169
170
  let statuslineBlinkTimer: TimeoutHandle | undefined;
170
171
  let statuslineClearTimer: TimeoutHandle | undefined;
171
172
  let statuslineCountdownTimer: TimeoutHandle | undefined;
173
+ let statuslineLoadingTimer: TimeoutHandle | undefined;
172
174
  let statuslineRefreshTimer: TimeoutHandle | undefined;
175
+ let statuslineLoadingFrame = 0;
176
+ let statuslineLoadingLimitId: string | undefined;
173
177
  let statuslineRequestId = 0;
178
+ let provisionalFullReport:
179
+ { limitId: string; firstSeenAt: number } | undefined;
174
180
  let unregisterTelegramStatusLine: (() => void) | undefined;
175
181
  let telegramStatusLineRegistration: Promise<void> | undefined;
176
182
 
@@ -181,7 +187,9 @@ export default function codexUsage(pi: ExtensionAPI) {
181
187
  if (!isOpenAICodexModel(activeModel)) return undefined;
182
188
  if (!cache) return undefined;
183
189
  const value = formatCodexUsageStatusValue(cache.report, activeModel);
184
- return value ? { label: activeUsageLabel(activeModel), value } : undefined;
190
+ return value
191
+ ? { label: activeUsageLabel(activeModel), value }
192
+ : undefined;
185
193
  },
186
194
  )
187
195
  .then((unregister) => {
@@ -196,13 +204,51 @@ export default function codexUsage(pi: ExtensionAPI) {
196
204
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
197
205
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
198
206
  if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
207
+ if (statuslineLoadingTimer) clearTimeout(statuslineLoadingTimer);
199
208
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
200
209
  statuslineBlinkTimer = undefined;
201
210
  statuslineClearTimer = undefined;
202
211
  statuslineCountdownTimer = undefined;
212
+ statuslineLoadingTimer = undefined;
213
+ statuslineLoadingLimitId = undefined;
203
214
  statuslineRefreshTimer = undefined;
204
215
  };
205
216
 
217
+ const stopStatuslineLoading = () => {
218
+ if (statuslineLoadingTimer) clearTimeout(statuslineLoadingTimer);
219
+ statuslineLoadingTimer = undefined;
220
+ statuslineLoadingLimitId = undefined;
221
+ };
222
+
223
+ const startStatuslineLoading = (
224
+ ctx: ExtensionContext,
225
+ model: CodexUsageModel | undefined,
226
+ ) => {
227
+ const limitId = activeUsageLimitId(model);
228
+ if (statuslineLoadingTimer && statuslineLoadingLimitId === limitId) return;
229
+ stopStatuslineLoading();
230
+ statuslineLoadingFrame = Math.random() < 0.5 ? 0 : DUAL_BAR_WIDTH * 2 - 1;
231
+ statuslineLoadingLimitId = limitId;
232
+ const drawNextFrame = () => {
233
+ try {
234
+ ctx.ui.setStatus(
235
+ STATUS_KEY,
236
+ formatStatuslineLoading(ctx, statuslineLoadingFrame, model),
237
+ );
238
+ statuslineLoadingFrame += 1;
239
+ statuslineLoadingTimer = setTimeout(
240
+ drawNextFrame,
241
+ LOADING_FRAME_MS,
242
+ ) as TimeoutHandle;
243
+ statuslineLoadingTimer.unref?.();
244
+ } catch (error) {
245
+ statuslineLoadingTimer = undefined;
246
+ handleTimerError(error);
247
+ }
248
+ };
249
+ drawNextFrame();
250
+ };
251
+
206
252
  const clearUsageStatusline = (ctx: ExtensionContext) => {
207
253
  statuslineRequestId += 1;
208
254
  clearStatuslineTimers();
@@ -222,11 +268,16 @@ export default function codexUsage(pi: ExtensionAPI) {
222
268
  statuslineClearTimer.unref?.();
223
269
  };
224
270
 
225
- const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
271
+ const scheduleStatuslineRefresh = (
272
+ ctx: ExtensionContext,
273
+ delayMs = REFRESH_INTERVAL_MS,
274
+ ) => {
226
275
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
227
276
  statuslineRefreshTimer = setTimeout(() => {
228
- void refreshCurrentCodexUsageStatusline(ctx, true).catch(handleAsyncTimerError);
229
- }, REFRESH_INTERVAL_MS) as TimeoutHandle;
277
+ void refreshCurrentCodexUsageStatusline(ctx, true).catch(
278
+ handleAsyncTimerError,
279
+ );
280
+ }, delayMs) as TimeoutHandle;
230
281
  statuslineRefreshTimer.unref?.();
231
282
  };
232
283
 
@@ -269,12 +320,16 @@ export default function codexUsage(pi: ExtensionAPI) {
269
320
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
270
321
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
271
322
  if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
323
+ stopStatuslineLoading();
272
324
  statuslineBlinkTimer = undefined;
273
325
  statuslineClearTimer = undefined;
274
326
  statuslineCountdownTimer = undefined;
275
327
  const text = formatCodexUsageStatusline(report, ctx, options.model);
276
328
  if (options.blink) {
277
- ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
329
+ ctx.ui.setStatus(
330
+ STATUS_KEY,
331
+ formatStatuslineLoading(ctx, statuslineLoadingFrame, options.model),
332
+ );
278
333
  statuslineBlinkTimer = setTimeout(() => {
279
334
  try {
280
335
  ctx.ui.setStatus(STATUS_KEY, text);
@@ -298,13 +353,15 @@ export default function codexUsage(pi: ExtensionAPI) {
298
353
  model: CodexUsageModel | undefined,
299
354
  ) => {
300
355
  const limitId = activeUsageLimitId(model);
301
- if (inFlightUsageQuery?.limitId === limitId) return inFlightUsageQuery.promise;
356
+ if (inFlightUsageQuery?.limitId === limitId)
357
+ return inFlightUsageQuery.promise;
302
358
  const promise = queryUsage(
303
359
  ctx,
304
360
  { timeoutMs: DEFAULT_TIMEOUT_MS },
305
361
  model,
306
362
  ).finally(() => {
307
- if (inFlightUsageQuery?.promise === promise) inFlightUsageQuery = undefined;
363
+ if (inFlightUsageQuery?.promise === promise)
364
+ inFlightUsageQuery = undefined;
308
365
  });
309
366
  inFlightUsageQuery = { limitId, promise };
310
367
  return promise;
@@ -322,17 +379,27 @@ export default function codexUsage(pi: ExtensionAPI) {
322
379
  return;
323
380
  }
324
381
 
325
- if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
382
+ const usableCache =
383
+ cache && canReuseCachedReport(cache.report, activeModel)
384
+ ? cache
385
+ : undefined;
386
+ if (usableCache) {
387
+ setUsageStatusline(ctx, usableCache.report, {
388
+ autoRefresh: true,
389
+ blink: false,
390
+ model: activeModel,
391
+ });
392
+ } else {
393
+ startStatuslineLoading(ctx, activeModel);
394
+ }
326
395
  const requestId = statuslineRequestId + 1;
327
396
  statuslineRequestId = requestId;
328
- const cached =
329
- cache &&
330
- Date.now() - cache.createdAt < REFRESH_INTERVAL_MS &&
331
- canReuseCachedReport(cache.report, activeModel)
332
- ? cache
397
+ const freshCache =
398
+ usableCache && Date.now() - usableCache.createdAt < REFRESH_INTERVAL_MS
399
+ ? usableCache
333
400
  : undefined;
334
- if (cached && !force) {
335
- setUsageStatusline(ctx, cached.report, {
401
+ if (freshCache && !force) {
402
+ setUsageStatusline(ctx, freshCache.report, {
336
403
  autoRefresh: true,
337
404
  blink: false,
338
405
  model: activeModel,
@@ -349,7 +416,12 @@ export default function codexUsage(pi: ExtensionAPI) {
349
416
 
350
417
  if (!result.ok) {
351
418
  failedRefreshes += 1;
352
- if (!cache || failedRefreshes >= 5) {
419
+ const activeCache =
420
+ cache && canReuseCachedReport(cache.report, activeModel)
421
+ ? cache
422
+ : undefined;
423
+ if (!activeCache || failedRefreshes >= 5) {
424
+ stopStatuslineLoading();
353
425
  ctx.ui.setStatus(
354
426
  STATUS_KEY,
355
427
  formatStatuslineProblem(ctx, result.errors, activeModel),
@@ -360,11 +432,34 @@ export default function codexUsage(pi: ExtensionAPI) {
360
432
  }
361
433
 
362
434
  const previousReport = cache?.report;
435
+ const previousWasFullyAvailable = previousReport
436
+ ? isFullyAvailableReport(previousReport, activeModel)
437
+ : false;
438
+ if (
439
+ isFullyAvailableReport(result.report, activeModel) &&
440
+ !previousWasFullyAvailable
441
+ ) {
442
+ const now = Date.now();
443
+ const limitId = activeUsageLimitId(activeModel);
444
+ if (provisionalFullReport?.limitId !== limitId) {
445
+ provisionalFullReport = { limitId, firstSeenAt: now };
446
+ }
447
+ if (
448
+ now - provisionalFullReport.firstSeenAt <
449
+ FULL_AVAILABILITY_CONFIRMATION_MS
450
+ ) {
451
+ scheduleStatuslineRefresh(ctx, PROVISIONAL_RETRY_MS);
452
+ return;
453
+ }
454
+ } else {
455
+ provisionalFullReport = undefined;
456
+ }
363
457
  const blink = previousReport
364
458
  ? formatReportBar(previousReport, activeModel) !==
365
459
  formatReportBar(result.report, activeModel)
366
460
  : false;
367
461
  failedRefreshes = 0;
462
+ provisionalFullReport = undefined;
368
463
  cache = { createdAt: Date.now(), report: result.report };
369
464
  setUsageStatusline(ctx, result.report, {
370
465
  autoRefresh: true,
@@ -385,19 +480,25 @@ export default function codexUsage(pi: ExtensionAPI) {
385
480
  pi.on("session_start", (_event, ctx) => {
386
481
  ensureTelegramStatusLineRegistered();
387
482
  if (isOpenAICodexModel(ctx.model))
388
- void refreshCurrentCodexUsageStatusline(ctx, false).catch(handleAsyncTimerError);
483
+ void refreshCurrentCodexUsageStatusline(ctx, false).catch(
484
+ handleAsyncTimerError,
485
+ );
389
486
  else clearUsageStatusline(ctx);
390
487
  });
391
488
 
392
489
  pi.on("session_tree", (_event, ctx) => {
393
490
  if (isOpenAICodexModel(ctx.model))
394
- void refreshCurrentCodexUsageStatusline(ctx, false).catch(handleAsyncTimerError);
491
+ void refreshCurrentCodexUsageStatusline(ctx, false).catch(
492
+ handleAsyncTimerError,
493
+ );
395
494
  else clearUsageStatusline(ctx);
396
495
  });
397
496
 
398
497
  pi.on("model_select", (event, ctx) => {
399
498
  if (isOpenAICodexModel(event.model)) {
400
- void refreshCurrentCodexUsageStatusline(ctx, false, event.model).catch(handleAsyncTimerError);
499
+ void refreshCurrentCodexUsageStatusline(ctx, false, event.model).catch(
500
+ handleAsyncTimerError,
501
+ );
401
502
  } else {
402
503
  clearUsageStatusline(ctx);
403
504
  }
@@ -446,7 +547,9 @@ function activeUsageLimitId(
446
547
  function activeUsageLabel(
447
548
  model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
448
549
  ): string {
449
- return isSparkCodexModel(model) ? SPARK_STATUS_LABEL_TEXT : DEFAULT_STATUS_LABEL_TEXT;
550
+ return isSparkCodexModel(model)
551
+ ? SPARK_STATUS_LABEL_TEXT
552
+ : DEFAULT_STATUS_LABEL_TEXT;
450
553
  }
451
554
 
452
555
  async function importTelegramStatusLineModule(): Promise<
@@ -826,7 +929,7 @@ function backendAdditionalLimitId(limit: BackendAdditionalRateLimit): string {
826
929
  const raw = `${asString(limit.limit_name) ?? ""} ${asString(limit.metered_feature) ?? ""}`;
827
930
  return raw.toLowerCase().includes("spark")
828
931
  ? SPARK_USAGE_LIMIT_ID
829
- : normalizedUsageKey(raw) ?? CODEX_USAGE_LIMIT_ID;
932
+ : (normalizedUsageKey(raw) ?? CODEX_USAGE_LIMIT_ID);
830
933
  }
831
934
 
832
935
  function normalizeBackendSnapshot(
@@ -995,7 +1098,7 @@ export function formatCodexUsageStatusValue(
995
1098
  const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
996
1099
  const snapshot = selectActiveUsageSnapshot(report, model);
997
1100
  if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
998
- const bar = formatDualLimitBar(snapshot.primary, snapshot.secondary);
1101
+ const bar = formatAdaptiveLimitBar(snapshot.primary, snapshot.secondary);
999
1102
  if (isQuotaWindowExhausted(snapshot.primary) && snapshot.primary?.resetAt) {
1000
1103
  const primaryCountdown = formatResetCountdown(
1001
1104
  snapshot.primary.resetAt,
@@ -1019,7 +1122,8 @@ export function formatWeeklyResetCountdown(
1019
1122
  ): string | undefined {
1020
1123
  const model = typeof modelOrNow === "number" ? undefined : modelOrNow;
1021
1124
  const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
1022
- const resetAt = selectActiveUsageSnapshot(report, model)?.secondary?.resetAt;
1125
+ const snapshot = selectActiveUsageSnapshot(report, model);
1126
+ const resetAt = weeklyWindow(snapshot)?.resetAt;
1023
1127
  if (resetAt === undefined) return undefined;
1024
1128
  return formatResetCountdown(resetAt, capturedNow);
1025
1129
  }
@@ -1048,9 +1152,9 @@ export function nextResetCountdownDelayMs(
1048
1152
  model?: CodexUsageModel,
1049
1153
  ): number | undefined {
1050
1154
  const snapshot = selectActiveUsageSnapshot(report, model);
1051
- const resetTimes = [snapshot?.secondary?.resetAt];
1052
- if (isQuotaWindowExhausted(snapshot?.primary)) {
1053
- resetTimes.push(snapshot?.primary?.resetAt);
1155
+ const resetTimes = [weeklyWindow(snapshot)?.resetAt];
1156
+ if (snapshot?.secondary && isQuotaWindowExhausted(snapshot.primary)) {
1157
+ resetTimes.push(snapshot.primary?.resetAt);
1054
1158
  }
1055
1159
  const delays = resetTimes
1056
1160
  .filter((resetAt): resetAt is number => resetAt !== undefined)
@@ -1094,7 +1198,7 @@ function formatReportBar(
1094
1198
  ): string | undefined {
1095
1199
  const snapshot = selectActiveUsageSnapshot(report, model);
1096
1200
  if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
1097
- return formatDualLimitBar(snapshot.primary, snapshot.secondary);
1201
+ return formatAdaptiveLimitBar(snapshot.primary, snapshot.secondary);
1098
1202
  }
1099
1203
 
1100
1204
  function formatStatuslineText(
@@ -1117,8 +1221,33 @@ function formatStatuslineBarText(
1117
1221
  return `${label} ${value}`;
1118
1222
  }
1119
1223
 
1120
- function formatEmptyStatuslineBar(ctx: ExtensionContext): string {
1121
- return formatStatuslineBarText(ctx, DUAL_BAR_CHARS[0].repeat(DUAL_BAR_WIDTH));
1224
+ export function formatCodexUsageLoadingBar(frame: number): string {
1225
+ const totalParts = DUAL_BAR_WIDTH * 2;
1226
+ const cycleFrames = (totalParts - 1) * 2;
1227
+ const finiteFrame = Number.isFinite(frame) ? frame : 0;
1228
+ const cycleFrame = Math.abs(Math.trunc(finiteFrame)) % cycleFrames;
1229
+ const primaryPart =
1230
+ cycleFrame < totalParts ? cycleFrame : cycleFrames - cycleFrame;
1231
+ const secondaryPart = totalParts - primaryPart - 1;
1232
+ const masks = Array<number>(DUAL_BAR_WIDTH).fill(0);
1233
+ const primaryCell = Math.floor(primaryPart / 2);
1234
+ const secondaryCell = Math.floor(secondaryPart / 2);
1235
+ masks[primaryCell] |= primaryPart % 2 === 0 ? 1 : 2;
1236
+ masks[secondaryCell] |= secondaryPart % 2 === 0 ? 4 : 8;
1237
+ return masks.map((mask) => DUAL_BAR_CHARS[mask]).join("");
1238
+ }
1239
+
1240
+ function formatStatuslineLoading(
1241
+ ctx: ExtensionContext,
1242
+ frame: number,
1243
+ model?: CodexUsageModel,
1244
+ ): string {
1245
+ return formatStatuslineBarText(
1246
+ ctx,
1247
+ formatCodexUsageLoadingBar(frame),
1248
+ "selectedBg",
1249
+ model,
1250
+ );
1122
1251
  }
1123
1252
 
1124
1253
  function formatStatuslineProblem(
@@ -1163,9 +1292,32 @@ function normalizedUsageKey(value: string | undefined): string | undefined {
1163
1292
  return key || undefined;
1164
1293
  }
1165
1294
 
1166
- function formatDualLimitBar(
1295
+ function formatAdaptiveLimitBar(
1167
1296
  primary: NormalizedRateLimitWindow | undefined,
1168
1297
  secondary: NormalizedRateLimitWindow | undefined,
1298
+ ): string {
1299
+ if (!primary || !secondary) return formatSingleLimitBar(primary ?? secondary);
1300
+ return formatDualLimitBar(primary, secondary);
1301
+ }
1302
+
1303
+ function formatSingleLimitBar(
1304
+ window: NormalizedRateLimitWindow | undefined,
1305
+ ): string {
1306
+ const filled = filledParts(window, DUAL_BAR_WIDTH * 4);
1307
+ const partMasks = [1, 4, 2, 8];
1308
+ let value = "";
1309
+ for (let index = 0; index < DUAL_BAR_WIDTH; index++) {
1310
+ const cellParts = Math.min(4, Math.max(0, filled - index * 4));
1311
+ let mask = 0;
1312
+ for (let part = 0; part < cellParts; part++) mask |= partMasks[part] ?? 0;
1313
+ value += DUAL_BAR_CHARS[mask];
1314
+ }
1315
+ return value;
1316
+ }
1317
+
1318
+ function formatDualLimitBar(
1319
+ primary: NormalizedRateLimitWindow,
1320
+ secondary: NormalizedRateLimitWindow,
1169
1321
  ): string {
1170
1322
  const primaryParts = filledTwentieths(primary);
1171
1323
  const secondaryParts = filledTwentieths(secondary);
@@ -1227,6 +1379,26 @@ export function canReuseCachedReport(
1227
1379
  return selectActiveUsageSnapshot(report, model) !== undefined;
1228
1380
  }
1229
1381
 
1382
+ export function isFullyAvailableReport(
1383
+ report: CodexUsageReport,
1384
+ model?: CodexUsageModel,
1385
+ ): boolean {
1386
+ const snapshot = selectActiveUsageSnapshot(report, model);
1387
+ const windows = [snapshot?.primary, snapshot?.secondary].filter(
1388
+ (window): window is NormalizedRateLimitWindow => window !== undefined,
1389
+ );
1390
+ return (
1391
+ windows.length > 0 &&
1392
+ windows.every((window) => clampPercent(window.usedPercent) === 0)
1393
+ );
1394
+ }
1395
+
1396
+ function weeklyWindow(
1397
+ snapshot: NormalizedRateLimitSnapshot | undefined,
1398
+ ): NormalizedRateLimitWindow | undefined {
1399
+ return snapshot?.secondary ?? snapshot?.primary;
1400
+ }
1401
+
1230
1402
  function selectActiveUsageSnapshot(
1231
1403
  report: CodexUsageReport,
1232
1404
  model: CodexUsageModel | undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
6
6
  "keywords": [