@hk_net/pi-usage-bars 0.3.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/CHANGELOG.md ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0] - 2026-07-22
4
+
5
+ ### Breaking changes
6
+
7
+ - Renamed the npm package to `@hk_net/pi-usage-bars`; install it with `pi install npm:@hk_net/pi-usage-bars`.
8
+ - Updated the minimum supported Pi release to 0.81.1.
9
+ - Removed Google Gemini CLI and Google Antigravity usage support, matching their removal from Pi 0.71.0.
10
+ - Refactored core usage orchestration to accept resolved provider tokens instead of reading or refreshing Pi credentials directly.
11
+
12
+ ### Added
13
+
14
+ - Added ZAI Coding Plan (China) provider and quota endpoint support.
15
+ - Added abort-aware polling and provider requests.
16
+ - Added strict TypeScript checking, a current-Pi smoke test, pinned development dependencies, and reproducible npm installs.
17
+ - Added Pi package gallery image metadata and documented endpoint/security behavior.
18
+
19
+ ### Changed
20
+
21
+ - Authentication now goes through `ctx.modelRegistry.getProviderAuth()`, allowing Pi to safely resolve and refresh credentials with its canonical locked credential store.
22
+ - Renamed Z.AI to the current Pi labels, ZAI Coding Plan (Global) and ZAI Coding Plan (China).
23
+ - Limited polling and custom `/usage` UI to interactive TUI mode.
24
+ - Made startup and model-change polling non-blocking and added complete session shutdown cleanup.
25
+ - Updated the selector to use typed TUI APIs, explicit render requests, abortable loading, and theme-safe invalidation.
26
+
27
+ ### Fixed
28
+
29
+ - Fixed OAuth refresh failures on Pi 0.81.1 caused by the removed runtime `getOAuthApiKey` export.
30
+ - Fixed potential credential races and permission changes caused by direct `auth.json` writes.
31
+ - Fixed in-flight polling and selector requests surviving session shutdown or dialog disposal.
32
+
33
+ ## [0.2.3] - 2026-06-10
34
+
35
+ ### Fixed
36
+
37
+ - **`DynamicBorder` is not defined** — added missing import of `DynamicBorder` from `@earendil-works/pi-coding-agent`. This fixes the `DynamicBorder is not defined` error when calling `/usage`.
38
+
39
+ ### Changed
40
+
41
+ - Updated repository URLs from `ajarellanod` to `hknet` in `package.json` and `README.md`.
42
+ - Updated README image URLs to point to the `hknet` repository.
43
+ - Updated LICENSE copyright to `ajarellanod (secondary: hknet)`.
44
+
45
+ ### Removed
46
+
47
+ - Removed stale tgz build artifacts and `package-lock.json` from the repository.
48
+ - Removed `NOTE.md` compliance report (development artifact).
49
+
50
+ ## [0.2.2] - 2026-06-10
51
+
52
+ ### Fixed
53
+
54
+ - **Keybinding violation in `UsageSelectorComponent`** — replaced `getKeybindings()` with the `keybindings` parameter injected by `ctx.ui.custom()`. Per the Pi extension docs, custom components must use the injected `KeybindingsManager` directly rather than calling `getKeybindings()` or `setKeybindings()`. This fixes a compliance issue that could cause unexpected behavior under custom keybinding configurations.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ajarellanod and hknet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # pi-usage-bars
2
+
3
+ Subscription usage indicators for [Pi](https://github.com/earendil-works/pi).
4
+
5
+ ![Codex footer bar](https://raw.githubusercontent.com/hknet/pi-usage-bars/main/assets/codex.png)
6
+
7
+ It adds:
8
+
9
+ - a footer status bar for the active subscription provider
10
+ - a `/usage` command showing every configured supported subscription
11
+
12
+ ## Supported providers
13
+
14
+ | Provider | Pi provider ID | Authentication |
15
+ | --- | --- | --- |
16
+ | OpenAI Codex | `openai-codex` | ChatGPT subscription OAuth |
17
+ | Anthropic Claude | `anthropic` | Claude Pro/Max OAuth |
18
+ | ZAI Coding Plan (Global) | `zai` | ZAI API key |
19
+ | ZAI Coding Plan (China) | `zai-coding-cn` | ZAI China API key |
20
+
21
+ Google Gemini CLI and Google Antigravity are not supported because Pi removed those built-in providers in version 0.71.0.
22
+
23
+ ## Requirements
24
+
25
+ - Pi 0.81.1 or newer
26
+ - Node.js 22.19 or newer when using the npm-distributed Pi CLI
27
+
28
+ Authenticate providers through Pi's `/login` command. The extension resolves credentials through Pi's provider API; it does not read or write `auth.json` itself.
29
+
30
+ ## Install
31
+
32
+ Install the npm package:
33
+
34
+ ```bash
35
+ pi install npm:@hk_net/pi-usage-bars
36
+ ```
37
+
38
+ Or install the latest source directly from GitHub:
39
+
40
+ ```bash
41
+ pi install https://github.com/hknet/pi-usage-bars
42
+ ```
43
+
44
+ Restart Pi after installation, or use `/reload` when the package is already installed.
45
+
46
+ ## Use
47
+
48
+ ### Footer usage bars
49
+
50
+ When the active model belongs to a configured supported provider, the footer shows session and weekly usage with reset countdowns when available.
51
+
52
+ ![Claude footer bar](https://raw.githubusercontent.com/hknet/pi-usage-bars/main/assets/claude.png)
53
+
54
+ Usage refresh runs in interactive TUI sessions every two minutes. It does not run in print, JSON, or RPC mode.
55
+
56
+ ### `/usage`
57
+
58
+ Run `/usage` to open a searchable list of configured supported providers.
59
+
60
+ ![/usage command](https://raw.githubusercontent.com/hknet/pi-usage-bars/main/assets/usage-command.png)
61
+
62
+ ## Endpoint configuration
63
+
64
+ The ZAI monitoring endpoints can be overridden:
65
+
66
+ | Variable | Default |
67
+ | --- | --- |
68
+ | `PI_ZAI_USAGE_ENDPOINT` | `https://api.z.ai/api/monitor/usage/quota/limit` |
69
+ | `PI_ZAI_CODING_CN_USAGE_ENDPOINT` | `https://open.bigmodel.cn/api/monitor/usage/quota/limit` |
70
+
71
+ **Security:** the corresponding provider token is sent as a bearer token to the configured endpoint. Only override these variables with an endpoint you trust.
72
+
73
+ The Codex and Claude usage endpoints are fixed to their first-party services. Claude responses are cached briefly in the system temporary directory to coordinate multiple Pi processes and reduce rate limiting. The cache contains usage values, not credentials.
74
+
75
+ ## Development
76
+
77
+ Install Node.js 22.19+ and Bun 1.3, then run:
78
+
79
+ ```bash
80
+ npm install
81
+ npm run check
82
+ ```
83
+
84
+ `npm run check` performs a strict TypeScript check, runs the Bun test suite, and smoke-loads the extension with the pinned current Pi release.
85
+
86
+ ## Credits
87
+
88
+ This extension is based on and inspired by:
89
+
90
+ - [CodexBar](https://github.com/steipete/CodexBar)
91
+ - [rho usage-bars](https://github.com/mikeyobrien/rho/tree/main/extensions/usage-bars)
@@ -0,0 +1,653 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export type ProviderKey = "codex" | "claude" | "zai" | "zai-cn";
6
+ export type PiProviderId = "openai-codex" | "anthropic" | "zai" | "zai-coding-cn";
7
+
8
+ export interface UsageData {
9
+ session: number;
10
+ weekly: number;
11
+ sessionResetsIn?: string;
12
+ weeklyResetsIn?: string;
13
+ sessionResetsAt?: string;
14
+ weeklyResetsAt?: string;
15
+ extraSpend?: number;
16
+ extraLimit?: number;
17
+ warning?: string;
18
+ stale?: boolean;
19
+ fetchedAt?: number;
20
+ error?: string;
21
+ }
22
+
23
+ export type UsageByProvider = Record<ProviderKey, UsageData | null>;
24
+ export type UsageTokens = Partial<Record<ProviderKey, string>>;
25
+
26
+ export interface UsageEndpoints {
27
+ zai: string;
28
+ zaiCn: string;
29
+ }
30
+
31
+ export interface HeadersLike {
32
+ get(name: string): string | null;
33
+ }
34
+
35
+ export interface FetchResponseLike {
36
+ ok: boolean;
37
+ status: number;
38
+ headers?: HeadersLike;
39
+ json(): Promise<unknown>;
40
+ }
41
+
42
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
43
+
44
+ export interface RequestConfig {
45
+ fetchFn?: FetchLike;
46
+ timeoutMs?: number;
47
+ signal?: AbortSignal;
48
+ }
49
+
50
+ export interface FetchConfig extends RequestConfig {
51
+ endpoints?: UsageEndpoints;
52
+ env?: NodeJS.ProcessEnv;
53
+ }
54
+
55
+ export interface FetchAllUsagesConfig extends FetchConfig {
56
+ cacheFile?: string;
57
+ nowMs?: number;
58
+ }
59
+
60
+ export interface ClaudeUsageFetchConfig extends RequestConfig {
61
+ cacheFile?: string;
62
+ nowMs?: number;
63
+ }
64
+
65
+ interface JsonRequestSuccess {
66
+ ok: true;
67
+ data: unknown;
68
+ status: number;
69
+ headers?: HeadersLike;
70
+ }
71
+
72
+ interface JsonRequestError {
73
+ ok: false;
74
+ error: string;
75
+ status: number | null;
76
+ headers?: HeadersLike;
77
+ }
78
+
79
+ type JsonRequestResult = JsonRequestSuccess | JsonRequestError;
80
+
81
+ interface ClaudeUsageAttemptResult {
82
+ usage: UsageData;
83
+ status: number | null;
84
+ retryAfterMs: number | null;
85
+ }
86
+
87
+ interface ClaudeUsageCacheState {
88
+ lastSuccess?: UsageData;
89
+ lastSuccessAt?: number;
90
+ cooldownUntil?: number;
91
+ consecutive429s?: number;
92
+ lastError?: string;
93
+ }
94
+
95
+ interface UsageBarsCacheFile {
96
+ version: 1;
97
+ claude?: ClaudeUsageCacheState;
98
+ }
99
+
100
+ const DEFAULT_FETCH_TIMEOUT_MS = 12_000;
101
+ const CLAUDE_SHARED_FRESH_TTL_MS = 2 * 60 * 1000;
102
+ const CLAUDE_BASE_BACKOFF_MS = 2 * 60 * 1000;
103
+ const CLAUDE_MAX_BACKOFF_MS = 30 * 60 * 1000;
104
+ const CLAUDE_LOCK_WAIT_MS = 4_000;
105
+ const CLAUDE_LOCK_POLL_MS = 125;
106
+ const CLAUDE_LOCK_STALE_MS = 20_000;
107
+
108
+ export const DEFAULT_USAGE_CACHE_FILE = path.join(os.tmpdir(), "pi", "usage-bars-cache.json");
109
+ export const DEFAULT_ZAI_USAGE_ENDPOINT = "https://api.z.ai/api/monitor/usage/quota/limit";
110
+ export const DEFAULT_ZAI_CN_USAGE_ENDPOINT = "https://open.bigmodel.cn/api/monitor/usage/quota/limit";
111
+
112
+ export function resolveUsageEndpoints(env: NodeJS.ProcessEnv = process.env): UsageEndpoints {
113
+ const configured = (value: string | undefined, fallback: string) => {
114
+ const trimmed = value?.trim();
115
+ return trimmed || fallback;
116
+ };
117
+
118
+ return {
119
+ zai: configured(env.PI_ZAI_USAGE_ENDPOINT, DEFAULT_ZAI_USAGE_ENDPOINT),
120
+ zaiCn: configured(env.PI_ZAI_CODING_CN_USAGE_ENDPOINT, DEFAULT_ZAI_CN_USAGE_ENDPOINT),
121
+ };
122
+ }
123
+
124
+ function toErrorMessage(error: unknown, externalSignal?: AbortSignal): string {
125
+ if (error instanceof Error) {
126
+ if (error.name === "AbortError") {
127
+ return externalSignal?.aborted ? "request cancelled" : "request timeout";
128
+ }
129
+ return error.message || String(error);
130
+ }
131
+ return String(error);
132
+ }
133
+
134
+ function asObject(value: unknown): Record<string, unknown> | null {
135
+ if (!value || typeof value !== "object") return null;
136
+ return value as Record<string, unknown>;
137
+ }
138
+
139
+ function normalizeUsagePair(session: number, weekly: number): { session: number; weekly: number } {
140
+ const clean = (value: number) => Number.isFinite(value) ? Number(value.toFixed(2)) : 0;
141
+ return { session: clean(session), weekly: clean(weekly) };
142
+ }
143
+
144
+ function getHeader(headers: HeadersLike | undefined, name: string): string | null {
145
+ if (!headers) return null;
146
+ try {
147
+ return headers.get(name);
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ function combineSignals(timeoutSignal: AbortSignal | undefined, externalSignal: AbortSignal | undefined): AbortSignal | undefined {
154
+ if (timeoutSignal && externalSignal) return AbortSignal.any([timeoutSignal, externalSignal]);
155
+ return timeoutSignal ?? externalSignal;
156
+ }
157
+
158
+ async function requestJson(url: string, init: RequestInit, config: RequestConfig = {}): Promise<JsonRequestResult> {
159
+ const fetchFn = config.fetchFn ?? (fetch as unknown as FetchLike);
160
+ const timeoutMs = config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
161
+ const timeoutController = timeoutMs > 0 ? new AbortController() : undefined;
162
+ const timeout = timeoutController
163
+ ? setTimeout(() => timeoutController.abort(), timeoutMs)
164
+ : undefined;
165
+ const signal = combineSignals(timeoutController?.signal, config.signal);
166
+
167
+ try {
168
+ if (config.signal?.aborted) {
169
+ return { ok: false, error: "request cancelled", status: null };
170
+ }
171
+
172
+ const response = await fetchFn(url, { ...init, signal });
173
+ if (!response.ok) {
174
+ return { ok: false, error: `HTTP ${response.status}`, status: response.status, headers: response.headers };
175
+ }
176
+
177
+ try {
178
+ return { ok: true, data: await response.json(), status: response.status, headers: response.headers };
179
+ } catch {
180
+ return { ok: false, error: "invalid JSON response", status: response.status, headers: response.headers };
181
+ }
182
+ } catch (error) {
183
+ return { ok: false, error: toErrorMessage(error, config.signal), status: null };
184
+ } finally {
185
+ if (timeout !== undefined) clearTimeout(timeout);
186
+ }
187
+ }
188
+
189
+ export function formatDuration(seconds: number): string {
190
+ if (!Number.isFinite(seconds) || seconds <= 0) return "now";
191
+ const days = Math.floor(seconds / 86400);
192
+ const hours = Math.floor((seconds % 86400) / 3600);
193
+ const minutes = Math.floor((seconds % 3600) / 60);
194
+ if (days > 0 && hours > 0) return `${days}d ${hours}h`;
195
+ if (days > 0) return `${days}d`;
196
+ if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
197
+ if (hours > 0) return `${hours}h`;
198
+ if (minutes > 0) return `${minutes}m`;
199
+ return "<1m";
200
+ }
201
+
202
+ export function formatResetsAt(isoDate: string, nowMs = Date.now()): string {
203
+ const resetTime = new Date(isoDate).getTime();
204
+ if (!Number.isFinite(resetTime)) return "";
205
+ return formatDuration(Math.max(0, resetTime - nowMs) / 1000);
206
+ }
207
+
208
+ export function parseRetryAfterMs(value: string | null | undefined, nowMs = Date.now()): number | null {
209
+ if (!value) return null;
210
+ const numeric = Number(value);
211
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric * 1000;
212
+ const dateMs = new Date(value).getTime();
213
+ return Number.isFinite(dateMs) ? Math.max(0, dateMs - nowMs) : null;
214
+ }
215
+
216
+ function readUsageCache(cacheFile = DEFAULT_USAGE_CACHE_FILE): UsageBarsCacheFile {
217
+ try {
218
+ const parsed = JSON.parse(fs.readFileSync(cacheFile, "utf-8"));
219
+ if (parsed?.version === 1 && typeof parsed === "object") return parsed as UsageBarsCacheFile;
220
+ } catch {
221
+ // Invalid or missing caches are treated as empty.
222
+ }
223
+ return { version: 1 };
224
+ }
225
+
226
+ function writeUsageCache(cache: UsageBarsCacheFile, cacheFile = DEFAULT_USAGE_CACHE_FILE): boolean {
227
+ try {
228
+ const directory = path.dirname(cacheFile);
229
+ if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
230
+ const temporaryPath = `${cacheFile}.tmp-${process.pid}-${Date.now()}`;
231
+ fs.writeFileSync(temporaryPath, JSON.stringify(cache, null, 2), { mode: 0o600 });
232
+ fs.renameSync(temporaryPath, cacheFile);
233
+ return true;
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+
239
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
240
+ if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
241
+ return new Promise((resolve, reject) => {
242
+ const timer = setTimeout(resolve, ms);
243
+ signal?.addEventListener("abort", () => {
244
+ clearTimeout(timer);
245
+ reject(new DOMException("Aborted", "AbortError"));
246
+ }, { once: true });
247
+ });
248
+ }
249
+
250
+ function ensureParentDir(filePath: string): void {
251
+ const directory = path.dirname(filePath);
252
+ if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
253
+ }
254
+
255
+ function safeUnlink(filePath: string): void {
256
+ try {
257
+ fs.unlinkSync(filePath);
258
+ } catch {
259
+ // Ignore cleanup races.
260
+ }
261
+ }
262
+
263
+ async function acquireFileLock(lockFile: string, signal?: AbortSignal): Promise<(() => void) | null> {
264
+ ensureParentDir(lockFile);
265
+ const startedAt = Date.now();
266
+
267
+ while (Date.now() - startedAt <= CLAUDE_LOCK_WAIT_MS) {
268
+ if (signal?.aborted) return null;
269
+ try {
270
+ const fd = fs.openSync(lockFile, "wx", 0o600);
271
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
272
+ fs.closeSync(fd);
273
+ return () => safeUnlink(lockFile);
274
+ } catch (error: unknown) {
275
+ if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") return null;
276
+ try {
277
+ const stat = fs.statSync(lockFile);
278
+ if (Date.now() - stat.mtimeMs >= CLAUDE_LOCK_STALE_MS) {
279
+ safeUnlink(lockFile);
280
+ continue;
281
+ }
282
+ } catch {
283
+ continue;
284
+ }
285
+ try {
286
+ await sleep(CLAUDE_LOCK_POLL_MS, signal);
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+ }
292
+
293
+ return null;
294
+ }
295
+
296
+ export function readPercentCandidate(value: unknown): number | null {
297
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
298
+ if (value >= 0 && value <= 1) return Number.isInteger(value) ? value : value * 100;
299
+ return value >= 0 && value <= 100 ? value : null;
300
+ }
301
+
302
+ export function readLimitPercent(limit: unknown): number | null {
303
+ const value = asObject(limit);
304
+ const direct = [
305
+ value?.percentage,
306
+ value?.utilization,
307
+ value?.used_percent,
308
+ value?.usedPercent,
309
+ value?.usagePercent,
310
+ value?.usage_percent,
311
+ ].map(readPercentCandidate).find((candidate) => candidate !== null);
312
+ if (direct !== undefined) return direct;
313
+
314
+ const current = typeof value?.currentValue === "number" ? value.currentValue : null;
315
+ const remaining = typeof value?.remaining === "number" ? value.remaining : null;
316
+ if (current !== null && remaining !== null && current + remaining > 0) {
317
+ return (current / (current + remaining)) * 100;
318
+ }
319
+ return null;
320
+ }
321
+
322
+ export function extractUsageFromPayload(payload: unknown): { session: number; weekly: number } | null {
323
+ const data = payload as any;
324
+ const limitArrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits];
325
+ const limits = limitArrays.find(Array.isArray) as unknown[] | undefined;
326
+
327
+ if (limits) {
328
+ const byType = (types: string[]) => limits.find((entry) => {
329
+ const type = String((entry as any)?.type || "").toUpperCase();
330
+ return types.includes(type);
331
+ });
332
+ const session = readLimitPercent(byType(["TIME_LIMIT", "SESSION_LIMIT", "REQUEST_LIMIT", "RPM_LIMIT", "RPD_LIMIT"]));
333
+ const weekly = readLimitPercent(byType(["TOKENS_LIMIT", "TOKEN_LIMIT", "WEEK_LIMIT", "WEEKLY_LIMIT", "TPM_LIMIT", "DAILY_LIMIT"]));
334
+ if (session !== null && weekly !== null) return normalizeUsagePair(session, weekly);
335
+ }
336
+
337
+ const sessionCandidates = [
338
+ data?.session,
339
+ data?.sessionPercent,
340
+ data?.session_percent,
341
+ data?.five_hour?.utilization,
342
+ data?.rate_limit?.primary_window?.used_percent,
343
+ data?.limits?.session?.utilization,
344
+ data?.usage?.session,
345
+ data?.data?.session,
346
+ data?.data?.sessionPercent,
347
+ data?.data?.session_percent,
348
+ data?.data?.usage?.session,
349
+ data?.quota?.session?.percentage,
350
+ data?.data?.quota?.session?.percentage,
351
+ ];
352
+ const weeklyCandidates = [
353
+ data?.weekly,
354
+ data?.weeklyPercent,
355
+ data?.weekly_percent,
356
+ data?.seven_day?.utilization,
357
+ data?.rate_limit?.secondary_window?.used_percent,
358
+ data?.limits?.weekly?.utilization,
359
+ data?.usage?.weekly,
360
+ data?.data?.weekly,
361
+ data?.data?.weeklyPercent,
362
+ data?.data?.weekly_percent,
363
+ data?.data?.usage?.weekly,
364
+ data?.quota?.weekly?.percentage,
365
+ data?.data?.quota?.weekly?.percentage,
366
+ data?.quota?.daily?.percentage,
367
+ data?.data?.quota?.daily?.percentage,
368
+ ];
369
+
370
+ const session = sessionCandidates.map(readPercentCandidate).find((candidate) => candidate !== null);
371
+ const weekly = weeklyCandidates.map(readPercentCandidate).find((candidate) => candidate !== null);
372
+ return session === undefined || weekly === undefined ? null : normalizeUsagePair(session, weekly);
373
+ }
374
+
375
+ function hydrateUsageResets(usage: UsageData, nowMs = Date.now()): UsageData {
376
+ return {
377
+ ...usage,
378
+ sessionResetsIn: usage.sessionResetsAt ? formatResetsAt(usage.sessionResetsAt, nowMs) : usage.sessionResetsIn,
379
+ weeklyResetsIn: usage.weeklyResetsAt ? formatResetsAt(usage.weeklyResetsAt, nowMs) : usage.weeklyResetsIn,
380
+ };
381
+ }
382
+
383
+ function snapshotUsage(usage: UsageData, nowMs = Date.now()): UsageData {
384
+ return {
385
+ session: usage.session,
386
+ weekly: usage.weekly,
387
+ sessionResetsAt: usage.sessionResetsAt,
388
+ weeklyResetsAt: usage.weeklyResetsAt,
389
+ sessionResetsIn: usage.sessionResetsIn,
390
+ weeklyResetsIn: usage.weeklyResetsIn,
391
+ extraSpend: usage.extraSpend,
392
+ extraLimit: usage.extraLimit,
393
+ fetchedAt: usage.fetchedAt ?? nowMs,
394
+ };
395
+ }
396
+
397
+ function staleCachedUsage(cached: UsageData, warning: string, nowMs = Date.now()): UsageData {
398
+ return { ...hydrateUsageResets(snapshotUsage(cached, nowMs), nowMs), stale: true, warning };
399
+ }
400
+
401
+ function readClaudeCacheState(cacheFile = DEFAULT_USAGE_CACHE_FILE): ClaudeUsageCacheState {
402
+ return readUsageCache(cacheFile).claude ?? {};
403
+ }
404
+
405
+ function writeClaudeCacheState(state: ClaudeUsageCacheState, cacheFile = DEFAULT_USAGE_CACHE_FILE): boolean {
406
+ const cache = readUsageCache(cacheFile);
407
+ cache.claude = state;
408
+ return writeUsageCache(cache, cacheFile);
409
+ }
410
+
411
+ function clearClaudeCooldown(state: ClaudeUsageCacheState): ClaudeUsageCacheState {
412
+ return { ...state, cooldownUntil: undefined, consecutive429s: 0, lastError: undefined };
413
+ }
414
+
415
+ function computeClaudeBackoffMs(state: ClaudeUsageCacheState, retryAfterMs: number | null): number {
416
+ if (retryAfterMs !== null && retryAfterMs > 0) {
417
+ return Math.min(CLAUDE_MAX_BACKOFF_MS, Math.max(CLAUDE_BASE_BACKOFF_MS, retryAfterMs));
418
+ }
419
+ const count = Math.max(1, state.consecutive429s ?? 0);
420
+ return Math.min(CLAUDE_MAX_BACKOFF_MS, CLAUDE_BASE_BACKOFF_MS * 2 ** Math.max(0, count - 1));
421
+ }
422
+
423
+ function cooldownMessage(untilMs: number, nowMs = Date.now()): string {
424
+ return `rate limited; retry in ${formatDuration(Math.max(0, untilMs - nowMs) / 1000)}`;
425
+ }
426
+
427
+ function readClaudeCacheOutcome(cacheFile = DEFAULT_USAGE_CACHE_FILE, nowMs = Date.now()): UsageData | null {
428
+ const state = readClaudeCacheState(cacheFile);
429
+ if (state.cooldownUntil && state.cooldownUntil > nowMs) {
430
+ const warning = cooldownMessage(state.cooldownUntil, nowMs);
431
+ return state.lastSuccess
432
+ ? staleCachedUsage(state.lastSuccess, warning, nowMs)
433
+ : { session: 0, weekly: 0, error: warning };
434
+ }
435
+ if (state.lastSuccess && state.lastSuccessAt && nowMs - state.lastSuccessAt <= CLAUDE_SHARED_FRESH_TTL_MS) {
436
+ return hydrateUsageResets(snapshotUsage(state.lastSuccess, state.lastSuccessAt), nowMs);
437
+ }
438
+ return null;
439
+ }
440
+
441
+ export async function fetchCodexUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
442
+ const result = await requestJson(
443
+ "https://chatgpt.com/backend-api/wham/usage",
444
+ { headers: { Authorization: `Bearer ${token}` } },
445
+ config,
446
+ );
447
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
448
+
449
+ const data = result.data as any;
450
+ const primary = data?.rate_limit?.primary_window;
451
+ const secondary = data?.rate_limit?.secondary_window;
452
+ return {
453
+ session: readPercentCandidate(primary?.used_percent) ?? 0,
454
+ weekly: readPercentCandidate(secondary?.used_percent) ?? 0,
455
+ sessionResetsIn: typeof primary?.reset_after_seconds === "number" ? formatDuration(primary.reset_after_seconds) : undefined,
456
+ weeklyResetsIn: typeof secondary?.reset_after_seconds === "number" ? formatDuration(secondary.reset_after_seconds) : undefined,
457
+ };
458
+ }
459
+
460
+ async function fetchClaudeUsageAttempt(
461
+ token: string,
462
+ config: RequestConfig = {},
463
+ nowMs = Date.now(),
464
+ ): Promise<ClaudeUsageAttemptResult> {
465
+ const result = await requestJson(
466
+ "https://api.anthropic.com/api/oauth/usage",
467
+ {
468
+ headers: {
469
+ Authorization: `Bearer ${token}`,
470
+ "anthropic-beta": "oauth-2025-04-20",
471
+ },
472
+ },
473
+ config,
474
+ );
475
+ const retryAfterMs = parseRetryAfterMs(getHeader(result.headers, "retry-after"), nowMs);
476
+ if (!result.ok) {
477
+ return { usage: { session: 0, weekly: 0, error: result.error }, status: result.status, retryAfterMs };
478
+ }
479
+
480
+ const data = result.data as any;
481
+ const usage: UsageData = hydrateUsageResets({
482
+ session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
483
+ weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0,
484
+ sessionResetsAt: typeof data?.five_hour?.resets_at === "string" ? data.five_hour.resets_at : undefined,
485
+ weeklyResetsAt: typeof data?.seven_day?.resets_at === "string" ? data.seven_day.resets_at : undefined,
486
+ fetchedAt: nowMs,
487
+ }, nowMs);
488
+
489
+ if (data?.extra_usage?.is_enabled) {
490
+ usage.extraSpend = typeof data.extra_usage.used_credits === "number" ? data.extra_usage.used_credits : undefined;
491
+ usage.extraLimit = typeof data.extra_usage.monthly_limit === "number" ? data.extra_usage.monthly_limit : undefined;
492
+ }
493
+ return { usage, status: result.status, retryAfterMs };
494
+ }
495
+
496
+ export async function fetchClaudeUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
497
+ return (await fetchClaudeUsageAttempt(token, config)).usage;
498
+ }
499
+
500
+ export async function fetchClaudeUsageWithFallback(
501
+ token: string,
502
+ config: ClaudeUsageFetchConfig = {},
503
+ ): Promise<UsageData> {
504
+ const cacheFile = config.cacheFile ?? DEFAULT_USAGE_CACHE_FILE;
505
+ const nowMs = config.nowMs ?? Date.now();
506
+ const cachedOutcome = readClaudeCacheOutcome(cacheFile, nowMs);
507
+ if (cachedOutcome) return cachedOutcome;
508
+ if (config.signal?.aborted) return { session: 0, weekly: 0, error: "request cancelled" };
509
+
510
+ const lockFile = `${cacheFile}.claude.lock`;
511
+ const releaseLock = await acquireFileLock(lockFile, config.signal);
512
+ if (!releaseLock) {
513
+ const waitedOutcome = readClaudeCacheOutcome(cacheFile, nowMs);
514
+ if (waitedOutcome) return waitedOutcome;
515
+ if (config.signal?.aborted) return { session: 0, weekly: 0, error: "request cancelled" };
516
+ }
517
+
518
+ try {
519
+ const lockOutcome = readClaudeCacheOutcome(cacheFile, nowMs);
520
+ if (lockOutcome) return lockOutcome;
521
+
522
+ let state = readClaudeCacheState(cacheFile);
523
+ const attempt = await fetchClaudeUsageAttempt(token, config, nowMs);
524
+ if (!attempt.usage.error) {
525
+ state = clearClaudeCooldown(state);
526
+ state.lastSuccess = snapshotUsage(attempt.usage, nowMs);
527
+ state.lastSuccessAt = nowMs;
528
+ writeClaudeCacheState(state, cacheFile);
529
+ return attempt.usage;
530
+ }
531
+
532
+ if (attempt.status === 429) {
533
+ const consecutive429s = Math.max(1, (state.consecutive429s ?? 0) + 1);
534
+ const cooldownUntil = nowMs + computeClaudeBackoffMs({ ...state, consecutive429s }, attempt.retryAfterMs);
535
+ state = { ...state, cooldownUntil, consecutive429s, lastError: attempt.usage.error };
536
+ writeClaudeCacheState(state, cacheFile);
537
+ return state.lastSuccess
538
+ ? staleCachedUsage(state.lastSuccess, cooldownMessage(cooldownUntil, nowMs), nowMs)
539
+ : { session: 0, weekly: 0, error: `${attempt.usage.error}; ${cooldownMessage(cooldownUntil, nowMs)}` };
540
+ }
541
+
542
+ return attempt.usage;
543
+ } finally {
544
+ releaseLock?.();
545
+ }
546
+ }
547
+
548
+ /** Parse ZAI limits where unit 3 is the five-hour window and unit 6 the weekly window. */
549
+ export function extractZaiUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
550
+ const data = payload as any;
551
+ const arrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits];
552
+ const limits = arrays.find(Array.isArray) as any[] | undefined;
553
+ if (!limits?.length) return null;
554
+
555
+ const tokenLimits = limits.filter((entry) => String(entry?.type || "").toUpperCase() === "TOKENS_LIMIT");
556
+ const sessionEntry = tokenLimits.find((entry) => entry?.unit === 3);
557
+ const weeklyEntry = tokenLimits.find((entry) => entry?.unit === 6);
558
+ if (!sessionEntry || !weeklyEntry) return null;
559
+
560
+ const session = readPercentCandidate(sessionEntry.percentage);
561
+ const weekly = readPercentCandidate(weeklyEntry.percentage);
562
+ if (session === null || weekly === null) return null;
563
+ const normalized = normalizeUsagePair(session, weekly);
564
+
565
+ return {
566
+ ...normalized,
567
+ sessionResetsIn: typeof sessionEntry.nextResetTime === "number" && sessionEntry.nextResetTime > 0
568
+ ? formatDuration(Math.max(0, sessionEntry.nextResetTime - nowMs) / 1000)
569
+ : undefined,
570
+ weeklyResetsIn: typeof weeklyEntry.nextResetTime === "number" && weeklyEntry.nextResetTime > 0
571
+ ? formatDuration(Math.max(0, weeklyEntry.nextResetTime - nowMs) / 1000)
572
+ : undefined,
573
+ };
574
+ }
575
+
576
+ export async function fetchZaiUsage(
577
+ token: string,
578
+ provider: "zai" | "zai-cn" = "zai",
579
+ config: FetchConfig = {},
580
+ ): Promise<UsageData> {
581
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
582
+ const endpoint = provider === "zai-cn" ? endpoints.zaiCn : endpoints.zai;
583
+ const result = await requestJson(endpoint, { headers: { Authorization: `Bearer ${token}` } }, config);
584
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
585
+
586
+ const zaiUsage = extractZaiUsageFromPayload(result.data);
587
+ if (zaiUsage) return zaiUsage;
588
+ return extractUsageFromPayload(result.data) ?? { session: 0, weekly: 0, error: "unrecognized response shape" };
589
+ }
590
+
591
+ export function detectProvider(
592
+ model: { provider?: string } | string | undefined | null,
593
+ ): ProviderKey | null {
594
+ if (!model || typeof model === "string") return null;
595
+ switch ((model.provider || "").toLowerCase()) {
596
+ case "openai-codex": return "codex";
597
+ case "anthropic": return "claude";
598
+ case "zai": return "zai";
599
+ case "zai-coding-cn": return "zai-cn";
600
+ default: return null;
601
+ }
602
+ }
603
+
604
+ export function providerToPiProviderId(provider: ProviderKey): PiProviderId {
605
+ switch (provider) {
606
+ case "codex": return "openai-codex";
607
+ case "claude": return "anthropic";
608
+ case "zai": return "zai";
609
+ case "zai-cn": return "zai-coding-cn";
610
+ }
611
+ }
612
+
613
+ export function clampPercent(value: number): number {
614
+ if (!Number.isFinite(value)) return 0;
615
+ return Math.max(0, Math.min(100, Math.round(value)));
616
+ }
617
+
618
+ export function colorForPercent(value: number): "success" | "warning" | "error" {
619
+ if (value >= 90) return "error";
620
+ if (value >= 70) return "warning";
621
+ return "success";
622
+ }
623
+
624
+ export async function fetchAllUsages(
625
+ tokens: UsageTokens,
626
+ config: FetchAllUsagesConfig = {},
627
+ ): Promise<UsageByProvider> {
628
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
629
+ const results: UsageByProvider = { codex: null, claude: null, zai: null, "zai-cn": null };
630
+ const tasks: Promise<void>[] = [];
631
+
632
+ const assign = (provider: ProviderKey, request: Promise<UsageData>) => {
633
+ tasks.push(request.then((usage) => {
634
+ results[provider] = usage;
635
+ }).catch((error) => {
636
+ results[provider] = { session: 0, weekly: 0, error: toErrorMessage(error, config.signal) };
637
+ }));
638
+ };
639
+
640
+ if (tokens.codex) assign("codex", fetchCodexUsage(tokens.codex, config));
641
+ if (tokens.claude) {
642
+ assign("claude", fetchClaudeUsageWithFallback(tokens.claude, {
643
+ ...config,
644
+ cacheFile: config.cacheFile,
645
+ nowMs: config.nowMs,
646
+ }));
647
+ }
648
+ if (tokens.zai) assign("zai", fetchZaiUsage(tokens.zai, "zai", { ...config, endpoints }));
649
+ if (tokens["zai-cn"]) assign("zai-cn", fetchZaiUsage(tokens["zai-cn"], "zai-cn", { ...config, endpoints }));
650
+
651
+ await Promise.all(tasks);
652
+ return results;
653
+ }
@@ -0,0 +1,517 @@
1
+ /** Usage bars for subscription-backed providers supported by current Pi releases. */
2
+
3
+ import {
4
+ DynamicBorder,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ type KeybindingsManager,
8
+ type Theme,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ Container,
12
+ Input,
13
+ Spacer,
14
+ Text,
15
+ type Focusable,
16
+ type TUI,
17
+ } from "@earendil-works/pi-tui";
18
+ import {
19
+ clampPercent,
20
+ colorForPercent,
21
+ detectProvider,
22
+ fetchAllUsages,
23
+ fetchClaudeUsageWithFallback,
24
+ fetchCodexUsage,
25
+ fetchZaiUsage,
26
+ providerToPiProviderId,
27
+ resolveUsageEndpoints,
28
+ type ProviderKey,
29
+ type UsageByProvider,
30
+ type UsageData,
31
+ type UsageTokens,
32
+ } from "./core";
33
+
34
+ const POLL_INTERVAL_MS = 2 * 60 * 1000;
35
+ const STATUS_KEY = "usage-bars";
36
+ const PROVIDERS: readonly ProviderKey[] = ["codex", "claude", "zai", "zai-cn"];
37
+
38
+ const PROVIDER_LABELS: Record<ProviderKey, string> = {
39
+ codex: "Codex",
40
+ claude: "Claude",
41
+ zai: "ZAI Coding Plan (Global)",
42
+ "zai-cn": "ZAI Coding Plan (China)",
43
+ };
44
+
45
+ interface SubscriptionItem {
46
+ name: string;
47
+ provider: ProviderKey;
48
+ data: UsageData;
49
+ isActive: boolean;
50
+ }
51
+
52
+ interface CredentialResolution {
53
+ token?: string;
54
+ error?: string;
55
+ }
56
+
57
+ class UsageSelectorComponent extends Container implements Focusable {
58
+ private readonly searchInput: Input;
59
+ private readonly listContainer: Container;
60
+ private readonly hintText: Text;
61
+ private readonly requestController = new AbortController();
62
+ private readonly tui: TUI;
63
+ private readonly theme: Theme;
64
+ private readonly keybindings: KeybindingsManager;
65
+ private readonly onCancelCallback: () => void;
66
+ private readonly activeProvider: ProviderKey | null;
67
+ private readonly fetchAllFn: (signal: AbortSignal) => Promise<UsageByProvider>;
68
+ private allItems: SubscriptionItem[] = [];
69
+ private filteredItems: SubscriptionItem[] = [];
70
+ private selectedIndex = 0;
71
+ private loading = true;
72
+ private hint: "loading" | "ready" | "error" = "loading";
73
+ private disposed = false;
74
+ private _focused = false;
75
+
76
+ get focused(): boolean {
77
+ return this._focused;
78
+ }
79
+
80
+ set focused(value: boolean) {
81
+ this._focused = value;
82
+ this.searchInput.focused = value;
83
+ }
84
+
85
+ constructor(
86
+ tui: TUI,
87
+ theme: Theme,
88
+ keybindings: KeybindingsManager,
89
+ activeProvider: ProviderKey | null,
90
+ fetchAll: (signal: AbortSignal) => Promise<UsageByProvider>,
91
+ onCancel: () => void,
92
+ ) {
93
+ super();
94
+ this.tui = tui;
95
+ this.theme = theme;
96
+ this.keybindings = keybindings;
97
+ this.activeProvider = activeProvider;
98
+ this.fetchAllFn = fetchAll;
99
+ this.onCancelCallback = onCancel;
100
+
101
+ this.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
102
+ this.addChild(new Spacer(1));
103
+ this.hintText = new Text("", 0, 0);
104
+ this.addChild(this.hintText);
105
+ this.addChild(new Spacer(1));
106
+ this.searchInput = new Input();
107
+ this.addChild(this.searchInput);
108
+ this.addChild(new Spacer(1));
109
+ this.listContainer = new Container();
110
+ this.addChild(this.listContainer);
111
+ this.addChild(new Spacer(1));
112
+ this.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
113
+
114
+ this.updateHint();
115
+ this.updateList();
116
+ void this.load();
117
+ }
118
+
119
+ private async load(): Promise<void> {
120
+ try {
121
+ const results = await this.fetchAllFn(this.requestController.signal);
122
+ if (this.disposed || this.requestController.signal.aborted) return;
123
+ this.loading = false;
124
+ this.hint = "ready";
125
+ this.buildItems(results);
126
+ } catch {
127
+ if (this.disposed || this.requestController.signal.aborted) return;
128
+ this.loading = false;
129
+ this.hint = "error";
130
+ }
131
+ this.updateHint();
132
+ this.updateList();
133
+ this.tui.requestRender();
134
+ }
135
+
136
+ private updateHint(): void {
137
+ if (this.hint === "loading") {
138
+ this.hintText.setText(this.theme.fg("dim", "Fetching usage from configured subscription providers…"));
139
+ } else if (this.hint === "error") {
140
+ this.hintText.setText(this.theme.fg("error", "Failed to fetch usage data"));
141
+ } else {
142
+ this.hintText.setText(
143
+ this.theme.fg("muted", "Only showing configured subscription providers. ") +
144
+ this.theme.fg("dim", "✓ = active provider"),
145
+ );
146
+ }
147
+ }
148
+
149
+ private buildItems(results: UsageByProvider): void {
150
+ this.allItems = PROVIDERS.flatMap((provider) => {
151
+ const data = results[provider];
152
+ return data
153
+ ? [{
154
+ name: PROVIDER_LABELS[provider],
155
+ provider,
156
+ data,
157
+ isActive: this.activeProvider === provider,
158
+ }]
159
+ : [];
160
+ });
161
+ this.filteredItems = this.allItems;
162
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
163
+ }
164
+
165
+ private filterItems(query: string): void {
166
+ const normalized = query.trim().toLowerCase();
167
+ this.filteredItems = normalized
168
+ ? this.allItems.filter((item) =>
169
+ item.name.toLowerCase().includes(normalized) || item.provider.includes(normalized))
170
+ : this.allItems;
171
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
172
+ }
173
+
174
+ private renderBar(percent: number, width = 16): string {
175
+ const value = clampPercent(percent);
176
+ const filled = Math.round((value / 100) * width);
177
+ return this.theme.fg(colorForPercent(value), "█".repeat(filled)) +
178
+ this.theme.fg("dim", "░".repeat(width - filled));
179
+ }
180
+
181
+ private renderItem(item: SubscriptionItem, selected: boolean): void {
182
+ const theme = this.theme;
183
+ const pointer = selected ? theme.fg("accent", "→ ") : " ";
184
+ const activeBadge = item.isActive ? theme.fg("success", " ✓") : "";
185
+ const name = selected ? theme.fg("accent", theme.bold(item.name)) : item.name;
186
+ this.listContainer.addChild(new Text(`${pointer}${name}${activeBadge}`, 0, 0));
187
+
188
+ const indent = " ";
189
+ if (item.data.error) {
190
+ this.listContainer.addChild(new Text(indent + theme.fg("error", item.data.error), 0, 0));
191
+ } else {
192
+ const session = clampPercent(item.data.session);
193
+ const weekly = clampPercent(item.data.weekly);
194
+ const sessionReset = item.data.sessionResetsIn
195
+ ? theme.fg("dim", ` resets in ${item.data.sessionResetsIn}`)
196
+ : "";
197
+ const weeklyReset = item.data.weeklyResetsIn
198
+ ? theme.fg("dim", ` resets in ${item.data.weeklyResetsIn}`)
199
+ : "";
200
+
201
+ this.listContainer.addChild(new Text(
202
+ indent + theme.fg("muted", "Session ") + this.renderBar(session) + " " +
203
+ theme.fg(colorForPercent(session), `${session}%`.padStart(4)) + sessionReset,
204
+ 0,
205
+ 0,
206
+ ));
207
+ this.listContainer.addChild(new Text(
208
+ indent + theme.fg("muted", "Weekly ") + this.renderBar(weekly) + " " +
209
+ theme.fg(colorForPercent(weekly), `${weekly}%`.padStart(4)) + weeklyReset,
210
+ 0,
211
+ 0,
212
+ ));
213
+
214
+ if (typeof item.data.extraSpend === "number" && typeof item.data.extraLimit === "number") {
215
+ this.listContainer.addChild(new Text(
216
+ indent + theme.fg("muted", "Extra ") +
217
+ theme.fg("dim", `$${item.data.extraSpend.toFixed(2)} / $${item.data.extraLimit}`),
218
+ 0,
219
+ 0,
220
+ ));
221
+ }
222
+ if (item.data.warning) {
223
+ this.listContainer.addChild(new Text(indent + theme.fg("warning", `⚠ ${item.data.warning}`), 0, 0));
224
+ }
225
+ }
226
+ this.listContainer.addChild(new Spacer(1));
227
+ }
228
+
229
+ private updateList(): void {
230
+ this.listContainer.clear();
231
+ if (this.loading) {
232
+ this.listContainer.addChild(new Text(this.theme.fg("muted", " Loading…"), 0, 0));
233
+ return;
234
+ }
235
+ if (this.filteredItems.length === 0) {
236
+ this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching configured providers"), 0, 0));
237
+ return;
238
+ }
239
+ this.filteredItems.forEach((item, index) => this.renderItem(item, index === this.selectedIndex));
240
+ }
241
+
242
+ private refresh(): void {
243
+ this.updateList();
244
+ this.tui.requestRender();
245
+ }
246
+
247
+ handleInput(keyData: string): void {
248
+ if (this.keybindings.matches(keyData, "tui.select.up")) {
249
+ if (this.filteredItems.length > 0) {
250
+ this.selectedIndex = this.selectedIndex === 0
251
+ ? this.filteredItems.length - 1
252
+ : this.selectedIndex - 1;
253
+ this.refresh();
254
+ }
255
+ return;
256
+ }
257
+ if (this.keybindings.matches(keyData, "tui.select.down")) {
258
+ if (this.filteredItems.length > 0) {
259
+ this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1
260
+ ? 0
261
+ : this.selectedIndex + 1;
262
+ this.refresh();
263
+ }
264
+ return;
265
+ }
266
+ if (
267
+ this.keybindings.matches(keyData, "tui.select.cancel") ||
268
+ this.keybindings.matches(keyData, "tui.select.confirm")
269
+ ) {
270
+ this.onCancelCallback();
271
+ return;
272
+ }
273
+
274
+ this.searchInput.handleInput(keyData);
275
+ this.filterItems(this.searchInput.getValue());
276
+ this.refresh();
277
+ }
278
+
279
+ override invalidate(): void {
280
+ super.invalidate();
281
+ this.updateHint();
282
+ this.updateList();
283
+ }
284
+
285
+ dispose(): void {
286
+ this.disposed = true;
287
+ this.requestController.abort();
288
+ }
289
+ }
290
+
291
+ interface UsageState extends UsageByProvider {
292
+ activeProvider: ProviderKey | null;
293
+ available: Partial<Record<ProviderKey, boolean>>;
294
+ }
295
+
296
+ export default function (pi: ExtensionAPI): void {
297
+ const endpoints = resolveUsageEndpoints();
298
+ const state: UsageState = {
299
+ codex: null,
300
+ claude: null,
301
+ zai: null,
302
+ "zai-cn": null,
303
+ activeProvider: null,
304
+ available: {},
305
+ };
306
+
307
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
308
+ let pollInFlight: Promise<void> | undefined;
309
+ let pollQueued = false;
310
+ let currentContext: ExtensionContext | undefined;
311
+ let sessionController: AbortController | undefined;
312
+
313
+ const renderPercent = (theme: Theme, value: number) => {
314
+ const percent = clampPercent(value);
315
+ return theme.fg(colorForPercent(percent), `${percent}%`);
316
+ };
317
+
318
+ const renderBar = (theme: Theme, value: number) => {
319
+ const percent = clampPercent(value);
320
+ const width = 8;
321
+ const filled = Math.round((percent / 100) * width);
322
+ return theme.fg(colorForPercent(percent), "█".repeat(filled)) +
323
+ theme.fg("dim", "░".repeat(width - filled));
324
+ };
325
+
326
+ function updateStatus(): void {
327
+ const ctx = currentContext;
328
+ if (!ctx || ctx.mode !== "tui") return;
329
+ const provider = state.activeProvider;
330
+ if (!provider || state.available[provider] === false) {
331
+ ctx.ui.setStatus(STATUS_KEY, undefined);
332
+ return;
333
+ }
334
+
335
+ const data = state[provider];
336
+ const theme = ctx.ui.theme;
337
+ const label = PROVIDER_LABELS[provider];
338
+ if (!data) {
339
+ ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", `${label} usage: loading…`));
340
+ return;
341
+ }
342
+ if (data.error) {
343
+ ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `${label} usage unavailable (${data.error})`));
344
+ return;
345
+ }
346
+
347
+ const session = clampPercent(data.session);
348
+ const weekly = clampPercent(data.weekly);
349
+ const status =
350
+ theme.fg("dim", `${label} `) +
351
+ theme.fg("muted", "S ") + renderBar(theme, session) + " " + renderPercent(theme, session) +
352
+ (data.sessionResetsIn ? theme.fg("dim", ` ⟳ ${data.sessionResetsIn}`) : "") +
353
+ theme.fg("muted", " W ") + renderBar(theme, weekly) + " " + renderPercent(theme, weekly) +
354
+ (data.weeklyResetsIn ? theme.fg("dim", ` ⟳ ${data.weeklyResetsIn}`) : "") +
355
+ (data.stale ? theme.fg("warning", " stale") : "") +
356
+ (data.warning && !data.stale ? theme.fg("warning", " ⚠") : "");
357
+ ctx.ui.setStatus(STATUS_KEY, status);
358
+ }
359
+
360
+ function updateProviderFrom(model: ExtensionContext["model"]): boolean {
361
+ const previous = state.activeProvider;
362
+ state.activeProvider = detectProvider(model);
363
+ if (previous !== state.activeProvider) {
364
+ updateStatus();
365
+ return true;
366
+ }
367
+ return false;
368
+ }
369
+
370
+ async function resolveCredential(ctx: ExtensionContext, provider: ProviderKey): Promise<CredentialResolution> {
371
+ const providerId = providerToPiProviderId(provider);
372
+ if (!ctx.modelRegistry.getProvider(providerId)) return {};
373
+ const status = ctx.modelRegistry.getProviderAuthStatus(providerId);
374
+ if (!status.configured) return {};
375
+
376
+ try {
377
+ const resolved = await ctx.modelRegistry.getProviderAuth(providerId);
378
+ if (provider === "claude" && resolved?.source !== "OAuth") return {};
379
+ const token = resolved?.auth.apiKey;
380
+ return token ? { token } : { error: "configured authentication did not resolve a token" };
381
+ } catch (error) {
382
+ return { error: error instanceof Error ? error.message : String(error) };
383
+ }
384
+ }
385
+
386
+ async function fetchProvider(
387
+ ctx: ExtensionContext,
388
+ provider: ProviderKey,
389
+ signal: AbortSignal,
390
+ ): Promise<void> {
391
+ const credential = await resolveCredential(ctx, provider);
392
+ if (signal.aborted) return;
393
+ state.available[provider] = Boolean(credential.token || credential.error);
394
+ if (credential.error) {
395
+ state[provider] = { session: 0, weekly: 0, error: `auth resolution failed (${credential.error})` };
396
+ return;
397
+ }
398
+ if (!credential.token) {
399
+ state[provider] = null;
400
+ return;
401
+ }
402
+
403
+ if (provider === "codex") state.codex = await fetchCodexUsage(credential.token, { signal });
404
+ if (provider === "claude") state.claude = await fetchClaudeUsageWithFallback(credential.token, { signal });
405
+ if (provider === "zai") state.zai = await fetchZaiUsage(credential.token, "zai", { endpoints, signal });
406
+ if (provider === "zai-cn") state["zai-cn"] = await fetchZaiUsage(credential.token, "zai-cn", { endpoints, signal });
407
+ }
408
+
409
+ async function runPoll(): Promise<void> {
410
+ const ctx = currentContext;
411
+ const signal = sessionController?.signal;
412
+ const provider = state.activeProvider;
413
+ if (!ctx || !signal || signal.aborted || ctx.mode !== "tui" || !provider) {
414
+ updateStatus();
415
+ return;
416
+ }
417
+
418
+ await fetchProvider(ctx, provider, signal);
419
+ if (signal.aborted) return;
420
+ const data = state[provider];
421
+ if (data && !data.error) pi.events.emit("usage:update", { provider, ...data });
422
+ updateStatus();
423
+ }
424
+
425
+ async function poll(): Promise<void> {
426
+ if (pollInFlight) {
427
+ pollQueued = true;
428
+ return pollInFlight;
429
+ }
430
+ do {
431
+ pollQueued = false;
432
+ pollInFlight = runPoll().catch(() => undefined).finally(() => {
433
+ pollInFlight = undefined;
434
+ });
435
+ await pollInFlight;
436
+ } while (pollQueued && !sessionController?.signal.aborted);
437
+ }
438
+
439
+ async function fetchAllForContext(ctx: ExtensionContext, signal: AbortSignal): Promise<UsageByProvider> {
440
+ const resolutions = await Promise.all(PROVIDERS.map(async (provider) =>
441
+ [provider, await resolveCredential(ctx, provider)] as const));
442
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
443
+
444
+ const tokens: UsageTokens = {};
445
+ const authErrors: Partial<Record<ProviderKey, string>> = {};
446
+ for (const [provider, resolution] of resolutions) {
447
+ if (resolution.token) tokens[provider] = resolution.token;
448
+ if (resolution.error) authErrors[provider] = resolution.error;
449
+ }
450
+
451
+ const results = await fetchAllUsages(tokens, { endpoints, signal });
452
+ for (const provider of PROVIDERS) {
453
+ const error = authErrors[provider];
454
+ if (error) results[provider] = { session: 0, weekly: 0, error: `auth resolution failed (${error})` };
455
+ }
456
+ return results;
457
+ }
458
+
459
+ pi.on("session_start", (_event, ctx) => {
460
+ currentContext = ctx;
461
+ sessionController?.abort();
462
+ sessionController = new AbortController();
463
+ updateProviderFrom(ctx.model);
464
+
465
+ if (pollTimer) clearInterval(pollTimer);
466
+ pollTimer = undefined;
467
+ if (ctx.mode !== "tui") return;
468
+
469
+ updateStatus();
470
+ void poll();
471
+ pollTimer = setInterval(() => void poll(), POLL_INTERVAL_MS);
472
+ });
473
+
474
+ pi.on("session_shutdown", (_event, ctx) => {
475
+ sessionController?.abort();
476
+ sessionController = undefined;
477
+ pollQueued = false;
478
+ if (pollTimer) clearInterval(pollTimer);
479
+ pollTimer = undefined;
480
+ if (ctx.mode === "tui") ctx.ui.setStatus(STATUS_KEY, undefined);
481
+ currentContext = undefined;
482
+ });
483
+
484
+ pi.on("turn_start", (_event, ctx) => {
485
+ currentContext = ctx;
486
+ if (updateProviderFrom(ctx.model)) void poll();
487
+ });
488
+
489
+ pi.on("model_select", (event, ctx) => {
490
+ currentContext = ctx;
491
+ updateProviderFrom(event.model);
492
+ void poll();
493
+ });
494
+
495
+ pi.registerCommand("usage", {
496
+ description: "Show subscription usage for configured providers",
497
+ handler: async (_args, ctx) => {
498
+ currentContext = ctx;
499
+ updateProviderFrom(ctx.model);
500
+ if (ctx.mode !== "tui") {
501
+ if (ctx.hasUI) ctx.ui.notify("/usage is available in interactive mode", "warning");
502
+ return;
503
+ }
504
+
505
+ await ctx.ui.custom<void>((tui, theme, keybindings, done) =>
506
+ new UsageSelectorComponent(
507
+ tui,
508
+ theme,
509
+ keybindings,
510
+ state.activeProvider,
511
+ (signal) => fetchAllForContext(ctx, signal),
512
+ () => done(),
513
+ ));
514
+ void poll();
515
+ },
516
+ });
517
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@hk_net/pi-usage-bars",
3
+ "version": "0.3.0",
4
+ "description": "Subscription usage bars for current Pi providers (Codex, Claude, and ZAI Coding Plan)",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "extension",
9
+ "usage",
10
+ "codex",
11
+ "anthropic",
12
+ "zai"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/hknet/pi-usage-bars.git"
18
+ },
19
+ "homepage": "https://github.com/hknet/pi-usage-bars#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/hknet/pi-usage-bars/issues"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "type": "module",
28
+ "files": [
29
+ "extensions",
30
+ "README.md",
31
+ "CHANGELOG.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "test": "bun test tests",
36
+ "typecheck": "tsc --noEmit",
37
+ "smoke": "pi -e ./extensions/usage-bars/index.ts --list-models",
38
+ "check": "npm run typecheck && npm test && npm run smoke",
39
+ "prepublishOnly": "npm run check"
40
+ },
41
+ "engines": {
42
+ "node": ">=22.19.0"
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": ">=0.81.1",
46
+ "@earendil-works/pi-tui": ">=0.81.1"
47
+ },
48
+ "devDependencies": {
49
+ "@earendil-works/pi-coding-agent": "0.81.1",
50
+ "@earendil-works/pi-tui": "0.81.1",
51
+ "@types/bun": "1.3.14",
52
+ "@types/node": "24.13.3",
53
+ "typescript": "5.9.3"
54
+ },
55
+ "pi": {
56
+ "extensions": [
57
+ "./extensions/usage-bars/index.ts"
58
+ ],
59
+ "image": "https://raw.githubusercontent.com/hknet/pi-usage-bars/main/assets/usage-command.png"
60
+ }
61
+ }