@llblab/pi-codex-usage 0.8.0 → 0.8.2

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/BACKLOG.md CHANGED
@@ -1,3 +1,8 @@
1
1
  # Backlog
2
2
 
3
- No open work.
3
+ ## Pending Release Verification
4
+
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
+ - [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
+ - [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,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.2: Quota Loading Hotfix
6
+
7
+ - 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.
8
+ - 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.
9
+
10
+ ## 0.8.1: Stale Context Hotfix
11
+
12
+ - 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.
13
+ - Fixed Codex/Spark refresh state to key in-flight usage lookups by active quota bucket and only reuse cached reports that contain the active bucket. Impact: switching between regular Codex and Spark no longer temporarily renders the active bucket as unavailable because of a stale request/cache.
14
+ - Fixed Spark failure labels. Impact: Spark unavailable/error states now render with the `spark` label instead of falling back to `codex`.
15
+ - Added regression coverage for active-bucket cache reuse and stale extension context error detection.
16
+
5
17
  ## 0.8.0: Spark Model Usage Status
6
18
 
7
19
  - Added active-bucket status support for `GPT-5.3-Codex-Spark`. Impact: selecting the Spark Codex subscription model now shows the parallel Spark rate-limit bucket with a `spark` label, while regular Codex models keep the existing `codex` bucket/status behavior.
package/README.md CHANGED
@@ -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
@@ -57,6 +58,8 @@ spark ██████████ 7d
57
58
 
58
59
  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
60
 
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
+
60
63
  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
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:
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;
@@ -163,12 +165,18 @@ type PendingRpc = {
163
165
  export default function codexUsage(pi: ExtensionAPI) {
164
166
  let cache: CachedReport | undefined;
165
167
  let failedRefreshes = 0;
166
- let inFlightUsageQuery: Promise<QueryUsageResult> | undefined;
168
+ let inFlightUsageQuery:
169
+ { limitId: string; promise: Promise<QueryUsageResult> } | undefined;
167
170
  let statuslineBlinkTimer: TimeoutHandle | undefined;
168
171
  let statuslineClearTimer: TimeoutHandle | undefined;
169
172
  let statuslineCountdownTimer: TimeoutHandle | undefined;
173
+ let statuslineLoadingTimer: TimeoutHandle | undefined;
170
174
  let statuslineRefreshTimer: TimeoutHandle | undefined;
175
+ let statuslineLoadingFrame = 0;
176
+ let statuslineLoadingLimitId: string | undefined;
171
177
  let statuslineRequestId = 0;
178
+ let provisionalFullReport:
179
+ { limitId: string; firstSeenAt: number } | undefined;
172
180
  let unregisterTelegramStatusLine: (() => void) | undefined;
173
181
  let telegramStatusLineRegistration: Promise<void> | undefined;
174
182
 
@@ -179,7 +187,9 @@ export default function codexUsage(pi: ExtensionAPI) {
179
187
  if (!isOpenAICodexModel(activeModel)) return undefined;
180
188
  if (!cache) return undefined;
181
189
  const value = formatCodexUsageStatusValue(cache.report, activeModel);
182
- return value ? { label: activeUsageLabel(activeModel), value } : undefined;
190
+ return value
191
+ ? { label: activeUsageLabel(activeModel), value }
192
+ : undefined;
183
193
  },
184
194
  )
185
195
  .then((unregister) => {
@@ -194,13 +204,51 @@ export default function codexUsage(pi: ExtensionAPI) {
194
204
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
195
205
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
196
206
  if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
207
+ if (statuslineLoadingTimer) clearTimeout(statuslineLoadingTimer);
197
208
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
198
209
  statuslineBlinkTimer = undefined;
199
210
  statuslineClearTimer = undefined;
200
211
  statuslineCountdownTimer = undefined;
212
+ statuslineLoadingTimer = undefined;
213
+ statuslineLoadingLimitId = undefined;
201
214
  statuslineRefreshTimer = undefined;
202
215
  };
203
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
+
204
252
  const clearUsageStatusline = (ctx: ExtensionContext) => {
205
253
  statuslineRequestId += 1;
206
254
  clearStatuslineTimers();
@@ -210,17 +258,26 @@ export default function codexUsage(pi: ExtensionAPI) {
210
258
  const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
211
259
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
212
260
  statuslineClearTimer = setTimeout(() => {
213
- ctx.ui.setStatus(STATUS_KEY, undefined);
214
- statuslineClearTimer = undefined;
261
+ try {
262
+ ctx.ui.setStatus(STATUS_KEY, undefined);
263
+ statuslineClearTimer = undefined;
264
+ } catch (error) {
265
+ handleTimerError(error);
266
+ }
215
267
  }, REFRESH_INTERVAL_MS) as TimeoutHandle;
216
268
  statuslineClearTimer.unref?.();
217
269
  };
218
270
 
219
- const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
271
+ const scheduleStatuslineRefresh = (
272
+ ctx: ExtensionContext,
273
+ delayMs = REFRESH_INTERVAL_MS,
274
+ ) => {
220
275
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
221
276
  statuslineRefreshTimer = setTimeout(() => {
222
- void refreshCurrentCodexUsageStatusline(ctx, true);
223
- }, REFRESH_INTERVAL_MS) as TimeoutHandle;
277
+ void refreshCurrentCodexUsageStatusline(ctx, true).catch(
278
+ handleAsyncTimerError,
279
+ );
280
+ }, delayMs) as TimeoutHandle;
224
281
  statuslineRefreshTimer.unref?.();
225
282
  };
226
283
 
@@ -236,12 +293,16 @@ export default function codexUsage(pi: ExtensionAPI) {
236
293
  return;
237
294
  }
238
295
  statuslineCountdownTimer = setTimeout(() => {
239
- if (isOpenAICodexModel(ctx.model)) {
240
- ctx.ui.setStatus(
241
- STATUS_KEY,
242
- formatCodexUsageStatusline(report, ctx, model),
243
- );
244
- scheduleStatuslineCountdown(ctx, report, model);
296
+ try {
297
+ if (isOpenAICodexModel(ctx.model)) {
298
+ ctx.ui.setStatus(
299
+ STATUS_KEY,
300
+ formatCodexUsageStatusline(report, ctx, model),
301
+ );
302
+ scheduleStatuslineCountdown(ctx, report, model);
303
+ }
304
+ } catch (error) {
305
+ handleTimerError(error);
245
306
  }
246
307
  }, delayMs) as TimeoutHandle;
247
308
  statuslineCountdownTimer.unref?.();
@@ -259,16 +320,24 @@ export default function codexUsage(pi: ExtensionAPI) {
259
320
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
260
321
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
261
322
  if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
323
+ stopStatuslineLoading();
262
324
  statuslineBlinkTimer = undefined;
263
325
  statuslineClearTimer = undefined;
264
326
  statuslineCountdownTimer = undefined;
265
327
  const text = formatCodexUsageStatusline(report, ctx, options.model);
266
328
  if (options.blink) {
267
- ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
329
+ ctx.ui.setStatus(
330
+ STATUS_KEY,
331
+ formatStatuslineLoading(ctx, statuslineLoadingFrame, options.model),
332
+ );
268
333
  statuslineBlinkTimer = setTimeout(() => {
269
- ctx.ui.setStatus(STATUS_KEY, text);
270
- scheduleStatuslineCountdown(ctx, report, options.model);
271
- statuslineBlinkTimer = undefined;
334
+ try {
335
+ ctx.ui.setStatus(STATUS_KEY, text);
336
+ scheduleStatuslineCountdown(ctx, report, options.model);
337
+ statuslineBlinkTimer = undefined;
338
+ } catch (error) {
339
+ handleTimerError(error);
340
+ }
272
341
  }, REDRAW_BLINK_MS) as TimeoutHandle;
273
342
  statuslineBlinkTimer.unref?.();
274
343
  } else {
@@ -283,74 +352,127 @@ export default function codexUsage(pi: ExtensionAPI) {
283
352
  ctx: ExtensionContext,
284
353
  model: CodexUsageModel | undefined,
285
354
  ) => {
286
- if (!inFlightUsageQuery) {
287
- inFlightUsageQuery = queryUsage(
288
- ctx,
289
- { timeoutMs: DEFAULT_TIMEOUT_MS },
290
- model,
291
- ).finally(() => {
355
+ const limitId = activeUsageLimitId(model);
356
+ if (inFlightUsageQuery?.limitId === limitId)
357
+ return inFlightUsageQuery.promise;
358
+ const promise = queryUsage(
359
+ ctx,
360
+ { timeoutMs: DEFAULT_TIMEOUT_MS },
361
+ model,
362
+ ).finally(() => {
363
+ if (inFlightUsageQuery?.promise === promise)
292
364
  inFlightUsageQuery = undefined;
293
- });
294
- }
295
- return inFlightUsageQuery;
365
+ });
366
+ inFlightUsageQuery = { limitId, promise };
367
+ return promise;
296
368
  };
297
369
 
298
370
  const refreshCurrentCodexUsageStatusline = async (
299
371
  ctx: ExtensionContext,
300
372
  force: boolean,
301
- model = ctx.model,
373
+ model?: CodexUsageModel,
302
374
  ) => {
303
- if (!isOpenAICodexModel(model)) {
304
- clearUsageStatusline(ctx);
305
- return;
306
- }
375
+ try {
376
+ const activeModel = model ?? ctx.model;
377
+ if (!isOpenAICodexModel(activeModel)) {
378
+ clearUsageStatusline(ctx);
379
+ return;
380
+ }
307
381
 
308
- if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
309
- const requestId = statuslineRequestId + 1;
310
- statuslineRequestId = requestId;
311
- const cached =
312
- cache && Date.now() - cache.createdAt < REFRESH_INTERVAL_MS
313
- ? cache
314
- : undefined;
315
- if (cached && !force) {
316
- setUsageStatusline(ctx, cached.report, {
317
- autoRefresh: true,
318
- blink: false,
319
- model,
320
- });
321
- return;
322
- }
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
+ }
395
+ const requestId = statuslineRequestId + 1;
396
+ statuslineRequestId = requestId;
397
+ const freshCache =
398
+ usableCache && Date.now() - usableCache.createdAt < REFRESH_INTERVAL_MS
399
+ ? usableCache
400
+ : undefined;
401
+ if (freshCache && !force) {
402
+ setUsageStatusline(ctx, freshCache.report, {
403
+ autoRefresh: true,
404
+ blink: false,
405
+ model: activeModel,
406
+ });
407
+ return;
408
+ }
323
409
 
324
- const result = await queryCurrentUsage(ctx, model);
325
- if (requestId !== statuslineRequestId) return;
326
- if (!isOpenAICodexModel(ctx.model)) {
327
- clearUsageStatusline(ctx);
328
- return;
329
- }
410
+ const result = await queryCurrentUsage(ctx, activeModel);
411
+ if (requestId !== statuslineRequestId) return;
412
+ if (!isOpenAICodexModel(ctx.model)) {
413
+ clearUsageStatusline(ctx);
414
+ return;
415
+ }
330
416
 
331
- if (!result.ok) {
332
- failedRefreshes += 1;
333
- if (!cache || failedRefreshes >= 5) {
334
- ctx.ui.setStatus(
335
- STATUS_KEY,
336
- formatStatuslineProblem(ctx, result.errors),
337
- );
417
+ if (!result.ok) {
418
+ failedRefreshes += 1;
419
+ const activeCache =
420
+ cache && canReuseCachedReport(cache.report, activeModel)
421
+ ? cache
422
+ : undefined;
423
+ if (!activeCache || failedRefreshes >= 5) {
424
+ stopStatuslineLoading();
425
+ ctx.ui.setStatus(
426
+ STATUS_KEY,
427
+ formatStatuslineProblem(ctx, result.errors, activeModel),
428
+ );
429
+ }
430
+ scheduleStatuslineRefresh(ctx);
431
+ return;
338
432
  }
339
- scheduleStatuslineRefresh(ctx);
340
- return;
341
- }
342
433
 
343
- const previousReport = cache?.report;
344
- const blink = previousReport
345
- ? formatReportBar(previousReport) !== formatReportBar(result.report)
346
- : false;
347
- failedRefreshes = 0;
348
- cache = { createdAt: Date.now(), report: result.report };
349
- setUsageStatusline(ctx, result.report, {
350
- autoRefresh: true,
351
- blink,
352
- model,
353
- });
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
+ }
457
+ const blink = previousReport
458
+ ? formatReportBar(previousReport, activeModel) !==
459
+ formatReportBar(result.report, activeModel)
460
+ : false;
461
+ failedRefreshes = 0;
462
+ provisionalFullReport = undefined;
463
+ cache = { createdAt: Date.now(), report: result.report };
464
+ setUsageStatusline(ctx, result.report, {
465
+ autoRefresh: true,
466
+ blink,
467
+ model: activeModel,
468
+ });
469
+ } catch (error) {
470
+ if (isStaleExtensionContextError(error)) {
471
+ clearStatuslineTimers();
472
+ return;
473
+ }
474
+ throw error;
475
+ }
354
476
  };
355
477
 
356
478
  ensureTelegramStatusLineRegistered();
@@ -358,19 +480,25 @@ export default function codexUsage(pi: ExtensionAPI) {
358
480
  pi.on("session_start", (_event, ctx) => {
359
481
  ensureTelegramStatusLineRegistered();
360
482
  if (isOpenAICodexModel(ctx.model))
361
- void refreshCurrentCodexUsageStatusline(ctx, false);
483
+ void refreshCurrentCodexUsageStatusline(ctx, false).catch(
484
+ handleAsyncTimerError,
485
+ );
362
486
  else clearUsageStatusline(ctx);
363
487
  });
364
488
 
365
489
  pi.on("session_tree", (_event, ctx) => {
366
490
  if (isOpenAICodexModel(ctx.model))
367
- void refreshCurrentCodexUsageStatusline(ctx, false);
491
+ void refreshCurrentCodexUsageStatusline(ctx, false).catch(
492
+ handleAsyncTimerError,
493
+ );
368
494
  else clearUsageStatusline(ctx);
369
495
  });
370
496
 
371
497
  pi.on("model_select", (event, ctx) => {
372
498
  if (isOpenAICodexModel(event.model)) {
373
- void refreshCurrentCodexUsageStatusline(ctx, false, event.model);
499
+ void refreshCurrentCodexUsageStatusline(ctx, false, event.model).catch(
500
+ handleAsyncTimerError,
501
+ );
374
502
  } else {
375
503
  clearUsageStatusline(ctx);
376
504
  }
@@ -383,6 +511,19 @@ export default function codexUsage(pi: ExtensionAPI) {
383
511
  });
384
512
  }
385
513
 
514
+ function handleAsyncTimerError(error: unknown): void {
515
+ handleTimerError(error);
516
+ }
517
+
518
+ function handleTimerError(error: unknown): void {
519
+ if (isStaleExtensionContextError(error)) return;
520
+ throw error;
521
+ }
522
+
523
+ export function isStaleExtensionContextError(error: unknown): boolean {
524
+ return error instanceof Error && error.message.includes("ctx is stale");
525
+ }
526
+
386
527
  function isOpenAICodexModel(
387
528
  model: Pick<PiModel, "provider"> | undefined,
388
529
  ): boolean {
@@ -406,7 +547,9 @@ function activeUsageLimitId(
406
547
  function activeUsageLabel(
407
548
  model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
408
549
  ): string {
409
- 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;
410
553
  }
411
554
 
412
555
  async function importTelegramStatusLineModule(): Promise<
@@ -786,7 +929,7 @@ function backendAdditionalLimitId(limit: BackendAdditionalRateLimit): string {
786
929
  const raw = `${asString(limit.limit_name) ?? ""} ${asString(limit.metered_feature) ?? ""}`;
787
930
  return raw.toLowerCase().includes("spark")
788
931
  ? SPARK_USAGE_LIMIT_ID
789
- : normalizedUsageKey(raw) ?? CODEX_USAGE_LIMIT_ID;
932
+ : (normalizedUsageKey(raw) ?? CODEX_USAGE_LIMIT_ID);
790
933
  }
791
934
 
792
935
  function normalizeBackendSnapshot(
@@ -1077,15 +1220,41 @@ function formatStatuslineBarText(
1077
1220
  return `${label} ${value}`;
1078
1221
  }
1079
1222
 
1080
- function formatEmptyStatuslineBar(ctx: ExtensionContext): string {
1081
- return formatStatuslineBarText(ctx, DUAL_BAR_CHARS[0].repeat(DUAL_BAR_WIDTH));
1223
+ export function formatCodexUsageLoadingBar(frame: number): string {
1224
+ const totalParts = DUAL_BAR_WIDTH * 2;
1225
+ const cycleFrames = (totalParts - 1) * 2;
1226
+ const finiteFrame = Number.isFinite(frame) ? frame : 0;
1227
+ const cycleFrame = Math.abs(Math.trunc(finiteFrame)) % cycleFrames;
1228
+ const primaryPart =
1229
+ cycleFrame < totalParts ? cycleFrame : cycleFrames - cycleFrame;
1230
+ const secondaryPart = totalParts - primaryPart - 1;
1231
+ const masks = Array<number>(DUAL_BAR_WIDTH).fill(0);
1232
+ const primaryCell = Math.floor(primaryPart / 2);
1233
+ const secondaryCell = Math.floor(secondaryPart / 2);
1234
+ masks[primaryCell] |= primaryPart % 2 === 0 ? 1 : 2;
1235
+ masks[secondaryCell] |= secondaryPart % 2 === 0 ? 4 : 8;
1236
+ return masks.map((mask) => DUAL_BAR_CHARS[mask]).join("");
1237
+ }
1238
+
1239
+ function formatStatuslineLoading(
1240
+ ctx: ExtensionContext,
1241
+ frame: number,
1242
+ model?: CodexUsageModel,
1243
+ ): string {
1244
+ return formatStatuslineBarText(
1245
+ ctx,
1246
+ formatCodexUsageLoadingBar(frame),
1247
+ "selectedBg",
1248
+ model,
1249
+ );
1082
1250
  }
1083
1251
 
1084
1252
  function formatStatuslineProblem(
1085
1253
  ctx: ExtensionContext,
1086
1254
  errors: UsageQueryError[],
1255
+ model?: CodexUsageModel,
1087
1256
  ): string {
1088
- const label = ctx.ui.theme.fg("accent", DEFAULT_STATUS_LABEL_TEXT);
1257
+ const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
1089
1258
  const value = isUsageUnavailable(errors)
1090
1259
  ? ctx.ui.theme.fg("muted", "n/a")
1091
1260
  : ctx.ui.theme.fg("error", "error");
@@ -1179,6 +1348,26 @@ function hasExhaustedQuotaWindow(
1179
1348
  );
1180
1349
  }
1181
1350
 
1351
+ export function canReuseCachedReport(
1352
+ report: CodexUsageReport,
1353
+ model: CodexUsageModel | undefined,
1354
+ ): boolean {
1355
+ return selectActiveUsageSnapshot(report, model) !== undefined;
1356
+ }
1357
+
1358
+ export function isFullyAvailableReport(
1359
+ report: CodexUsageReport,
1360
+ model?: CodexUsageModel,
1361
+ ): boolean {
1362
+ const snapshot = selectActiveUsageSnapshot(report, model);
1363
+ return (
1364
+ snapshot?.primary !== undefined &&
1365
+ snapshot.secondary !== undefined &&
1366
+ clampPercent(snapshot.primary.usedPercent) === 0 &&
1367
+ clampPercent(snapshot.secondary.usedPercent) === 0
1368
+ );
1369
+ }
1370
+
1182
1371
  function selectActiveUsageSnapshot(
1183
1372
  report: CodexUsageReport,
1184
1373
  model: CodexUsageModel | undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
6
6
  "keywords": [