pi-fancy-footer 1.2.0 → 1.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/README.md CHANGED
@@ -15,7 +15,7 @@ pi install npm:pi-fancy-footer
15
15
  ## 📊 What it shows
16
16
 
17
17
  - Active model + thinking level
18
- - Provider quota status for OpenAI Codex
18
+ - Provider quota status for OpenAI Codex and Claude models
19
19
  - Context window capacity and a mini gauge of remaining context
20
20
  - Total session cost
21
21
  - Repo / path, branch, commit, open PR number, unresolved PR review
@@ -71,7 +71,7 @@ Create `~/.pi/agent/fancy-footer.json`:
71
71
  "providerStatus": {
72
72
  "refreshMs": 60000,
73
73
  "cacheTtlMs": 60000,
74
- "providers": ["openai-codex"],
74
+ "providers": ["openai-codex", "anthropic"],
75
75
  "display": "gauge",
76
76
  "showCredits": false,
77
77
  "showReset": false
@@ -125,7 +125,7 @@ Top-level settings:
125
125
  - `providerStatus`:
126
126
  - `refreshMs` - provider status refresh interval in milliseconds
127
127
  - `cacheTtlMs` - cache freshness window in milliseconds
128
- - `providers` - supported provider adapters (`openai-codex`)
128
+ - `providers` - supported provider adapters (`openai-codex`, `anthropic`)
129
129
  - `display` - render quota windows as a mini `gauge` (default) or plain
130
130
  `text`
131
131
  - `showCredits` - include provider-specific credit balance when available
@@ -265,18 +265,24 @@ Notes:
265
265
  - `pull-request-ci-status` is icon-only and uses symbols for running / failed /
266
266
  okay status. By default it uses semantic colors (warning / error / success);
267
267
  set this widget's icon color to override them.
268
- - `provider-status` shows provider quota windows, currently for OpenAI Codex,
269
- as battery-style mini gauges per window, e.g. `5h ▰▰▰▰▱ 80% 7d ▰▰▱▱▱ 38%`,
268
+ - `provider-status` shows provider quota windows for OpenAI Codex and Claude
269
+ models as battery-style mini gauges per window, e.g.
270
+ `5h ▰▰▰▰▱ 80% 7d ▰▰▱▱▱ 38%`,
270
271
  where filled cells show the remaining quota and each window is colored by
271
272
  how close it is to exhaustion. The gauge spans `gaugeWidth` cells and
272
273
  reuses the configured `gaugeStyle` glyphs; set `providerStatus.display` to
273
274
  `text` for the
274
- compact `5h:95% 7d:97%` form. It uses existing pi OpenAI Codex credentials from
275
- `~/.pi/agent/auth.json`, falling back to Codex CLI credentials in
276
- `~/.codex/auth.json`, and caches status under
277
- `~/.cache/pi-fancy-footer/provider-status/`.
275
+ compact `5h:95% 7d:97%` form. Codex uses existing pi OpenAI Codex credentials
276
+ from `~/.pi/agent/auth.json`, falling back to Codex CLI credentials in
277
+ `~/.codex/auth.json`. Claude uses pi Anthropic OAuth credentials from
278
+ `~/.pi/agent/auth.json` and reads Claude.ai usage for the 5-hour and weekly
279
+ windows. Status is cached under `~/.cache/pi-fancy-footer/provider-status/`.
280
+ The widget is hidden when the active model selection is not backed by the
281
+ status provider.
278
282
  - `provider-status` also refreshes from `x-codex-*` provider response headers
279
- when pi exposes them, avoiding a separate status request after provider calls.
283
+ when pi exposes them, avoiding a separate Codex status request after provider
284
+ calls. Claude status refreshes from the Claude.ai usage endpoint, not
285
+ provider response headers.
280
286
  - `iconFamily` lets you choose between `nerd`, `emoji`, `unicode`, and
281
287
  `ascii` palettes.
282
288
  - `nerd` keeps the original Nerd Font look. `emoji`, `unicode`, and `ascii`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fancy-footer",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A fancy footer extension for pi",
5
5
  "type": "module",
6
6
  "peerDependencies": {
@@ -10,6 +10,7 @@ test("footerConfigValidationErrors accepts a valid config", () => {
10
10
  footerConfigValidationErrors({
11
11
  gaugeStyle: "bars",
12
12
  gaugeWidth: 8,
13
+ providerStatus: { providers: ["openai-codex", "anthropic"] },
13
14
  widgets: { "context-bar": { row: 0 } },
14
15
  }),
15
16
  [],
@@ -7,6 +7,8 @@ import {
7
7
  buildProviderStatusGauge,
8
8
  collectProviderStatus,
9
9
  formatProviderStatusText,
10
+ isProviderStatusRelevantToModel,
11
+ normalizeClaudeUsageResponse,
10
12
  normalizeCodexUsageResponse,
11
13
  parseCodexRateLimitHeaders,
12
14
  providerStatusColor,
@@ -26,6 +28,10 @@ const providerStatusConfig: ProviderStatusConfigSnapshot = {
26
28
  showCredits: false,
27
29
  showReset: false,
28
30
  };
31
+ const anthropicProviderStatusConfig: ProviderStatusConfigSnapshot = {
32
+ ...providerStatusConfig,
33
+ providers: ["anthropic"],
34
+ };
29
35
 
30
36
  test("normalizeCodexUsageResponse extracts primary and secondary quota windows", () => {
31
37
  const snapshot = normalizeCodexUsageResponse(
@@ -63,6 +69,63 @@ test("normalizeCodexUsageResponse extracts primary and secondary quota windows",
63
69
  assert.equal(snapshot?.state, "ok");
64
70
  });
65
71
 
72
+ test("normalizeClaudeUsageResponse extracts five-hour and weekly usage windows", () => {
73
+ const fiveHourReset = "2026-06-16T15:20:00.365459+00:00";
74
+ const weeklyReset = "2026-06-16T23:00:00.365483+00:00";
75
+ const snapshot = normalizeClaudeUsageResponse(
76
+ {
77
+ five_hour: {
78
+ utilization: 0,
79
+ resets_at: fiveHourReset,
80
+ },
81
+ seven_day: {
82
+ utilization: 8,
83
+ resets_at: weeklyReset,
84
+ },
85
+ seven_day_sonnet: {
86
+ utilization: 99,
87
+ resets_at: weeklyReset,
88
+ },
89
+ extra_usage: {
90
+ utilization: 75,
91
+ },
92
+ },
93
+ now,
94
+ );
95
+
96
+ assert.deepEqual(snapshot?.primary, {
97
+ label: "5h",
98
+ usedPercent: 0,
99
+ leftPercent: 100,
100
+ resetAt: Math.round(Date.parse(fiveHourReset) / 1000),
101
+ });
102
+ assert.deepEqual(snapshot?.secondary, {
103
+ label: "7d",
104
+ usedPercent: 8,
105
+ leftPercent: 92,
106
+ resetAt: Math.round(Date.parse(weeklyReset) / 1000),
107
+ });
108
+ assert.equal(snapshot?.provider, "anthropic");
109
+ assert.equal(snapshot?.state, "ok");
110
+ });
111
+
112
+ test("normalizeClaudeUsageResponse ignores responses without supported windows", () => {
113
+ assert.equal(
114
+ normalizeClaudeUsageResponse(
115
+ {
116
+ seven_day_sonnet: {
117
+ utilization: 10,
118
+ },
119
+ extra_usage: {
120
+ utilization: 20,
121
+ },
122
+ },
123
+ now,
124
+ ),
125
+ undefined,
126
+ );
127
+ });
128
+
66
129
  test("parseCodexRateLimitHeaders accepts case-insensitive x-codex headers", () => {
67
130
  const snapshot = parseCodexRateLimitHeaders(
68
131
  {
@@ -137,6 +200,62 @@ test("formatProviderStatusText can show provider-specific credits without window
137
200
  );
138
201
  });
139
202
 
203
+ test("isProviderStatusRelevantToModel limits Codex status to OpenAI-like models", () => {
204
+ assert.equal(
205
+ isProviderStatusRelevantToModel("openai-codex", {
206
+ provider: "anthropic",
207
+ id: "claude-sonnet-4",
208
+ name: "Claude Sonnet 4",
209
+ }),
210
+ false,
211
+ );
212
+ assert.equal(
213
+ isProviderStatusRelevantToModel("openai-codex", {
214
+ id: "gpt-5-codex",
215
+ name: "GPT-5 Codex",
216
+ }),
217
+ true,
218
+ );
219
+ assert.equal(
220
+ isProviderStatusRelevantToModel("openai-codex", {
221
+ provider: "openai",
222
+ id: "o3",
223
+ }),
224
+ true,
225
+ );
226
+ assert.equal(
227
+ isProviderStatusRelevantToModel("other-provider", {
228
+ id: "claude-sonnet-4",
229
+ }),
230
+ true,
231
+ );
232
+ });
233
+
234
+ test("isProviderStatusRelevantToModel limits Anthropic status to Claude-like models", () => {
235
+ assert.equal(
236
+ isProviderStatusRelevantToModel("anthropic", {
237
+ provider: "anthropic",
238
+ id: "claude-sonnet-4",
239
+ name: "Claude Sonnet 4",
240
+ }),
241
+ true,
242
+ );
243
+ assert.equal(
244
+ isProviderStatusRelevantToModel("anthropic", {
245
+ id: "claude-opus-4",
246
+ }),
247
+ true,
248
+ );
249
+ assert.equal(
250
+ isProviderStatusRelevantToModel("anthropic", {
251
+ provider: "openai",
252
+ id: "gpt-5-codex",
253
+ }),
254
+ false,
255
+ );
256
+ assert.equal(isProviderStatusRelevantToModel("anthropic", undefined), false);
257
+ });
258
+
140
259
  test("providerStatusColor derives semantic color from status", () => {
141
260
  assert.equal(
142
261
  providerStatusColor(
@@ -233,6 +352,18 @@ test("updateProviderStatusFromHeaders honors disabled providers", async () => {
233
352
  assert.deepEqual(updated, []);
234
353
  });
235
354
 
355
+ test("updateProviderStatusFromHeaders ignores Anthropic response headers", async () => {
356
+ const updated = await updateProviderStatusFromHeaders(
357
+ {
358
+ "anthropic-ratelimit-requests-remaining": "10",
359
+ "anthropic-ratelimit-tokens-remaining": "1000",
360
+ },
361
+ anthropicProviderStatusConfig,
362
+ );
363
+
364
+ assert.deepEqual(updated, []);
365
+ });
366
+
236
367
  test("updateProviderStatusFromHeaders does not merge expired cached windows", async (t) => {
237
368
  const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
238
369
  t.after(async () => {
@@ -16,9 +16,15 @@ import {
16
16
  export const CODEX_USAGE_URL = "https://chatgpt.com/codex/settings/usage";
17
17
  const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
18
18
  const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
19
- const TOKEN_URL = "https://auth.openai.com/oauth/token";
19
+ const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
20
20
  const CODEX_PRIMARY_WINDOW_LABEL = "5h";
21
21
  const CODEX_SECONDARY_WINDOW_LABEL = "7d";
22
+ export const CLAUDE_USAGE_URL = "https://claude.ai/settings/usage";
23
+ const CLAUDE_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
24
+ const CLAUDE_CLIENT_ID = "https://claude.ai/oauth/claude-code-client-metadata";
25
+ const CLAUDE_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
26
+ const CLAUDE_PRIMARY_WINDOW_LABEL = "5h";
27
+ const CLAUDE_SECONDARY_WINDOW_LABEL = "7d";
22
28
 
23
29
  type HeaderLike = Record<string, string | number | boolean | undefined | null>;
24
30
 
@@ -41,10 +47,87 @@ const CODEX_SOURCE: ProviderStatusSource = {
41
47
  parseHeaders: parseCodexRateLimitHeaders,
42
48
  };
43
49
 
50
+ const ANTHROPIC_SOURCE: ProviderStatusSource = {
51
+ id: "anthropic",
52
+ label: "Claude",
53
+ usageUrl: CLAUDE_USAGE_URL,
54
+ fetch: fetchClaudeProviderStatus,
55
+ parseHeaders: () => undefined,
56
+ };
57
+
44
58
  export const PROVIDER_STATUS_SOURCES: readonly ProviderStatusSource[] = [
45
59
  CODEX_SOURCE,
60
+ ANTHROPIC_SOURCE,
46
61
  ];
47
62
 
63
+ type ModelLike = {
64
+ id?: unknown;
65
+ name?: unknown;
66
+ displayName?: unknown;
67
+ provider?: unknown;
68
+ providerId?: unknown;
69
+ };
70
+
71
+ function modelValue(value: unknown): string {
72
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
73
+ }
74
+
75
+ function looksLikeOpenAIModel(value: string): boolean {
76
+ if (!value) return false;
77
+
78
+ const normalized = value.replace(/[/_:\s]+/g, "-");
79
+ return (
80
+ normalized.includes("openai") ||
81
+ normalized.includes("codex") ||
82
+ normalized.includes("chatgpt") ||
83
+ /(^|-)gpt(?:[0-9-]|$)/.test(normalized) ||
84
+ /(^|-)o[134](?:-|$)/.test(normalized)
85
+ );
86
+ }
87
+
88
+ function looksLikeAnthropicModel(value: string): boolean {
89
+ if (!value) return false;
90
+
91
+ const normalized = value.replace(/[/_:\s]+/g, "-");
92
+ return (
93
+ normalized.includes("anthropic") ||
94
+ normalized.includes("claude") ||
95
+ /(^|-)(sonnet|opus|haiku)(?:-|$)/.test(normalized)
96
+ );
97
+ }
98
+
99
+ function looksLikeProviderModel(providerId: string, value: string): boolean {
100
+ if (providerId === CODEX_SOURCE.id) return looksLikeOpenAIModel(value);
101
+ if (providerId === ANTHROPIC_SOURCE.id) return looksLikeAnthropicModel(value);
102
+ return true;
103
+ }
104
+
105
+ export function isProviderStatusRelevantToModel(
106
+ providerId: string,
107
+ model: ModelLike | string | undefined,
108
+ ): boolean {
109
+ if (
110
+ providerId !== CODEX_SOURCE.id &&
111
+ providerId !== ANTHROPIC_SOURCE.id
112
+ ) {
113
+ return true;
114
+ }
115
+
116
+ if (typeof model === "string") {
117
+ return looksLikeProviderModel(providerId, modelValue(model));
118
+ }
119
+ if (!model) return false;
120
+
121
+ const provider = modelValue(model.provider) || modelValue(model.providerId);
122
+ if (provider) return looksLikeProviderModel(providerId, provider);
123
+
124
+ return [
125
+ modelValue(model.id),
126
+ modelValue(model.name),
127
+ modelValue(model.displayName),
128
+ ].some((value) => looksLikeProviderModel(providerId, value));
129
+ }
130
+
48
131
  function enabledProviderStatusSources(
49
132
  config: Pick<ProviderStatusConfigSnapshot, "providers">,
50
133
  ): readonly ProviderStatusSource[] {
@@ -54,6 +137,7 @@ function enabledProviderStatusSources(
54
137
  }
55
138
 
56
139
  interface AuthCredentials {
140
+ provider: "openai-codex" | "anthropic";
57
141
  source: "pi" | "codex";
58
142
  path: string;
59
143
  accessToken: string;
@@ -260,6 +344,36 @@ export function normalizeCodexUsageResponse(
260
344
  };
261
345
  }
262
346
 
347
+ export function normalizeClaudeUsageResponse(
348
+ value: unknown,
349
+ now = new Date(),
350
+ ): ProviderStatusSnapshot | undefined {
351
+ const obj = objectValue(value);
352
+ if (!obj) return undefined;
353
+
354
+ const primary = normalizeClaudeUsageWindow(
355
+ objectValue(obj.five_hour),
356
+ CLAUDE_PRIMARY_WINDOW_LABEL,
357
+ now,
358
+ );
359
+ const secondary = normalizeClaudeUsageWindow(
360
+ objectValue(obj.seven_day),
361
+ CLAUDE_SECONDARY_WINDOW_LABEL,
362
+ now,
363
+ );
364
+ if (!primary && !secondary) return undefined;
365
+
366
+ return {
367
+ provider: "anthropic",
368
+ source: "api",
369
+ fetchedAt: now.toISOString(),
370
+ state: computeProviderStatusState(primary, secondary),
371
+ ...(primary ? { primary } : {}),
372
+ ...(secondary ? { secondary } : {}),
373
+ url: CLAUDE_USAGE_URL,
374
+ };
375
+ }
376
+
263
377
  export function isProviderStatusFresh(
264
378
  snapshot: ProviderStatusSnapshot | undefined,
265
379
  maxAgeMs: number,
@@ -274,7 +388,7 @@ export function isProviderStatusFresh(
274
388
  async function fetchCodexProviderStatus(
275
389
  _pi: ExtensionAPI,
276
390
  ): Promise<ProviderStatusSnapshot> {
277
- let auth = await resolveAuth();
391
+ let auth = await resolveCodexAuth();
278
392
 
279
393
  for (let attempt = 0; attempt < 2; attempt++) {
280
394
  const response = await fetch(CODEX_USAGE_ENDPOINT, {
@@ -310,11 +424,57 @@ async function fetchCodexProviderStatus(
310
424
  throw new Error("Codex usage request failed after auth refresh");
311
425
  }
312
426
 
313
- async function resolveAuth(): Promise<AuthCredentials> {
314
- const pi = await readAuthFile("pi", homePath(".pi/agent/auth.json"));
427
+ async function fetchClaudeProviderStatus(
428
+ _pi: ExtensionAPI,
429
+ ): Promise<ProviderStatusSnapshot> {
430
+ let auth = await resolveClaudeAuth();
431
+
432
+ for (let attempt = 0; attempt < 2; attempt++) {
433
+ const response = await fetch(CLAUDE_USAGE_ENDPOINT, {
434
+ headers: {
435
+ authorization: `Bearer ${auth.accessToken}`,
436
+ accept: "application/json",
437
+ "user-agent": "pi-fancy-footer",
438
+ },
439
+ });
440
+ const text = await response.text();
441
+
442
+ if (response.ok) {
443
+ const parsed = normalizeClaudeUsageResponse(JSON.parse(text));
444
+ if (parsed) return parsed;
445
+ throw new Error("Claude usage response did not contain quota data");
446
+ }
447
+
448
+ if (
449
+ (response.status === 401 || response.status === 403) &&
450
+ attempt === 0 &&
451
+ auth.refreshToken
452
+ ) {
453
+ auth = await refreshAuth(auth);
454
+ continue;
455
+ }
456
+
457
+ throw new Error(
458
+ `Claude usage request failed (${response.status}): ${text.slice(0, 500)}`,
459
+ );
460
+ }
461
+
462
+ throw new Error("Claude usage request failed after auth refresh");
463
+ }
464
+
465
+ async function resolveCodexAuth(): Promise<AuthCredentials> {
466
+ const pi = await readAuthFile(
467
+ "pi",
468
+ homePath(".pi/agent/auth.json"),
469
+ "openai-codex",
470
+ );
315
471
  if (pi) return refreshIfNeeded(pi);
316
472
 
317
- const codex = await readAuthFile("codex", homePath(".codex/auth.json"));
473
+ const codex = await readAuthFile(
474
+ "codex",
475
+ homePath(".codex/auth.json"),
476
+ "openai-codex",
477
+ );
318
478
  if (codex) return refreshIfNeeded(codex);
319
479
 
320
480
  throw new Error(
@@ -322,6 +482,19 @@ async function resolveAuth(): Promise<AuthCredentials> {
322
482
  );
323
483
  }
324
484
 
485
+ async function resolveClaudeAuth(): Promise<AuthCredentials> {
486
+ const pi = await readAuthFile(
487
+ "pi",
488
+ homePath(".pi/agent/auth.json"),
489
+ "anthropic",
490
+ );
491
+ if (pi) return refreshIfNeeded(pi);
492
+
493
+ throw new Error(
494
+ "No usable Anthropic OAuth credentials found. Run pi /login for Claude/Anthropic first.",
495
+ );
496
+ }
497
+
325
498
  async function refreshIfNeeded(auth: AuthCredentials): Promise<AuthCredentials> {
326
499
  if (!auth.refreshToken || !auth.expiresAtMs) return auth;
327
500
  if (auth.expiresAtMs > Date.now() + 5 * 60 * 1000) return auth;
@@ -331,22 +504,38 @@ async function refreshIfNeeded(auth: AuthCredentials): Promise<AuthCredentials>
331
504
  async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
332
505
  if (!auth.refreshToken) return auth;
333
506
 
507
+ const refreshConfig =
508
+ auth.provider === "anthropic"
509
+ ? {
510
+ clientId: CLAUDE_CLIENT_ID,
511
+ tokenUrl: CLAUDE_TOKEN_URL,
512
+ label: "Anthropic",
513
+ }
514
+ : {
515
+ clientId: CODEX_CLIENT_ID,
516
+ tokenUrl: CODEX_TOKEN_URL,
517
+ label: "Codex",
518
+ };
334
519
  const body = new URLSearchParams({
335
520
  grant_type: "refresh_token",
336
521
  refresh_token: auth.refreshToken,
337
- client_id: CODEX_CLIENT_ID,
522
+ client_id: refreshConfig.clientId,
338
523
  }).toString();
339
524
 
340
- const result = await localRefresh(body);
525
+ const result = await localRefresh(body, refreshConfig.tokenUrl);
341
526
 
342
527
  if (result.code !== 0 || !result.stdout) {
343
- throw new Error(result.stderr || "Failed to refresh Codex auth");
528
+ throw new Error(
529
+ result.stderr || `Failed to refresh ${refreshConfig.label} auth`,
530
+ );
344
531
  }
345
532
 
346
533
  const refreshed = JSON.parse(result.stdout) as Record<string, unknown>;
347
534
  const accessToken = stringValue(refreshed.access_token);
348
535
  if (!accessToken) {
349
- throw new Error("Codex auth refresh did not return access_token");
536
+ throw new Error(
537
+ `${refreshConfig.label} auth refresh did not return access_token`,
538
+ );
350
539
  }
351
540
 
352
541
  const refreshToken = stringValue(refreshed.refresh_token) ?? auth.refreshToken;
@@ -365,9 +554,10 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
365
554
 
366
555
  async function localRefresh(
367
556
  body: string,
557
+ tokenUrl: string,
368
558
  ): Promise<{ code: number; stdout: string; stderr: string }> {
369
559
  try {
370
- const response = await fetch(TOKEN_URL, {
560
+ const response = await fetch(tokenUrl, {
371
561
  method: "POST",
372
562
  headers: { "content-type": "application/x-www-form-urlencoded" },
373
563
  body,
@@ -387,12 +577,13 @@ async function localRefresh(
387
577
  async function readAuthFile(
388
578
  source: "pi" | "codex",
389
579
  path: string,
580
+ provider: AuthCredentials["provider"],
390
581
  ): Promise<AuthCredentials | undefined> {
391
582
  try {
392
583
  const raw = JSON.parse(await readFile(path, "utf8")) as unknown;
393
584
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
394
585
  return source === "pi"
395
- ? parsePiAuth(raw as Record<string, unknown>, path)
586
+ ? parsePiAuth(raw as Record<string, unknown>, path, provider)
396
587
  : parseCodexAuth(raw as Record<string, unknown>, path);
397
588
  } catch {
398
589
  return undefined;
@@ -402,11 +593,13 @@ async function readAuthFile(
402
593
  function parsePiAuth(
403
594
  raw: Record<string, unknown>,
404
595
  path: string,
596
+ provider: AuthCredentials["provider"],
405
597
  ): AuthCredentials | undefined {
406
- const entry = objectValue(raw["openai-codex"]);
598
+ const entry = objectValue(raw[provider]);
407
599
  const accessToken = stringValue(entry?.access);
408
600
  if (!accessToken) return undefined;
409
601
  return {
602
+ provider,
410
603
  source: "pi",
411
604
  path,
412
605
  accessToken,
@@ -431,6 +624,7 @@ function parseCodexAuth(
431
624
  const accessToken = stringValue(tokens?.access_token);
432
625
  if (!accessToken) return undefined;
433
626
  return {
627
+ provider: "openai-codex",
434
628
  source: "codex",
435
629
  path,
436
630
  accessToken,
@@ -451,11 +645,12 @@ async function persistAuth(auth: AuthCredentials): Promise<void> {
451
645
  >;
452
646
 
453
647
  if (auth.source === "pi") {
454
- const entry = objectValue(raw["openai-codex"]);
648
+ const entry = objectValue(raw[auth.provider]);
455
649
  if (entry) {
456
650
  entry.access = auth.accessToken;
457
651
  if (auth.refreshToken) entry.refresh = auth.refreshToken;
458
652
  if (auth.expiresAtMs) entry.expires = auth.expiresAtMs;
653
+ if (auth.accountId) entry.accountId = auth.accountId;
459
654
  }
460
655
  } else {
461
656
  const tokens = objectValue(raw.tokens);
@@ -548,6 +743,25 @@ function normalizeApiWindow(
548
743
  return windowFromUsedPercent(label, usedPercent ?? 0, resetAt, now);
549
744
  }
550
745
 
746
+ function normalizeClaudeUsageWindow(
747
+ value: Record<string, unknown> | undefined,
748
+ fallbackLabel: string,
749
+ now: Date,
750
+ ): ProviderStatusWindow | undefined {
751
+ if (!value) return undefined;
752
+ const usedPercent =
753
+ numberValue(value.utilization) ??
754
+ numberString(stringValue(value.utilization));
755
+ if (usedPercent === undefined) return undefined;
756
+
757
+ return windowFromUsedPercent(
758
+ fallbackLabel,
759
+ usedPercent,
760
+ resetAtFromTimestamp(stringValue(value.resets_at)),
761
+ now,
762
+ );
763
+ }
764
+
551
765
  function windowLabelFromSeconds(seconds: number | undefined): string | undefined {
552
766
  if (seconds === undefined || seconds <= 0) return undefined;
553
767
  if (seconds % 86_400 === 0) return `${seconds / 86_400}d`;
@@ -600,6 +814,12 @@ function normalizeResetAt(value: number | undefined): number | undefined {
600
814
  return value > 10_000_000_000 ? Math.round(value / 1000) : value;
601
815
  }
602
816
 
817
+ function resetAtFromTimestamp(value: string | undefined): number | undefined {
818
+ if (!value) return undefined;
819
+ const parsed = Date.parse(value);
820
+ return Number.isFinite(parsed) ? Math.round(parsed / 1000) : undefined;
821
+ }
822
+
603
823
  function providerStatusCachePath(providerId: string): string {
604
824
  const base =
605
825
  process.env.XDG_CACHE_HOME ||
@@ -0,0 +1,157 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { renderFooterLines } from "./render.ts";
4
+ import {
5
+ DEFAULT_FOOTER_CONFIG,
6
+ EMPTY_GIT_INFO,
7
+ type FooterConfigSnapshot,
8
+ type ProviderStatusSnapshot,
9
+ type SessionUsageMetrics,
10
+ } from "./shared.ts";
11
+
12
+ const theme = {
13
+ fg: (_color: string, text: string) => text,
14
+ };
15
+
16
+ const usageMetrics: SessionUsageMetrics = {
17
+ latest: undefined,
18
+ totalCost: 0,
19
+ };
20
+
21
+ const providerStatus: ProviderStatusSnapshot = {
22
+ provider: "openai-codex",
23
+ source: "headers",
24
+ fetchedAt: "2026-05-06T10:00:00Z",
25
+ state: "ok",
26
+ primary: {
27
+ label: "5h",
28
+ leftPercent: 95,
29
+ usedPercent: 5,
30
+ },
31
+ };
32
+
33
+ const claudeProviderStatus: ProviderStatusSnapshot = {
34
+ provider: "anthropic",
35
+ source: "api",
36
+ fetchedAt: "2026-06-16T10:00:00Z",
37
+ state: "ok",
38
+ primary: {
39
+ label: "5h",
40
+ leftPercent: 100,
41
+ usedPercent: 0,
42
+ },
43
+ secondary: {
44
+ label: "7d",
45
+ leftPercent: 92,
46
+ usedPercent: 8,
47
+ },
48
+ };
49
+
50
+ const footerConfig: FooterConfigSnapshot = {
51
+ ...DEFAULT_FOOTER_CONFIG,
52
+ iconFamily: "ascii",
53
+ providerStatus: {
54
+ ...DEFAULT_FOOTER_CONFIG.providerStatus,
55
+ display: "text",
56
+ },
57
+ widgets: {
58
+ "context-bar": { enabled: false },
59
+ "context-capacity": { enabled: false },
60
+ location: { enabled: false },
61
+ },
62
+ };
63
+
64
+ function contextWithModel(model: { id: string; name: string; provider?: string }) {
65
+ return {
66
+ cwd: "/repo",
67
+ model,
68
+ getContextUsage: () => ({
69
+ contextWindow: 200_000,
70
+ tokens: 0,
71
+ percent: 0,
72
+ }),
73
+ sessionManager: {
74
+ getBranch: () => [],
75
+ },
76
+ };
77
+ }
78
+
79
+ test("renderFooterLines hides Codex provider status for non-OpenAI models", () => {
80
+ const lines = renderFooterLines(
81
+ 120,
82
+ contextWithModel({
83
+ provider: "anthropic",
84
+ id: "claude-sonnet-4",
85
+ name: "Claude Sonnet 4",
86
+ }) as never,
87
+ EMPTY_GIT_INFO,
88
+ "off",
89
+ theme as never,
90
+ usageMetrics,
91
+ footerConfig,
92
+ [],
93
+ [providerStatus],
94
+ );
95
+
96
+ assert.doesNotMatch(lines.join("\n"), /5h:95%/);
97
+ });
98
+
99
+ test("renderFooterLines shows Codex provider status for OpenAI models", () => {
100
+ const lines = renderFooterLines(
101
+ 120,
102
+ contextWithModel({
103
+ provider: "openai",
104
+ id: "gpt-5-codex",
105
+ name: "GPT-5 Codex",
106
+ }) as never,
107
+ EMPTY_GIT_INFO,
108
+ "off",
109
+ theme as never,
110
+ usageMetrics,
111
+ footerConfig,
112
+ [],
113
+ [providerStatus],
114
+ );
115
+
116
+ assert.match(lines.join("\n"), /5h:95%/);
117
+ });
118
+
119
+ test("renderFooterLines shows Anthropic provider status for Claude models", () => {
120
+ const lines = renderFooterLines(
121
+ 120,
122
+ contextWithModel({
123
+ provider: "anthropic",
124
+ id: "claude-sonnet-4",
125
+ name: "Claude Sonnet 4",
126
+ }) as never,
127
+ EMPTY_GIT_INFO,
128
+ "off",
129
+ theme as never,
130
+ usageMetrics,
131
+ footerConfig,
132
+ [],
133
+ [claudeProviderStatus],
134
+ );
135
+
136
+ assert.match(lines.join("\n"), /5h:100% 7d:92%/);
137
+ });
138
+
139
+ test("renderFooterLines hides Anthropic provider status for OpenAI models", () => {
140
+ const lines = renderFooterLines(
141
+ 120,
142
+ contextWithModel({
143
+ provider: "openai",
144
+ id: "gpt-5-codex",
145
+ name: "GPT-5 Codex",
146
+ }) as never,
147
+ EMPTY_GIT_INFO,
148
+ "off",
149
+ theme as never,
150
+ usageMetrics,
151
+ footerConfig,
152
+ [],
153
+ [claudeProviderStatus],
154
+ );
155
+
156
+ assert.doesNotMatch(lines.join("\n"), /5h:100%/);
157
+ });
package/src/render.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  buildProviderStatusGauge,
50
50
  formatProviderStatusReset,
51
51
  formatProviderStatusText,
52
+ isProviderStatusRelevantToModel,
52
53
  providerStatusColor,
53
54
  } from "./provider-status.ts";
54
55
 
@@ -917,6 +918,9 @@ export function renderFooterLines(
917
918
  usageMetrics,
918
919
  footerConfig.iconFamily,
919
920
  );
921
+ const activeProviderStatuses = providerStatuses.filter((snapshot) =>
922
+ isProviderStatusRelevantToModel(snapshot.provider, ctx.model),
923
+ );
920
924
  const renderCtx: WidgetRenderContext = {
921
925
  width,
922
926
  theme,
@@ -924,7 +928,7 @@ export function renderFooterLines(
924
928
  gaugeWidth: footerConfig.gaugeWidth,
925
929
  gaugeColors: footerConfig.gaugeColors,
926
930
  metrics,
927
- providerStatuses,
931
+ providerStatuses: activeProviderStatuses,
928
932
  providerStatusConfig: footerConfig.providerStatus,
929
933
  defaultIconColor: footerConfig.defaultIconColor,
930
934
  defaultTextColor: footerConfig.defaultTextColor,
package/src/shared.ts CHANGED
@@ -463,7 +463,10 @@ export interface FooterConfigSnapshot {
463
463
  extensionWidgets: Record<string, FooterWidgetConfigOverride>;
464
464
  }
465
465
 
466
- export const PROVIDER_STATUS_PROVIDER_IDS = ["openai-codex"] as const;
466
+ export const PROVIDER_STATUS_PROVIDER_IDS = [
467
+ "openai-codex",
468
+ "anthropic",
469
+ ] as const;
467
470
 
468
471
  export type ProviderStatusProviderId =
469
472
  (typeof PROVIDER_STATUS_PROVIDER_IDS)[number];
@@ -484,7 +487,7 @@ export interface ProviderStatusConfigSnapshot {
484
487
  export const DEFAULT_PROVIDER_STATUS_CONFIG: ProviderStatusConfigSnapshot = {
485
488
  refreshMs: 60_000,
486
489
  cacheTtlMs: 60_000,
487
- providers: ["openai-codex"],
490
+ providers: ["openai-codex", "anthropic"],
488
491
  display: "gauge",
489
492
  showCredits: false,
490
493
  showReset: false,