@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 +4 -0
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +29 -8
- package/README.md +4 -2
- package/banner.png +0 -0
- package/index.ts +245 -43
- package/package.json +3 -2
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
734
|
-
return errors.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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 <
|
|
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(
|
|
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.
|
|
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",
|