@llblab/pi-codex-usage 0.6.0 → 0.8.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/BACKLOG.md CHANGED
@@ -1,4 +1,3 @@
1
1
  # Backlog
2
2
 
3
- - [ ] `Runtime smoke test` Verify the extension inside a live Pi session against missing auth, missing subscription, and transient network failure. Usable subscription with weekly reset countdown has been checked against real Codex auth.
4
- - [ ] `Statusline polish` Tune the successful-refresh blink duration if the current 150ms redraw feels too visible or too subtle in the Pi footer.
3
+ No open work.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0: Spark Model Usage Status
6
+
7
+ - 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.
8
+ - Added parsing for backend `additional_rate_limits`, including the Spark `GPT-5.3-Codex-Spark` / `codex_bengalfox` limit. Impact: Spark quota can be shown from the same Pi-auth usage response path as normal Codex quota instead of relying only on app-server fallback.
9
+
10
+ ## 0.7.0: Exhausted Quota Visibility
11
+
12
+ - Added exhausted-quota warning background for the statusline quota bar. Impact: when either the 5-hour or weekly Codex window has 0% remaining, the bar keeps its shape but switches from the selected background to the error background for faster visual detection.
13
+ - Added exhausted-primary countdown display for cases where the 5-hour Codex window has 0% remaining and exposes a reset time. Impact: the statusline keeps the normal ten-cell dual bar and appends `<5-hour-reset>/<weekly-reset>` countdowns, so operators can see both how long to wait for short-window recovery and how much weekly quota remains.
14
+ - Fixed quota-bar rounding so a truly empty segment state is shown only when a quota window has 0% remaining, while any positive sub-5% remainder still renders as one visible step. Impact: the statusline now distinguishes exhausted Codex limits from tiny remaining quota without changing normal round-to-nearest behavior elsewhere.
15
+
5
16
  ## 0.6.0: Telegram Status Integration
6
17
 
7
18
  - Added optional `pi-telegram` status-menu integration through the public Telegram status-line provider API. When `pi-telegram` is available and the active model is an OpenAI Codex subscription model, the `/start` menu status text now includes `codex: <value>` using the same compact quota bar and reset countdown value as the terminal statusline. Impact: Telegram operators can see Codex quota/reset state in the main control menu without any extra configuration, while non-Codex models and missing `pi-telegram` installs stay unchanged.
package/README.md CHANGED
@@ -15,9 +15,11 @@ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-co
15
15
  ## Features
16
16
 
17
17
  - Shows an empty statusline bar immediately, then refreshes every 30 seconds while the active Pi model uses `openai-codex`
18
- - Statusline output stays compact, with the `codex` label accented and the quota bar plus weekly reset countdown drawn on a themed background
19
- - When `pi-telegram` is available, the same compact value appears as `codex: <value>` in the `/start` menu status text for active OpenAI Codex subscription models
20
- - Additional returned buckets, including Spark-specific limits, are ignored
18
+ - Statusline output stays compact, with the active usage label accented and the quota bar plus weekly reset countdown drawn on a themed background
19
+ - Regular Codex subscription models show the primary `codex` quota bucket
20
+ - `GPT-5.3-Codex-Spark` shows the parallel Spark quota bucket with the `spark` label
21
+ - When `pi-telegram` is available, the same compact value appears as `codex: <value>` or `spark: <value>` in the `/start` menu status text for active OpenAI Codex subscription models
22
+ - Additional returned buckets unrelated to the active Codex/Spark model are ignored
21
23
  - Pi OpenAI Codex provider auth is used first
22
24
  - Codex CLI app-server remains available as a fallback
23
25
  - Missing auth, subscription, plan, or quota windows are shown as `n/a`, not as an error
@@ -41,16 +43,30 @@ pi install git:github.com/llblab/pi-codex-usage
41
43
 
42
44
  ## Statusline
43
45
 
44
- Normal usage:
46
+ Regular Codex usage:
45
47
 
46
48
  ```text
47
49
  codex ██████▀▀▀▀ 6d
48
50
  ```
49
51
 
50
- 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.
52
+ Spark model usage:
53
+
54
+ ```text
55
+ spark ██████████ 7d
56
+ ```
57
+
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.
51
59
 
52
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.
53
61
 
62
+ 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
+
64
+ ```text
65
+ codex ▄▄▄▄▄⠀⠀⠀⠀⠀ 5h/7d
66
+ ```
67
+
68
+ The ten-character dual bar stays unchanged. The first countdown is the 5-hour reset, and the second countdown after `/` is the weekly reset.
69
+
54
70
  Unavailable because Codex auth or subscription quota is not available:
55
71
 
56
72
  ```text
@@ -69,9 +85,10 @@ If `@llblab/pi-telegram` is loaded with the public status-line provider API, thi
69
85
 
70
86
  ```text
71
87
  codex: ██████▀▀▀▀ 6d
88
+ spark: ██████████ 7d
72
89
  ```
73
90
 
74
- The value is the same compact quota bar plus weekly reset countdown used by the terminal statusline. If `pi-telegram` is absent, older, or the active model is not a Codex subscription model, no Telegram row is added.
91
+ The value is the same compact quota bar plus weekly reset countdown used by the terminal statusline, and the label follows the active Codex/Spark model. If `pi-telegram` is absent, older, or the active model is not a Codex subscription model, no Telegram row is added.
75
92
 
76
93
  ## Auth
77
94
 
package/index.ts CHANGED
@@ -20,7 +20,11 @@ const REFRESH_INTERVAL_MS = 30 * SECOND_MS;
20
20
  const REDRAW_BLINK_MS = 150;
21
21
  const STATUS_KEY = "aa-codex-usage";
22
22
  const MAX_ERROR_BODY_CHARS = 600;
23
- const STATUS_LABEL_TEXT = "codex";
23
+ const DEFAULT_STATUS_LABEL_TEXT = "codex";
24
+ const SPARK_STATUS_LABEL_TEXT = "spark";
25
+ const CODEX_USAGE_LIMIT_ID = "codex";
26
+ const SPARK_USAGE_LIMIT_ID = "spark";
27
+ const SPARK_MODEL_KEY = "gpt-5.3-codex-spark";
24
28
  const DUAL_BAR_WIDTH = 10;
25
29
  const TELEGRAM_STATUS_IMPORT_SPECIFIERS = [
26
30
  "@llblab/pi-telegram/status",
@@ -49,7 +53,7 @@ type UsageSource = "pi-auth" | "codex-app-server";
49
53
  type TimeoutHandle = ReturnType<typeof setTimeout> & { unref?: () => void };
50
54
  type PiModel = NonNullable<ExtensionContext["model"]>;
51
55
  export type CodexUsageModel = Pick<PiModel, "id" | "name" | "provider">;
52
- type CodexUsageTelegramStatusModel = Pick<PiModel, "provider">;
56
+ type CodexUsageTelegramStatusModel = Pick<PiModel, "id" | "name" | "provider">;
53
57
  type TelegramStatusLineProviderResult =
54
58
  | { label: string; value: string }
55
59
  | undefined;
@@ -99,6 +103,13 @@ export type NormalizedRateLimitWindow = {
99
103
 
100
104
  type RateLimitStatusPayload = {
101
105
  rate_limit?: unknown;
106
+ additional_rate_limits?: unknown;
107
+ };
108
+
109
+ type BackendAdditionalRateLimit = {
110
+ limit_name?: unknown;
111
+ metered_feature?: unknown;
112
+ rate_limit?: unknown;
102
113
  };
103
114
 
104
115
  type BackendRateLimitDetails = {
@@ -167,8 +178,8 @@ export default function codexUsage(pi: ExtensionAPI) {
167
178
  ({ activeModel }) => {
168
179
  if (!isOpenAICodexModel(activeModel)) return undefined;
169
180
  if (!cache) return undefined;
170
- const value = formatCodexUsageStatusValue(cache.report);
171
- return value ? { label: "codex", value } : undefined;
181
+ const value = formatCodexUsageStatusValue(cache.report, activeModel);
182
+ return value ? { label: activeUsageLabel(activeModel), value } : undefined;
172
183
  },
173
184
  )
174
185
  .then((unregister) => {
@@ -219,7 +230,7 @@ export default function codexUsage(pi: ExtensionAPI) {
219
230
  model: CodexUsageModel | undefined,
220
231
  ) => {
221
232
  if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
222
- const delayMs = nextResetCountdownDelayMs(report);
233
+ const delayMs = nextResetCountdownDelayMs(report, Date.now(), model);
223
234
  if (delayMs === undefined) {
224
235
  statuslineCountdownTimer = undefined;
225
236
  return;
@@ -268,11 +279,16 @@ export default function codexUsage(pi: ExtensionAPI) {
268
279
  else scheduleTemporaryStatuslineClear(ctx);
269
280
  };
270
281
 
271
- const queryCurrentUsage = (ctx: ExtensionContext) => {
282
+ const queryCurrentUsage = (
283
+ ctx: ExtensionContext,
284
+ model: CodexUsageModel | undefined,
285
+ ) => {
272
286
  if (!inFlightUsageQuery) {
273
- inFlightUsageQuery = queryUsage(ctx, {
274
- timeoutMs: DEFAULT_TIMEOUT_MS,
275
- }).finally(() => {
287
+ inFlightUsageQuery = queryUsage(
288
+ ctx,
289
+ { timeoutMs: DEFAULT_TIMEOUT_MS },
290
+ model,
291
+ ).finally(() => {
276
292
  inFlightUsageQuery = undefined;
277
293
  });
278
294
  }
@@ -305,7 +321,7 @@ export default function codexUsage(pi: ExtensionAPI) {
305
321
  return;
306
322
  }
307
323
 
308
- const result = await queryCurrentUsage(ctx);
324
+ const result = await queryCurrentUsage(ctx, model);
309
325
  if (requestId !== statuslineRequestId) return;
310
326
  if (!isOpenAICodexModel(ctx.model)) {
311
327
  clearUsageStatusline(ctx);
@@ -373,6 +389,26 @@ function isOpenAICodexModel(
373
389
  return model?.provider === CODEX_PROVIDER_ID;
374
390
  }
375
391
 
392
+ function isSparkCodexModel(
393
+ model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
394
+ ): boolean {
395
+ if (!isOpenAICodexModel(model)) return false;
396
+ const key = `${model?.id ?? ""} ${model?.name ?? ""}`.toLowerCase();
397
+ return key.includes(SPARK_MODEL_KEY);
398
+ }
399
+
400
+ function activeUsageLimitId(
401
+ model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
402
+ ): string {
403
+ return isSparkCodexModel(model) ? SPARK_USAGE_LIMIT_ID : CODEX_USAGE_LIMIT_ID;
404
+ }
405
+
406
+ function activeUsageLabel(
407
+ model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
408
+ ): string {
409
+ return isSparkCodexModel(model) ? SPARK_STATUS_LABEL_TEXT : DEFAULT_STATUS_LABEL_TEXT;
410
+ }
411
+
376
412
  async function importTelegramStatusLineModule(): Promise<
377
413
  TelegramStatusLineModule | undefined
378
414
  > {
@@ -401,25 +437,29 @@ async function registerCodexUsageTelegramStatusLine(
401
437
  async function queryUsage(
402
438
  ctx: ExtensionContext,
403
439
  options: Pick<QueryUsageOptions, "timeoutMs">,
440
+ model: CodexUsageModel | undefined,
404
441
  ): Promise<QueryUsageResult> {
405
442
  const errors: UsageQueryError[] = [];
443
+ const sources = isSparkCodexModel(model)
444
+ ? (["codex-app-server", "pi-auth"] as const)
445
+ : (["pi-auth", "codex-app-server"] as const);
406
446
 
407
- try {
408
- const report = await queryViaPiAuth(ctx, options.timeoutMs);
409
- return { ok: true, report };
410
- } catch (cause) {
411
- errors.push({ source: "pi-auth", message: errorMessage(cause), cause });
412
- }
413
-
414
- try {
415
- const report = await queryViaCodexAppServer(options.timeoutMs);
416
- return { ok: true, report };
417
- } catch (cause) {
418
- errors.push({
419
- source: "codex-app-server",
420
- message: errorMessage(cause),
421
- cause,
422
- });
447
+ for (const source of sources) {
448
+ try {
449
+ const report =
450
+ source === "pi-auth"
451
+ ? await queryViaPiAuth(ctx, options.timeoutMs)
452
+ : await queryViaCodexAppServer(options.timeoutMs);
453
+ if (selectUsageSnapshot(report, activeUsageLimitId(model))) {
454
+ return { ok: true, report };
455
+ }
456
+ errors.push({
457
+ source,
458
+ message: `${source} returned no displayable ${activeUsageLabel(model)} rate-limit windows`,
459
+ });
460
+ } catch (cause) {
461
+ errors.push({ source, message: errorMessage(cause), cause });
462
+ }
423
463
  }
424
464
 
425
465
  return { ok: false, errors };
@@ -711,17 +751,42 @@ export function normalizeBackendPayload(
711
751
  _capturedAt: number,
712
752
  _source: UsageSource,
713
753
  ): CodexUsageReport {
714
- const snapshot = normalizeBackendSnapshot(
715
- "codex",
754
+ const snapshots: NormalizedRateLimitSnapshot[] = [];
755
+ const primarySnapshot = normalizeBackendSnapshot(
756
+ CODEX_USAGE_LIMIT_ID,
716
757
  payload.rate_limit,
717
758
  _capturedAt,
718
759
  );
719
- if (!snapshot) {
760
+ if (primarySnapshot) snapshots.push(primarySnapshot);
761
+
762
+ if (Array.isArray(payload.additional_rate_limits)) {
763
+ for (const item of payload.additional_rate_limits) {
764
+ const additional = assertObject(
765
+ item,
766
+ "additional rate limit",
767
+ ) as BackendAdditionalRateLimit;
768
+ const snapshot = normalizeBackendSnapshot(
769
+ backendAdditionalLimitId(additional),
770
+ additional.rate_limit,
771
+ _capturedAt,
772
+ );
773
+ if (snapshot) snapshots.push(snapshot);
774
+ }
775
+ }
776
+
777
+ if (snapshots.length === 0) {
720
778
  throw new Error(
721
779
  "Codex usage endpoint returned no displayable rate-limit windows.",
722
780
  );
723
781
  }
724
- return { snapshots: [snapshot] };
782
+ return { snapshots };
783
+ }
784
+
785
+ function backendAdditionalLimitId(limit: BackendAdditionalRateLimit): string {
786
+ const raw = `${asString(limit.limit_name) ?? ""} ${asString(limit.metered_feature) ?? ""}`;
787
+ return raw.toLowerCase().includes("spark")
788
+ ? SPARK_USAGE_LIMIT_ID
789
+ : normalizedUsageKey(raw) ?? CODEX_USAGE_LIMIT_ID;
725
790
  }
726
791
 
727
792
  function normalizeBackendSnapshot(
@@ -859,12 +924,17 @@ function mergeSnapshot(
859
924
  export function formatCodexUsageStatusline(
860
925
  report: CodexUsageReport,
861
926
  ctx: ExtensionContext,
862
- _model?: CodexUsageModel,
927
+ model?: CodexUsageModel,
863
928
  ): string {
864
- const value = formatCodexUsageStatusValue(report);
865
- if (!value) return formatStatuslineText(ctx, "n/a");
929
+ const value = formatCodexUsageStatusValue(report, model);
930
+ if (!value) return formatStatuslineText(ctx, "n/a", model);
866
931
  const [bar, countdown] = value.split(" ", 2);
867
- const barText = formatStatuslineBarText(ctx, bar ?? "");
932
+ const barText = formatStatuslineBarText(
933
+ ctx,
934
+ bar ?? "",
935
+ hasExhaustedQuotaWindow(report, model) ? "toolErrorBg" : "selectedBg",
936
+ model,
937
+ );
868
938
  return countdown
869
939
  ? `${barText} ${ctx.ui.theme.fg("dim", countdown)}`
870
940
  : barText;
@@ -878,21 +948,40 @@ export function formatCodexUsageBar(
878
948
 
879
949
  export function formatCodexUsageStatusValue(
880
950
  report: CodexUsageReport,
951
+ modelOrNow?: CodexUsageModel | number,
881
952
  now = Date.now(),
882
953
  ): string | undefined {
883
- const bar = formatReportBar(report);
884
- if (!bar) return undefined;
885
- const countdown = formatWeeklyResetCountdown(report, now);
954
+ const model = typeof modelOrNow === "number" ? undefined : modelOrNow;
955
+ const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
956
+ const snapshot = selectActiveUsageSnapshot(report, model);
957
+ if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
958
+ const bar = formatDualLimitBar(snapshot.primary, snapshot.secondary);
959
+ if (isQuotaWindowExhausted(snapshot.primary) && snapshot.primary?.resetAt) {
960
+ const primaryCountdown = formatResetCountdown(
961
+ snapshot.primary.resetAt,
962
+ capturedNow,
963
+ );
964
+ const weeklyCountdown = snapshot.secondary?.resetAt
965
+ ? formatResetCountdown(snapshot.secondary.resetAt, capturedNow)
966
+ : undefined;
967
+ return weeklyCountdown
968
+ ? `${bar} ${primaryCountdown}/${weeklyCountdown}`
969
+ : `${bar} ${primaryCountdown}`;
970
+ }
971
+ const countdown = formatWeeklyResetCountdown(report, model, capturedNow);
886
972
  return countdown ? `${bar} ${countdown}` : bar;
887
973
  }
888
974
 
889
975
  export function formatWeeklyResetCountdown(
890
976
  report: CodexUsageReport,
977
+ modelOrNow?: CodexUsageModel | number,
891
978
  now = Date.now(),
892
979
  ): string | undefined {
893
- const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
980
+ const model = typeof modelOrNow === "number" ? undefined : modelOrNow;
981
+ const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
982
+ const resetAt = selectActiveUsageSnapshot(report, model)?.secondary?.resetAt;
894
983
  if (resetAt === undefined) return undefined;
895
- return formatResetCountdown(resetAt, now);
984
+ return formatResetCountdown(resetAt, capturedNow);
896
985
  }
897
986
 
898
987
  export function formatResetCountdown(
@@ -916,10 +1005,18 @@ export function formatResetCountdown(
916
1005
  export function nextResetCountdownDelayMs(
917
1006
  report: CodexUsageReport,
918
1007
  now = Date.now(),
1008
+ model?: CodexUsageModel,
919
1009
  ): number | undefined {
920
- const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
921
- if (resetAt === undefined) return undefined;
922
- return nextResetCountdownDelayForRemainingMs(resetAt - now);
1010
+ const snapshot = selectActiveUsageSnapshot(report, model);
1011
+ const resetTimes = [snapshot?.secondary?.resetAt];
1012
+ if (isQuotaWindowExhausted(snapshot?.primary)) {
1013
+ resetTimes.push(snapshot?.primary?.resetAt);
1014
+ }
1015
+ const delays = resetTimes
1016
+ .filter((resetAt): resetAt is number => resetAt !== undefined)
1017
+ .map((resetAt) => nextResetCountdownDelayForRemainingMs(resetAt - now))
1018
+ .filter((delay): delay is number => delay !== undefined);
1019
+ return delays.length > 0 ? Math.min(...delays) : undefined;
923
1020
  }
924
1021
 
925
1022
  export function nextResetCountdownDelayForRemainingMs(
@@ -951,20 +1048,32 @@ function formatTenths(value: number): string {
951
1048
  return value % 10 === 0 ? String(value / 10) : (value / 10).toFixed(1);
952
1049
  }
953
1050
 
954
- function formatReportBar(report: CodexUsageReport): string | undefined {
955
- const snapshot = selectPrimaryCodexSnapshot(report);
1051
+ function formatReportBar(
1052
+ report: CodexUsageReport,
1053
+ model?: CodexUsageModel,
1054
+ ): string | undefined {
1055
+ const snapshot = selectActiveUsageSnapshot(report, model);
956
1056
  if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
957
1057
  return formatDualLimitBar(snapshot.primary, snapshot.secondary);
958
1058
  }
959
1059
 
960
- function formatStatuslineText(ctx: ExtensionContext, value: string): string {
961
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
1060
+ function formatStatuslineText(
1061
+ ctx: ExtensionContext,
1062
+ value: string,
1063
+ model?: CodexUsageModel,
1064
+ ): string {
1065
+ const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
962
1066
  return `${label} ${ctx.ui.theme.fg("dim", value)}`;
963
1067
  }
964
1068
 
965
- function formatStatuslineBarText(ctx: ExtensionContext, bar: string): string {
966
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
967
- const value = ctx.ui.theme.bg("selectedBg", ctx.ui.theme.fg("dim", bar));
1069
+ function formatStatuslineBarText(
1070
+ ctx: ExtensionContext,
1071
+ bar: string,
1072
+ background: "selectedBg" | "toolErrorBg" = "selectedBg",
1073
+ model?: CodexUsageModel,
1074
+ ): string {
1075
+ const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
1076
+ const value = ctx.ui.theme.bg(background, ctx.ui.theme.fg("dim", bar));
968
1077
  return `${label} ${value}`;
969
1078
  }
970
1079
 
@@ -976,7 +1085,7 @@ function formatStatuslineProblem(
976
1085
  ctx: ExtensionContext,
977
1086
  errors: UsageQueryError[],
978
1087
  ): string {
979
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
1088
+ const label = ctx.ui.theme.fg("accent", DEFAULT_STATUS_LABEL_TEXT);
980
1089
  const value = isUsageUnavailable(errors)
981
1090
  ? ctx.ui.theme.fg("muted", "n/a")
982
1091
  : ctx.ui.theme.fg("error", "error");
@@ -1005,12 +1114,6 @@ function isUnavailableError(error: UsageQueryError): boolean {
1005
1114
  );
1006
1115
  }
1007
1116
 
1008
- function selectPrimaryCodexSnapshot(
1009
- report: CodexUsageReport,
1010
- ): NormalizedRateLimitSnapshot | undefined {
1011
- return report.snapshots.find(isPrimaryCodexSnapshot);
1012
- }
1013
-
1014
1117
  function normalizedUsageKey(value: string | undefined): string | undefined {
1015
1118
  const key = value
1016
1119
  ?.toLowerCase()
@@ -1041,19 +1144,56 @@ function formatDualLimitBar(
1041
1144
 
1042
1145
  function filledTwentieths(
1043
1146
  window: NormalizedRateLimitWindow | undefined,
1147
+ ): number {
1148
+ return filledParts(window, 20);
1149
+ }
1150
+
1151
+ function filledParts(
1152
+ window: NormalizedRateLimitWindow | undefined,
1153
+ totalParts: number,
1044
1154
  ): number {
1045
1155
  if (!window) return 0;
1046
- return Math.round(remainingPercent(window) / 5);
1156
+ const remaining = remainingPercent(window);
1157
+ if (remaining <= 0) return 0;
1158
+ return Math.max(1, Math.round(remaining / (100 / totalParts)));
1047
1159
  }
1048
1160
 
1049
1161
  function remainingPercent(window: NormalizedRateLimitWindow): number {
1050
1162
  return 100 - clampPercent(window.usedPercent);
1051
1163
  }
1052
1164
 
1053
- function isPrimaryCodexSnapshot(
1054
- snapshot: NormalizedRateLimitSnapshot,
1165
+ function isQuotaWindowExhausted(
1166
+ window: NormalizedRateLimitWindow | undefined,
1167
+ ): boolean {
1168
+ return window !== undefined && remainingPercent(window) <= 0;
1169
+ }
1170
+
1171
+ function hasExhaustedQuotaWindow(
1172
+ report: CodexUsageReport,
1173
+ model?: CodexUsageModel,
1055
1174
  ): boolean {
1056
- return normalizedUsageKey(snapshot.limitId) === "codex";
1175
+ const snapshot = selectActiveUsageSnapshot(report, model);
1176
+ return (
1177
+ isQuotaWindowExhausted(snapshot?.primary) ||
1178
+ isQuotaWindowExhausted(snapshot?.secondary)
1179
+ );
1180
+ }
1181
+
1182
+ function selectActiveUsageSnapshot(
1183
+ report: CodexUsageReport,
1184
+ model: CodexUsageModel | undefined,
1185
+ ): NormalizedRateLimitSnapshot | undefined {
1186
+ return selectUsageSnapshot(report, activeUsageLimitId(model));
1187
+ }
1188
+
1189
+ function selectUsageSnapshot(
1190
+ report: CodexUsageReport,
1191
+ limitId: string,
1192
+ ): NormalizedRateLimitSnapshot | undefined {
1193
+ const normalizedLimitId = normalizedUsageKey(limitId);
1194
+ return report.snapshots.find(
1195
+ (snapshot) => normalizedUsageKey(snapshot.limitId) === normalizedLimitId,
1196
+ );
1057
1197
  }
1058
1198
 
1059
1199
  function clampPercent(value: number): number {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
6
6
  "keywords": [