@llblab/pi-codex-usage 0.8.1 → 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
@@ -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,11 @@
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
+
5
10
  ## 0.8.1: Stale Context Hotfix
6
11
 
7
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.
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;
@@ -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(
@@ -1117,8 +1220,33 @@ function formatStatuslineBarText(
1117
1220
  return `${label} ${value}`;
1118
1221
  }
1119
1222
 
1120
- function formatEmptyStatuslineBar(ctx: ExtensionContext): string {
1121
- 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
+ );
1122
1250
  }
1123
1251
 
1124
1252
  function formatStatuslineProblem(
@@ -1227,6 +1355,19 @@ export function canReuseCachedReport(
1227
1355
  return selectActiveUsageSnapshot(report, model) !== undefined;
1228
1356
  }
1229
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
+
1230
1371
  function selectActiveUsageSnapshot(
1231
1372
  report: CodexUsageReport,
1232
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.1",
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": [