@llblab/pi-codex-usage 0.4.0 → 0.5.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
@@ -11,3 +11,7 @@
11
11
  - `Compact dual bar`: Encode the 5-hour and weekly quota windows in the ten-character separated-sextant bar.
12
12
  - Trigger: Changing statusline formatting.
13
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.
14
+
15
+ - `Weekly reset countdown`: Append the weekly reset countdown only when the secondary window exposes a reset time.
16
+ - Trigger: Changing reset-time normalization or statusline refresh cadence.
17
+ - Action: Keep `d` labels rounded upward in 144-minute day-tenth steps, `h`/`m`/`s` labels floored, and hold `0s` until a successful quota refresh reports the next window.
package/BACKLOG.md CHANGED
@@ -1,4 +1,4 @@
1
1
  # Backlog
2
2
 
3
- - [ ] `Runtime smoke test` Verify the extension inside a live Pi session against real Codex auth states: usable subscription, missing auth, missing subscription, and transient network failure.
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
4
  - [ ] `Statusline polish` Tune the successful-refresh blink duration if the current 150ms redraw feels too visible or too subtle in the Pi footer.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,31 @@
1
1
  # Changelog
2
2
 
3
- - `0.4.0` Expanded the dual statusline bar to ten glyphs with 20 steps per quota window, moved its status key near the start of the footer status order, and draws the bar on the themed selected background. Impact: 5-hour and weekly limits now move in 5% increments for 40 total discrete points while empty cells no longer blend into the terminal background.
4
- - `0.3.5` Refined the compact statusline bar with quadrant glyphs, darker bar coloring, and blink-on-segment-change behavior. Impact: Codex quota changes are easier to notice while routine refreshes stay visually stable.
5
- - `0.3.4` Added package banner metadata and README hero image. Impact: Pi/package listings can show the Codex Usage banner while npm packages include the image asset.
6
- - `Fork baseline` Imported `extensions/pi-codex-usage` from `narumiruna/pi-extensions` as a standalone `@llblab/pi-codex-usage` package. Impact: the extension can be installed and maintained independently.
7
- - `Minimal statusline` Removed command-driven report output and narrowed the extension to a zero-configuration statusline widget. Impact: runtime behavior is automatic while `openai-codex` is active.
8
- - `Primary quota focus` Ignored additional returned buckets such as Spark-specific limits. Impact: the statusline only represents primary Codex 5-hour and weekly quota windows.
9
- - `Dual quota bar` Replaced textual percentages with a fixed-width separated-sextant bar. Impact: both 5-hour and weekly remaining quota are encoded in five statusline characters.
10
- - `Optimistic refresh` Kept the last good bar during refresh and transient failures, with a short successful-redraw blink. Impact: the footer no longer shifts or collapses during polling.
3
+ ## 0.5.0: Weekly Reset Countdown
4
+
5
+ - Added refresh request coalescing so repeated statusline events share one quota lookup instead of spawning parallel provider/fallback requests. Impact: transient failures and busy session-tree updates no longer amplify Codex usage polling work.
6
+ - Hardened quota parsing and failure classification by accepting array-shaped app-server rate limits, treating `n/a` as valid only when every failed source reports an unavailable auth/plan/quota state, and adding `node:test` coverage for normalization, bar formatting, and failure classification. Impact: real fallback/runtime failures are surfaced as `error` while expected unavailable states still show `n/a`.
7
+ - Added a weekly reset countdown after the quota bar when the secondary Codex window exposes a reset timestamp, including 144-minute day-tenth steps, exact boundary redraw scheduling, hour/minute/second bucket formatting, and `0s` holdover until the next successful quota refresh. Impact: the statusline now shows both remaining quota and time until the weekly bucket cycles.
8
+
9
+ ## 0.4.1: Stable Startup Bar
10
+
11
+ - Fixed the initial empty statusline bar to use ten non-trimmed blank glyph cells through the same formatting path as the populated quota bar. Impact: the footer background stays at ten cells during startup before the first quota values arrive.
12
+
13
+ ## 0.4.0
14
+
15
+ - Expanded the dual statusline bar to ten glyphs with 20 steps per quota window, moved its status key near the start of the footer status order, and draws the bar on the themed selected background. Impact: 5-hour and weekly limits now move in 5% increments for 40 total discrete points while empty cells no longer blend into the terminal background.
16
+
17
+ ## 0.3.5
18
+
19
+ - Refined the compact statusline bar with quadrant glyphs, darker bar coloring, and blink-on-segment-change behavior. Impact: Codex quota changes are easier to notice while routine refreshes stay visually stable.
20
+
21
+ ## 0.3.4
22
+
23
+ - Added package banner metadata and README hero image. Impact: Pi/package listings can show the Codex Usage banner while npm packages include the image asset.
24
+
25
+ ## Fork baseline
26
+
27
+ - Imported `extensions/pi-codex-usage` from `narumiruna/pi-extensions` as a standalone `@llblab/pi-codex-usage` package. Impact: the extension can be installed and maintained independently.
28
+ - Removed command-driven report output and narrowed the extension to a zero-configuration statusline widget. Impact: runtime behavior is automatic while `openai-codex` is active.
29
+ - Ignored additional returned buckets such as Spark-specific limits. Impact: the statusline only represents primary Codex 5-hour and weekly quota windows.
30
+ - Replaced textual percentages with a fixed-width separated-sextant bar. Impact: both 5-hour and weekly remaining quota are encoded in five statusline characters.
31
+ - Kept the last good bar during refresh and transient failures, with a short successful-redraw blink. Impact: the footer no longer shifts or collapses during polling.
package/README.md CHANGED
@@ -15,7 +15,7 @@ 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 drawn on a themed background
18
+ - Statusline output stays compact, with the `codex` label accented and the quota bar plus weekly reset countdown drawn on a themed background
19
19
  - Additional returned buckets, including Spark-specific limits, are ignored
20
20
  - Pi OpenAI Codex provider auth is used first
21
21
  - Codex CLI app-server remains available as a fallback
@@ -43,11 +43,13 @@ pi install git:github.com/llblab/pi-codex-usage
43
43
  Normal usage:
44
44
 
45
45
  ```text
46
- codex ██████▀▀▀▀
46
+ codex ██████▀▀▀▀ 6d
47
47
  ```
48
48
 
49
49
  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.
50
50
 
51
+ 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`, `1.1d`, and `1d`, rounded upward to the next tenth. Under a day it switches to floored hours, under an hour 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.
52
+
51
53
  Unavailable because Codex auth or subscription quota is not available:
52
54
 
53
55
  ```text
package/banner.png CHANGED
Binary file
package/index.ts CHANGED
@@ -9,13 +9,19 @@ import type {
9
9
  const CODEX_PROVIDER_ID = "openai-codex";
10
10
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
11
11
  const DEFAULT_TIMEOUT_MS = 15_000;
12
- const REFRESH_INTERVAL_MS = 30 * 1000;
12
+ const SECOND_MS = 1000;
13
+ const MINUTE_MS = 60 * SECOND_MS;
14
+ const HOUR_MS = 60 * MINUTE_MS;
15
+ const DAY_MS = 24 * HOUR_MS;
16
+ const DAY_TENTH_MS = 144 * MINUTE_MS;
17
+ const REFRESH_INTERVAL_MS = 30 * SECOND_MS;
13
18
  const REDRAW_BLINK_MS = 150;
14
19
  const STATUS_KEY = "aa-codex-usage";
15
20
  const MAX_ERROR_BODY_CHARS = 600;
16
21
  const STATUS_LABEL_TEXT = "codex";
22
+ const DUAL_BAR_WIDTH = 10;
17
23
  const DUAL_BAR_CHARS = [
18
- " ",
24
+ "",
19
25
  "▘",
20
26
  "▝",
21
27
  "▀",
@@ -51,7 +57,7 @@ type QueryUsageResult =
51
57
  | { ok: true; report: CodexUsageReport }
52
58
  | { ok: false; errors: UsageQueryError[] };
53
59
 
54
- type UsageQueryError = {
60
+ export type UsageQueryError = {
55
61
  source: UsageSource;
56
62
  message: string;
57
63
  cause?: unknown;
@@ -69,6 +75,7 @@ export type NormalizedRateLimitSnapshot = {
69
75
 
70
76
  export type NormalizedRateLimitWindow = {
71
77
  usedPercent: number;
78
+ resetAt?: number;
72
79
  };
73
80
 
74
81
  type RateLimitStatusPayload = {
@@ -82,6 +89,13 @@ type BackendRateLimitDetails = {
82
89
 
83
90
  type BackendWindowSnapshot = {
84
91
  used_percent?: unknown;
92
+ reset_at?: unknown;
93
+ resets_at?: unknown;
94
+ reset_time?: unknown;
95
+ end_time?: unknown;
96
+ ends_at?: unknown;
97
+ expires_at?: unknown;
98
+ reset_after_seconds?: unknown;
85
99
  };
86
100
 
87
101
  type AppServerRateLimitResponse = {
@@ -96,6 +110,13 @@ type AppServerRateLimitSnapshot = {
96
110
 
97
111
  type AppServerWindowSnapshot = {
98
112
  usedPercent?: unknown;
113
+ resetAt?: unknown;
114
+ resetsAt?: unknown;
115
+ resetTime?: unknown;
116
+ endTime?: unknown;
117
+ endsAt?: unknown;
118
+ expiresAt?: unknown;
119
+ resetAfterSeconds?: unknown;
99
120
  };
100
121
 
101
122
  type RpcResponse = {
@@ -112,17 +133,21 @@ type PendingRpc = {
112
133
  export default function codexUsage(pi: ExtensionAPI) {
113
134
  let cache: CachedReport | undefined;
114
135
  let failedRefreshes = 0;
136
+ let inFlightUsageQuery: Promise<QueryUsageResult> | undefined;
115
137
  let statuslineBlinkTimer: TimeoutHandle | undefined;
116
138
  let statuslineClearTimer: TimeoutHandle | undefined;
139
+ let statuslineCountdownTimer: TimeoutHandle | undefined;
117
140
  let statuslineRefreshTimer: TimeoutHandle | undefined;
118
141
  let statuslineRequestId = 0;
119
142
 
120
143
  const clearStatuslineTimers = () => {
121
144
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
122
145
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
146
+ if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
123
147
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
124
148
  statuslineBlinkTimer = undefined;
125
149
  statuslineClearTimer = undefined;
150
+ statuslineCountdownTimer = undefined;
126
151
  statuslineRefreshTimer = undefined;
127
152
  };
128
153
 
@@ -149,6 +174,29 @@ export default function codexUsage(pi: ExtensionAPI) {
149
174
  statuslineRefreshTimer.unref?.();
150
175
  };
151
176
 
177
+ const scheduleStatuslineCountdown = (
178
+ ctx: ExtensionContext,
179
+ report: CodexUsageReport,
180
+ model: CodexUsageModel | undefined,
181
+ ) => {
182
+ if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
183
+ const delayMs = nextResetCountdownDelayMs(report);
184
+ if (delayMs === undefined) {
185
+ statuslineCountdownTimer = undefined;
186
+ return;
187
+ }
188
+ statuslineCountdownTimer = setTimeout(() => {
189
+ if (isOpenAICodexModel(ctx.model)) {
190
+ ctx.ui.setStatus(
191
+ STATUS_KEY,
192
+ formatCodexUsageStatusline(report, ctx, model),
193
+ );
194
+ scheduleStatuslineCountdown(ctx, report, model);
195
+ }
196
+ }, delayMs) as TimeoutHandle;
197
+ statuslineCountdownTimer.unref?.();
198
+ };
199
+
152
200
  const setUsageStatusline = (
153
201
  ctx: ExtensionContext,
154
202
  report: CodexUsageReport,
@@ -160,23 +208,38 @@ export default function codexUsage(pi: ExtensionAPI) {
160
208
  ) => {
161
209
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
162
210
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
211
+ if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
163
212
  statuslineBlinkTimer = undefined;
164
213
  statuslineClearTimer = undefined;
214
+ statuslineCountdownTimer = undefined;
165
215
  const text = formatCodexUsageStatusline(report, ctx, options.model);
166
216
  if (options.blink) {
167
217
  ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
168
218
  statuslineBlinkTimer = setTimeout(() => {
169
219
  ctx.ui.setStatus(STATUS_KEY, text);
220
+ scheduleStatuslineCountdown(ctx, report, options.model);
170
221
  statuslineBlinkTimer = undefined;
171
222
  }, REDRAW_BLINK_MS) as TimeoutHandle;
172
223
  statuslineBlinkTimer.unref?.();
173
224
  } else {
174
225
  ctx.ui.setStatus(STATUS_KEY, text);
226
+ scheduleStatuslineCountdown(ctx, report, options.model);
175
227
  }
176
228
  if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
177
229
  else scheduleTemporaryStatuslineClear(ctx);
178
230
  };
179
231
 
232
+ const queryCurrentUsage = (ctx: ExtensionContext) => {
233
+ if (!inFlightUsageQuery) {
234
+ inFlightUsageQuery = queryUsage(ctx, {
235
+ timeoutMs: DEFAULT_TIMEOUT_MS,
236
+ }).finally(() => {
237
+ inFlightUsageQuery = undefined;
238
+ });
239
+ }
240
+ return inFlightUsageQuery;
241
+ };
242
+
180
243
  const refreshCurrentCodexUsageStatusline = async (
181
244
  ctx: ExtensionContext,
182
245
  force: boolean,
@@ -203,7 +266,7 @@ export default function codexUsage(pi: ExtensionAPI) {
203
266
  return;
204
267
  }
205
268
 
206
- const result = await queryUsage(ctx, { timeoutMs: DEFAULT_TIMEOUT_MS });
269
+ const result = await queryCurrentUsage(ctx);
207
270
  if (requestId !== statuslineRequestId) return;
208
271
  if (!isOpenAICodexModel(ctx.model)) {
209
272
  clearUsageStatusline(ctx);
@@ -577,7 +640,11 @@ export function normalizeBackendPayload(
577
640
  _capturedAt: number,
578
641
  _source: UsageSource,
579
642
  ): CodexUsageReport {
580
- const snapshot = normalizeBackendSnapshot("codex", payload.rate_limit);
643
+ const snapshot = normalizeBackendSnapshot(
644
+ "codex",
645
+ payload.rate_limit,
646
+ _capturedAt,
647
+ );
581
648
  if (!snapshot) {
582
649
  throw new Error(
583
650
  "Codex usage endpoint returned no displayable rate-limit windows.",
@@ -589,20 +656,25 @@ export function normalizeBackendPayload(
589
656
  function normalizeBackendSnapshot(
590
657
  limitId: string,
591
658
  rateLimit: unknown,
659
+ capturedAt: number,
592
660
  ): NormalizedRateLimitSnapshot | undefined {
593
661
  if (rateLimit === null || rateLimit === undefined) return undefined;
594
662
  const details = assertObject(
595
663
  rateLimit,
596
664
  "rate limit",
597
665
  ) as BackendRateLimitDetails;
598
- const primary = normalizeBackendWindow(details.primary_window);
599
- const secondary = normalizeBackendWindow(details.secondary_window);
666
+ const primary = normalizeBackendWindow(details.primary_window, capturedAt);
667
+ const secondary = normalizeBackendWindow(
668
+ details.secondary_window,
669
+ capturedAt,
670
+ );
600
671
  if (!primary && !secondary) return undefined;
601
672
  return { limitId, primary, secondary };
602
673
  }
603
674
 
604
675
  function normalizeBackendWindow(
605
676
  value: unknown,
677
+ capturedAt: number,
606
678
  ): NormalizedRateLimitWindow | undefined {
607
679
  if (value === null || value === undefined) return undefined;
608
680
  const window = assertObject(
@@ -611,7 +683,19 @@ function normalizeBackendWindow(
611
683
  ) as BackendWindowSnapshot;
612
684
  const usedPercent = asNumber(window.used_percent);
613
685
  if (usedPercent === undefined) return undefined;
614
- return { usedPercent };
686
+ const resetAt = asResetTime(
687
+ [
688
+ window.reset_at,
689
+ window.resets_at,
690
+ window.reset_time,
691
+ window.end_time,
692
+ window.ends_at,
693
+ window.expires_at,
694
+ ],
695
+ window.reset_after_seconds,
696
+ capturedAt,
697
+ );
698
+ return resetAt === undefined ? { usedPercent } : { usedPercent, resetAt };
615
699
  }
616
700
 
617
701
  export function normalizeAppServerResponse(
@@ -620,7 +704,7 @@ export function normalizeAppServerResponse(
620
704
  ): CodexUsageReport {
621
705
  const snapshots: NormalizedRateLimitSnapshot[] = [];
622
706
  const addSnapshot = (raw: unknown, fallbackId: string) => {
623
- const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
707
+ const snapshot = normalizeAppServerSnapshot(raw, fallbackId, _capturedAt);
624
708
  if (!snapshot) return;
625
709
  const existingIndex = snapshots.findIndex(
626
710
  (item) => item.limitId === snapshot.limitId,
@@ -633,7 +717,11 @@ export function normalizeAppServerResponse(
633
717
  else snapshots.push(snapshot);
634
718
  };
635
719
 
636
- addSnapshot(response.rateLimits, "codex");
720
+ if (Array.isArray(response.rateLimits)) {
721
+ for (const item of response.rateLimits) addSnapshot(item, "codex");
722
+ } else {
723
+ addSnapshot(response.rateLimits, "codex");
724
+ }
637
725
  if (snapshots.length === 0) {
638
726
  throw new Error(
639
727
  "codex app-server returned no displayable rate-limit windows.",
@@ -646,6 +734,7 @@ export function normalizeAppServerResponse(
646
734
  function normalizeAppServerSnapshot(
647
735
  raw: unknown,
648
736
  fallbackId: string,
737
+ capturedAt: number,
649
738
  ): NormalizedRateLimitSnapshot | undefined {
650
739
  if (raw === null || raw === undefined) return undefined;
651
740
  const snapshot = assertObject(
@@ -653,14 +742,15 @@ function normalizeAppServerSnapshot(
653
742
  "app-server rate-limit snapshot",
654
743
  ) as AppServerRateLimitSnapshot;
655
744
  const limitId = asString(snapshot.limitId) ?? fallbackId;
656
- const primary = normalizeAppServerWindow(snapshot.primary);
657
- const secondary = normalizeAppServerWindow(snapshot.secondary);
745
+ const primary = normalizeAppServerWindow(snapshot.primary, capturedAt);
746
+ const secondary = normalizeAppServerWindow(snapshot.secondary, capturedAt);
658
747
  if (!primary && !secondary) return undefined;
659
748
  return { limitId, primary, secondary };
660
749
  }
661
750
 
662
751
  function normalizeAppServerWindow(
663
752
  value: unknown,
753
+ capturedAt: number,
664
754
  ): NormalizedRateLimitWindow | undefined {
665
755
  if (value === null || value === undefined) return undefined;
666
756
  const window = assertObject(
@@ -669,7 +759,19 @@ function normalizeAppServerWindow(
669
759
  ) as AppServerWindowSnapshot;
670
760
  const usedPercent = asNumber(window.usedPercent);
671
761
  if (usedPercent === undefined) return undefined;
672
- return { usedPercent };
762
+ const resetAt = asResetTime(
763
+ [
764
+ window.resetAt,
765
+ window.resetsAt,
766
+ window.resetTime,
767
+ window.endTime,
768
+ window.endsAt,
769
+ window.expiresAt,
770
+ ],
771
+ window.resetAfterSeconds,
772
+ capturedAt,
773
+ );
774
+ return resetAt === undefined ? { usedPercent } : { usedPercent, resetAt };
673
775
  }
674
776
 
675
777
  function mergeSnapshot(
@@ -689,7 +791,81 @@ export function formatCodexUsageStatusline(
689
791
  _model?: CodexUsageModel,
690
792
  ): string {
691
793
  const bar = formatReportBar(report);
692
- return bar ? formatStatuslineBarText(ctx, bar) : formatStatuslineText(ctx, "n/a");
794
+ if (!bar) return formatStatuslineText(ctx, "n/a");
795
+ const countdown = formatWeeklyResetCountdown(report);
796
+ const barText = formatStatuslineBarText(ctx, bar);
797
+ return countdown
798
+ ? `${barText} ${ctx.ui.theme.fg("dim", countdown)}`
799
+ : barText;
800
+ }
801
+
802
+ export function formatCodexUsageBar(
803
+ report: CodexUsageReport,
804
+ ): string | undefined {
805
+ return formatReportBar(report);
806
+ }
807
+
808
+ export function formatWeeklyResetCountdown(
809
+ report: CodexUsageReport,
810
+ now = Date.now(),
811
+ ): string | undefined {
812
+ const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
813
+ if (resetAt === undefined) return undefined;
814
+ return formatResetCountdown(resetAt, now);
815
+ }
816
+
817
+ export function formatResetCountdown(
818
+ resetAt: number,
819
+ now = Date.now(),
820
+ ): string {
821
+ const remainingMs = Math.max(0, resetAt - now);
822
+ if (remainingMs >= DAY_MS) {
823
+ const dayTenths = Math.max(10, Math.ceil(remainingMs / DAY_TENTH_MS));
824
+ return `${formatTenths(dayTenths)}d`;
825
+ }
826
+ if (remainingMs >= HOUR_MS) return `${Math.floor(remainingMs / HOUR_MS)}h`;
827
+ if (remainingMs >= MINUTE_MS)
828
+ return `${Math.floor(remainingMs / MINUTE_MS)}m`;
829
+ return `${Math.floor(remainingMs / SECOND_MS)}s`;
830
+ }
831
+
832
+ export function nextResetCountdownDelayMs(
833
+ report: CodexUsageReport,
834
+ now = Date.now(),
835
+ ): number | undefined {
836
+ const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
837
+ if (resetAt === undefined) return undefined;
838
+ return nextResetCountdownDelayForRemainingMs(resetAt - now);
839
+ }
840
+
841
+ export function nextResetCountdownDelayForRemainingMs(
842
+ remainingMs: number,
843
+ ): number | undefined {
844
+ if (remainingMs <= 0) return undefined;
845
+ if (remainingMs >= DAY_MS) {
846
+ const dayTenths = Math.max(10, Math.ceil(remainingMs / DAY_TENTH_MS));
847
+ return Math.max(1, remainingMs - (dayTenths - 1) * DAY_TENTH_MS);
848
+ }
849
+ if (remainingMs >= HOUR_MS) {
850
+ return Math.max(
851
+ 1,
852
+ remainingMs - Math.floor(remainingMs / HOUR_MS) * HOUR_MS + 1,
853
+ );
854
+ }
855
+ if (remainingMs >= MINUTE_MS) {
856
+ return Math.max(
857
+ 1,
858
+ remainingMs - Math.floor(remainingMs / MINUTE_MS) * MINUTE_MS + 1,
859
+ );
860
+ }
861
+ return Math.max(
862
+ 1,
863
+ remainingMs - Math.floor(remainingMs / SECOND_MS) * SECOND_MS + 1,
864
+ );
865
+ }
866
+
867
+ function formatTenths(value: number): string {
868
+ return value % 10 === 0 ? String(value / 10) : (value / 10).toFixed(1);
693
869
  }
694
870
 
695
871
  function formatReportBar(report: CodexUsageReport): string | undefined {
@@ -705,18 +881,12 @@ function formatStatuslineText(ctx: ExtensionContext, value: string): string {
705
881
 
706
882
  function formatStatuslineBarText(ctx: ExtensionContext, bar: string): string {
707
883
  const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
708
- const value = ctx.ui.theme.bg(
709
- "selectedBg",
710
- ctx.ui.theme.fg("dim", bar),
711
- );
884
+ const value = ctx.ui.theme.bg("selectedBg", ctx.ui.theme.fg("dim", bar));
712
885
  return `${label} ${value}`;
713
886
  }
714
887
 
715
888
  function formatEmptyStatuslineBar(ctx: ExtensionContext): string {
716
- return formatStatuslineBarText(
717
- ctx,
718
- "\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0",
719
- );
889
+ return formatStatuslineBarText(ctx, DUAL_BAR_CHARS[0].repeat(DUAL_BAR_WIDTH));
720
890
  }
721
891
 
722
892
  function formatStatuslineProblem(
@@ -724,30 +894,32 @@ function formatStatuslineProblem(
724
894
  errors: UsageQueryError[],
725
895
  ): string {
726
896
  const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
727
- const value = isUnavailable(errors)
897
+ const value = isUsageUnavailable(errors)
728
898
  ? ctx.ui.theme.fg("muted", "n/a")
729
899
  : ctx.ui.theme.fg("error", "error");
730
900
  return `${label} ${value}`;
731
901
  }
732
902
 
733
- function isUnavailable(errors: UsageQueryError[]): boolean {
734
- return errors.some((error) => {
735
- const message = error.message.toLowerCase();
736
- return (
737
- message.includes("no pi openai codex subscription auth") ||
738
- message.includes("no displayable rate-limit windows") ||
739
- message.includes("returned no displayable rate-limit windows") ||
740
- message.includes("returned 401") ||
741
- message.includes("returned 403") ||
742
- message.includes("unauthorized") ||
743
- message.includes("forbidden") ||
744
- message.includes("subscription") ||
745
- message.includes("no active plan") ||
746
- message.includes("plan unavailable") ||
747
- message.includes("quota unavailable") ||
748
- message.includes("rate limits unavailable")
749
- );
750
- });
903
+ export function isUsageUnavailable(errors: UsageQueryError[]): boolean {
904
+ return errors.length > 0 && errors.every(isUnavailableError);
905
+ }
906
+
907
+ function isUnavailableError(error: UsageQueryError): boolean {
908
+ const message = error.message.toLowerCase();
909
+ return (
910
+ message.includes("no pi openai codex subscription auth") ||
911
+ message.includes("no displayable rate-limit windows") ||
912
+ message.includes("returned no displayable rate-limit windows") ||
913
+ message.includes("returned 401") ||
914
+ message.includes("returned 403") ||
915
+ message.includes("unauthorized") ||
916
+ message.includes("forbidden") ||
917
+ message.includes("subscription") ||
918
+ message.includes("no active plan") ||
919
+ message.includes("plan unavailable") ||
920
+ message.includes("quota unavailable") ||
921
+ message.includes("rate limits unavailable")
922
+ );
751
923
  }
752
924
 
753
925
  function selectPrimaryCodexSnapshot(
@@ -771,7 +943,7 @@ function formatDualLimitBar(
771
943
  const primaryParts = filledTwentieths(primary);
772
944
  const secondaryParts = filledTwentieths(secondary);
773
945
  let value = "";
774
- for (let index = 0; index < 10; index++) {
946
+ for (let index = 0; index < DUAL_BAR_WIDTH; index++) {
775
947
  const leftPart = index * 2 + 1;
776
948
  const rightPart = leftPart + 1;
777
949
  let mask = 0;
@@ -784,7 +956,9 @@ function formatDualLimitBar(
784
956
  return value;
785
957
  }
786
958
 
787
- function filledTwentieths(window: NormalizedRateLimitWindow | undefined): number {
959
+ function filledTwentieths(
960
+ window: NormalizedRateLimitWindow | undefined,
961
+ ): number {
788
962
  if (!window) return 0;
789
963
  return Math.round(remainingPercent(window) / 5);
790
964
  }
@@ -842,6 +1016,34 @@ function asNumber(value: unknown): number | undefined {
842
1016
  return undefined;
843
1017
  }
844
1018
 
1019
+ function asResetTime(
1020
+ absoluteValues: unknown[],
1021
+ relativeSeconds: unknown,
1022
+ capturedAt: number,
1023
+ ): number | undefined {
1024
+ for (const value of absoluteValues) {
1025
+ const timestamp = asTimestampMs(value);
1026
+ if (timestamp !== undefined) return timestamp;
1027
+ }
1028
+ const seconds = asNumber(relativeSeconds);
1029
+ if (seconds === undefined || seconds < 0) return undefined;
1030
+ return capturedAt + seconds * SECOND_MS;
1031
+ }
1032
+
1033
+ function asTimestampMs(value: unknown): number | undefined {
1034
+ if (typeof value === "number" && Number.isFinite(value)) {
1035
+ if (value <= 0) return undefined;
1036
+ return value < 10_000_000_000 ? value * SECOND_MS : value;
1037
+ }
1038
+ if (typeof value === "string" && value.trim()) {
1039
+ const numeric = Number(value);
1040
+ if (Number.isFinite(numeric)) return asTimestampMs(numeric);
1041
+ const parsed = Date.parse(value);
1042
+ return Number.isFinite(parsed) ? parsed : undefined;
1043
+ }
1044
+ return undefined;
1045
+ }
1046
+
845
1047
  function hasHeader(headers: Record<string, string>, name: string): boolean {
846
1048
  return Object.keys(headers).some(
847
1049
  (key) => key.toLowerCase() === name.toLowerCase(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
6
6
  "keywords": [
@@ -26,9 +26,10 @@
26
26
  },
27
27
  "scripts": {
28
28
  "check": "node --experimental-strip-types -e \"await import('./index.ts'); console.log('pi-codex-usage: extension import ok')\"",
29
+ "test": "node --experimental-strip-types --test test/*.test.ts",
29
30
  "typecheck": "tsc --noEmit",
30
31
  "pack:dry": "npm pack --dry-run",
31
- "validate": "npm run typecheck && npm run check && npm run pack:dry"
32
+ "validate": "npm run typecheck && npm run test && npm run check && npm run pack:dry"
32
33
  },
33
34
  "files": [
34
35
  "index.ts",