pi-fancy-footer 1.3.1 → 1.3.2

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
@@ -276,9 +276,10 @@ Notes:
276
276
  from `~/.pi/agent/auth.json`, falling back to Codex CLI credentials in
277
277
  `~/.codex/auth.json`. Claude uses pi Anthropic OAuth credentials from
278
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.
279
+ windows. Status is cached under `~/.cache/pi-fancy-footer/provider-status/`;
280
+ when a refresh fails, cached quota windows keep showing until their reset times
281
+ pass instead of hiding the widget. The widget is hidden when the active model
282
+ selection is not backed by the status provider.
282
283
  - `provider-status` also refreshes from `x-codex-*` provider response headers
283
284
  when pi exposes them, avoiding a separate Codex status request after provider
284
285
  calls. Claude status refreshes from the Claude.ai usage endpoint, not
@@ -297,6 +298,7 @@ Notes:
297
298
  - `pull-request-ci-status` shows GitHub Actions workflow runs for the current
298
299
  PR head commit. It links to the relevant run and switches to failed as soon as
299
300
  one workflow fails, even when other workflows are still running.
301
+
300
302
  ## 🧱 Gauge styles
301
303
 
302
304
  The `gaugeStyle` setting controls the characters used by the `context-bar`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fancy-footer",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "A fancy footer extension for pi",
5
5
  "type": "module",
6
6
  "peerDependencies": {
@@ -14,10 +14,7 @@ import {
14
14
  providerStatusColor,
15
15
  updateProviderStatusFromHeaders,
16
16
  } from "./provider-status.ts";
17
- import {
18
- getGaugeStyle,
19
- type ProviderStatusConfigSnapshot,
20
- } from "./shared.ts";
17
+ import { getGaugeStyle, type ProviderStatusConfigSnapshot } from "./shared.ts";
21
18
 
22
19
  const now = new Date("2026-05-06T10:00:00Z");
23
20
  const providerStatusConfig: ProviderStatusConfigSnapshot = {
@@ -338,6 +335,295 @@ test("collectProviderStatus does not present expired cache as live status after
338
335
  assert.match(snapshot?.error ?? "", /No usable Codex OAuth credentials/);
339
336
  });
340
337
 
338
+ test("collectProviderStatus keeps cached quota in effect after a failed refresh", async (t) => {
339
+ const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
340
+ t.after(async () => {
341
+ await rm(dir, { recursive: true, force: true });
342
+ });
343
+
344
+ const previousHome = process.env.HOME;
345
+ const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
346
+ const previousFetch = globalThis.fetch;
347
+ process.env.HOME = dir;
348
+ process.env.XDG_CACHE_HOME = join(dir, "cache");
349
+ globalThis.fetch = async () =>
350
+ new Response(JSON.stringify({ error: { message: "rate limited" } }), {
351
+ status: 429,
352
+ });
353
+ t.after(() => {
354
+ if (previousHome === undefined) delete process.env.HOME;
355
+ else process.env.HOME = previousHome;
356
+ if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
357
+ else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
358
+ globalThis.fetch = previousFetch;
359
+ });
360
+
361
+ await mkdir(join(dir, ".pi", "agent"), { recursive: true });
362
+ await writeFile(
363
+ join(dir, ".pi", "agent", "auth.json"),
364
+ JSON.stringify({ anthropic: { access: "test-access-token" } }),
365
+ { mode: 0o600 },
366
+ );
367
+
368
+ const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
369
+ const cacheDir = join(
370
+ process.env.XDG_CACHE_HOME,
371
+ "pi-fancy-footer",
372
+ "provider-status",
373
+ );
374
+ await mkdir(cacheDir, { recursive: true });
375
+ await writeFile(
376
+ join(cacheDir, "anthropic.json"),
377
+ JSON.stringify({
378
+ provider: "anthropic",
379
+ source: "api",
380
+ fetchedAt: "2026-05-06T09:00:00Z",
381
+ state: "ok",
382
+ primary: {
383
+ label: "5h",
384
+ usedPercent: 5,
385
+ leftPercent: 95,
386
+ resetAt: futureResetAt,
387
+ },
388
+ secondary: {
389
+ label: "7d",
390
+ usedPercent: 12,
391
+ leftPercent: 88,
392
+ resetAt: futureResetAt,
393
+ },
394
+ url: "https://claude.ai/settings/usage",
395
+ }),
396
+ { mode: 0o600 },
397
+ );
398
+
399
+ const snapshots = await collectProviderStatus({} as never, {
400
+ ...anthropicProviderStatusConfig,
401
+ cacheTtlMs: 1,
402
+ });
403
+
404
+ assert.equal(snapshots.length, 1);
405
+ const snapshot = snapshots[0];
406
+ assert.equal(snapshot?.source, "cache");
407
+ assert.equal(snapshot?.state, "ok");
408
+ assert.deepEqual(snapshot?.primary, {
409
+ label: "5h",
410
+ usedPercent: 5,
411
+ leftPercent: 95,
412
+ resetAt: futureResetAt,
413
+ });
414
+ assert.deepEqual(snapshot?.secondary, {
415
+ label: "7d",
416
+ usedPercent: 12,
417
+ leftPercent: 88,
418
+ resetAt: futureResetAt,
419
+ });
420
+ assert.match(snapshot?.error ?? "", /429/);
421
+ });
422
+
423
+ test("collectProviderStatus hides cached quota once its windows reset", async (t) => {
424
+ const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
425
+ t.after(async () => {
426
+ await rm(dir, { recursive: true, force: true });
427
+ });
428
+
429
+ const previousHome = process.env.HOME;
430
+ const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
431
+ const previousFetch = globalThis.fetch;
432
+ process.env.HOME = dir;
433
+ process.env.XDG_CACHE_HOME = join(dir, "cache");
434
+ globalThis.fetch = async () => new Response("rate limited", { status: 429 });
435
+ t.after(() => {
436
+ if (previousHome === undefined) delete process.env.HOME;
437
+ else process.env.HOME = previousHome;
438
+ if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
439
+ else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
440
+ globalThis.fetch = previousFetch;
441
+ });
442
+
443
+ await mkdir(join(dir, ".pi", "agent"), { recursive: true });
444
+ await writeFile(
445
+ join(dir, ".pi", "agent", "auth.json"),
446
+ JSON.stringify({ anthropic: { access: "test-access-token" } }),
447
+ { mode: 0o600 },
448
+ );
449
+
450
+ const cacheDir = join(
451
+ process.env.XDG_CACHE_HOME,
452
+ "pi-fancy-footer",
453
+ "provider-status",
454
+ );
455
+ await mkdir(cacheDir, { recursive: true });
456
+ await writeFile(
457
+ join(cacheDir, "anthropic.json"),
458
+ JSON.stringify({
459
+ provider: "anthropic",
460
+ source: "api",
461
+ fetchedAt: "2026-05-06T09:00:00Z",
462
+ state: "ok",
463
+ primary: { label: "5h", usedPercent: 5, leftPercent: 95, resetAt: 1 },
464
+ secondary: {
465
+ label: "7d",
466
+ usedPercent: 12,
467
+ leftPercent: 88,
468
+ resetAt: 1,
469
+ },
470
+ url: "https://claude.ai/settings/usage",
471
+ }),
472
+ { mode: 0o600 },
473
+ );
474
+
475
+ const snapshots = await collectProviderStatus({} as never, {
476
+ ...anthropicProviderStatusConfig,
477
+ cacheTtlMs: 1,
478
+ });
479
+
480
+ assert.equal(snapshots.length, 1);
481
+ const snapshot = snapshots[0];
482
+ assert.equal(snapshot?.source, "api");
483
+ assert.equal(snapshot?.state, "unavailable");
484
+ assert.equal(snapshot?.primary, undefined);
485
+ assert.equal(snapshot?.secondary, undefined);
486
+ assert.match(snapshot?.error ?? "", /429/);
487
+ });
488
+
489
+ test("collectProviderStatus keeps cached quota in effect after a failed auth refresh", async (t) => {
490
+ const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
491
+ t.after(async () => {
492
+ await rm(dir, { recursive: true, force: true });
493
+ });
494
+
495
+ const previousHome = process.env.HOME;
496
+ const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
497
+ const previousFetch = globalThis.fetch;
498
+ process.env.HOME = dir;
499
+ process.env.XDG_CACHE_HOME = join(dir, "cache");
500
+ globalThis.fetch = async () => new Response("rate limited", { status: 429 });
501
+ t.after(() => {
502
+ if (previousHome === undefined) delete process.env.HOME;
503
+ else process.env.HOME = previousHome;
504
+ if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
505
+ else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
506
+ globalThis.fetch = previousFetch;
507
+ });
508
+
509
+ await mkdir(join(dir, ".pi", "agent"), { recursive: true });
510
+ await writeFile(
511
+ join(dir, ".pi", "agent", "auth.json"),
512
+ JSON.stringify({
513
+ anthropic: {
514
+ access: "expired-access-token",
515
+ refresh: "test-refresh-token",
516
+ expires: Date.now() - 60_000,
517
+ },
518
+ }),
519
+ { mode: 0o600 },
520
+ );
521
+
522
+ const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
523
+ const cacheDir = join(
524
+ process.env.XDG_CACHE_HOME,
525
+ "pi-fancy-footer",
526
+ "provider-status",
527
+ );
528
+ await mkdir(cacheDir, { recursive: true });
529
+ await writeFile(
530
+ join(cacheDir, "anthropic.json"),
531
+ JSON.stringify({
532
+ provider: "anthropic",
533
+ source: "api",
534
+ fetchedAt: "2026-05-06T09:00:00Z",
535
+ state: "ok",
536
+ primary: {
537
+ label: "5h",
538
+ usedPercent: 5,
539
+ leftPercent: 95,
540
+ resetAt: futureResetAt,
541
+ },
542
+ url: "https://claude.ai/settings/usage",
543
+ }),
544
+ { mode: 0o600 },
545
+ );
546
+
547
+ const snapshots = await collectProviderStatus({} as never, {
548
+ ...anthropicProviderStatusConfig,
549
+ cacheTtlMs: 1,
550
+ });
551
+
552
+ assert.equal(snapshots.length, 1);
553
+ const snapshot = snapshots[0];
554
+ assert.equal(snapshot?.source, "cache");
555
+ assert.equal(snapshot?.state, "ok");
556
+ assert.equal(snapshot?.primary?.resetAt, futureResetAt);
557
+ assert.match(snapshot?.error ?? "", /429/);
558
+ });
559
+
560
+ test("collectProviderStatus keeps cached quota in effect regardless of the failure cause", async (t) => {
561
+ const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
562
+ t.after(async () => {
563
+ await rm(dir, { recursive: true, force: true });
564
+ });
565
+
566
+ const previousHome = process.env.HOME;
567
+ const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
568
+ const previousFetch = globalThis.fetch;
569
+ process.env.HOME = dir;
570
+ process.env.XDG_CACHE_HOME = join(dir, "cache");
571
+ // A non-retryable failure (e.g. revoked credentials) must not invalidate a
572
+ // quota window that has not reset yet.
573
+ globalThis.fetch = async () => new Response("forbidden", { status: 403 });
574
+ t.after(() => {
575
+ if (previousHome === undefined) delete process.env.HOME;
576
+ else process.env.HOME = previousHome;
577
+ if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
578
+ else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
579
+ globalThis.fetch = previousFetch;
580
+ });
581
+
582
+ await mkdir(join(dir, ".pi", "agent"), { recursive: true });
583
+ await writeFile(
584
+ join(dir, ".pi", "agent", "auth.json"),
585
+ JSON.stringify({ anthropic: { access: "test-access-token" } }),
586
+ { mode: 0o600 },
587
+ );
588
+
589
+ const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
590
+ const cacheDir = join(
591
+ process.env.XDG_CACHE_HOME,
592
+ "pi-fancy-footer",
593
+ "provider-status",
594
+ );
595
+ await mkdir(cacheDir, { recursive: true });
596
+ await writeFile(
597
+ join(cacheDir, "anthropic.json"),
598
+ JSON.stringify({
599
+ provider: "anthropic",
600
+ source: "api",
601
+ fetchedAt: "2026-05-06T09:00:00Z",
602
+ state: "ok",
603
+ primary: {
604
+ label: "5h",
605
+ usedPercent: 5,
606
+ leftPercent: 95,
607
+ resetAt: futureResetAt,
608
+ },
609
+ url: "https://claude.ai/settings/usage",
610
+ }),
611
+ { mode: 0o600 },
612
+ );
613
+
614
+ const snapshots = await collectProviderStatus({} as never, {
615
+ ...anthropicProviderStatusConfig,
616
+ cacheTtlMs: 1,
617
+ });
618
+
619
+ assert.equal(snapshots.length, 1);
620
+ const snapshot = snapshots[0];
621
+ assert.equal(snapshot?.source, "cache");
622
+ assert.equal(snapshot?.state, "ok");
623
+ assert.equal(snapshot?.primary?.resetAt, futureResetAt);
624
+ assert.match(snapshot?.error ?? "", /403/);
625
+ });
626
+
341
627
  test("updateProviderStatusFromHeaders honors disabled providers", async () => {
342
628
  const updated = await updateProviderStatusFromHeaders(
343
629
  {
@@ -28,6 +28,10 @@ const CLAUDE_SECONDARY_WINDOW_LABEL = "7d";
28
28
 
29
29
  type HeaderLike = Record<string, string | number | boolean | undefined | null>;
30
30
 
31
+ type TokenRefreshResult =
32
+ | { ok: true; stdout: string }
33
+ | { ok: false; error: Error };
34
+
31
35
  export interface ProviderStatusSource {
32
36
  id: string;
33
37
  label: string;
@@ -106,10 +110,7 @@ export function isProviderStatusRelevantToModel(
106
110
  providerId: string,
107
111
  model: ModelLike | string | undefined,
108
112
  ): boolean {
109
- if (
110
- providerId !== CODEX_SOURCE.id &&
111
- providerId !== ANTHROPIC_SOURCE.id
112
- ) {
113
+ if (providerId !== CODEX_SOURCE.id && providerId !== ANTHROPIC_SOURCE.id) {
113
114
  return true;
114
115
  }
115
116
 
@@ -238,20 +239,76 @@ async function collectProviderStatusFromSource(
238
239
  await writeProviderStatusCache(snapshot).catch(() => undefined);
239
240
  return snapshot;
240
241
  } catch (error) {
241
- if (isProviderStatusFresh(cached, config.cacheTtlMs)) {
242
- return { ...cached, source: "cache" };
243
- }
244
- return {
245
- provider: source.id,
246
- source: "api",
247
- fetchedAt: new Date().toISOString(),
248
- state: "unavailable",
249
- url: source.usageUrl,
250
- error: error instanceof Error ? error.message : String(error),
251
- };
242
+ // A refresh failed. The cached quota windows remain valid until they
243
+ // reset, regardless of why the refresh failed, so keep showing whichever
244
+ // windows are still in effect.
245
+ return (
246
+ displayableCachedStatus(cached, error) ??
247
+ unavailableProviderStatus(source, error)
248
+ );
252
249
  }
253
250
  }
254
251
 
252
+ // Projects a cached snapshot onto the windows that are still in effect, i.e.
253
+ // have not reset yet. Returns undefined when nothing is left to display.
254
+ function displayableCachedStatus(
255
+ cached: ProviderStatusSnapshot | undefined,
256
+ error: unknown,
257
+ now = new Date(),
258
+ ): ProviderStatusSnapshot | undefined {
259
+ if (!cached) return undefined;
260
+
261
+ const primary = windowInEffect(cached.primary, now);
262
+ const secondary = windowInEffect(cached.secondary, now);
263
+ if (!primary && !secondary) return undefined;
264
+
265
+ const {
266
+ primary: _expiredPrimary,
267
+ secondary: _expiredSecondary,
268
+ ...rest
269
+ } = cached;
270
+ return {
271
+ ...rest,
272
+ source: "cache",
273
+ state: computeProviderStatusState(primary, secondary),
274
+ ...(primary ? { primary } : {}),
275
+ ...(secondary ? { secondary } : {}),
276
+ error: providerStatusErrorMessage(error),
277
+ };
278
+ }
279
+
280
+ // A window is in effect while its reset time is still in the future. Windows
281
+ // without a reset time have no intrinsic lifetime, so they are not shown once
282
+ // the refresh that would confirm them has failed.
283
+ function windowInEffect(
284
+ window: ProviderStatusWindow | undefined,
285
+ now: Date,
286
+ ): ProviderStatusWindow | undefined {
287
+ if (!window?.resetAt) return undefined;
288
+ const resetAtMs = window.resetAt * 1000;
289
+ return Number.isFinite(resetAtMs) && resetAtMs > now.getTime()
290
+ ? window
291
+ : undefined;
292
+ }
293
+
294
+ function unavailableProviderStatus(
295
+ source: ProviderStatusSource,
296
+ error: unknown,
297
+ ): ProviderStatusSnapshot {
298
+ return {
299
+ provider: source.id,
300
+ source: "api",
301
+ fetchedAt: new Date().toISOString(),
302
+ state: "unavailable",
303
+ url: source.usageUrl,
304
+ error: providerStatusErrorMessage(error),
305
+ };
306
+ }
307
+
308
+ function providerStatusErrorMessage(error: unknown): string {
309
+ return error instanceof Error ? error.message : String(error);
310
+ }
311
+
255
312
  export async function updateProviderStatusFromHeaders(
256
313
  headers: HeaderLike,
257
314
  config?: ProviderStatusConfigSnapshot,
@@ -495,7 +552,9 @@ async function resolveClaudeAuth(): Promise<AuthCredentials> {
495
552
  );
496
553
  }
497
554
 
498
- async function refreshIfNeeded(auth: AuthCredentials): Promise<AuthCredentials> {
555
+ async function refreshIfNeeded(
556
+ auth: AuthCredentials,
557
+ ): Promise<AuthCredentials> {
499
558
  if (!auth.refreshToken || !auth.expiresAtMs) return auth;
500
559
  if (auth.expiresAtMs > Date.now() + 5 * 60 * 1000) return auth;
501
560
  return refreshAuth(auth);
@@ -522,12 +581,15 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
522
581
  client_id: refreshConfig.clientId,
523
582
  }).toString();
524
583
 
525
- const result = await localRefresh(body, refreshConfig.tokenUrl);
584
+ const result = await requestTokenRefresh(
585
+ body,
586
+ refreshConfig.tokenUrl,
587
+ `${refreshConfig.label} auth refresh`,
588
+ );
526
589
 
527
- if (result.code !== 0 || !result.stdout) {
528
- throw new Error(
529
- result.stderr || `Failed to refresh ${refreshConfig.label} auth`,
530
- );
590
+ if (!result.ok) throw result.error;
591
+ if (!result.stdout) {
592
+ throw new Error(`Failed to refresh ${refreshConfig.label} auth`);
531
593
  }
532
594
 
533
595
  const refreshed = JSON.parse(result.stdout) as Record<string, unknown>;
@@ -538,7 +600,8 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
538
600
  );
539
601
  }
540
602
 
541
- const refreshToken = stringValue(refreshed.refresh_token) ?? auth.refreshToken;
603
+ const refreshToken =
604
+ stringValue(refreshed.refresh_token) ?? auth.refreshToken;
542
605
  const expiresIn = numberValue(refreshed.expires_in);
543
606
  const next: AuthCredentials = {
544
607
  ...auth,
@@ -552,10 +615,11 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
552
615
  return next;
553
616
  }
554
617
 
555
- async function localRefresh(
618
+ async function requestTokenRefresh(
556
619
  body: string,
557
620
  tokenUrl: string,
558
- ): Promise<{ code: number; stdout: string; stderr: string }> {
621
+ label: string,
622
+ ): Promise<TokenRefreshResult> {
559
623
  try {
560
624
  const response = await fetch(tokenUrl, {
561
625
  method: "POST",
@@ -563,13 +627,19 @@ async function localRefresh(
563
627
  body,
564
628
  });
565
629
  const text = await response.text();
566
- if (!response.ok) return { code: 1, stdout: "", stderr: text };
567
- return { code: 0, stdout: text, stderr: "" };
630
+ if (!response.ok) {
631
+ return {
632
+ ok: false,
633
+ error: new Error(
634
+ `${label} failed (${response.status}): ${text.slice(0, 500)}`,
635
+ ),
636
+ };
637
+ }
638
+ return { ok: true, stdout: text };
568
639
  } catch (error) {
569
640
  return {
570
- code: -1,
571
- stdout: "",
572
- stderr: error instanceof Error ? error.message : String(error),
641
+ ok: false,
642
+ error: error instanceof Error ? error : new Error(String(error)),
573
643
  };
574
644
  }
575
645
  }
@@ -705,7 +775,7 @@ function mergeProviderStatus(
705
775
  ...update,
706
776
  ...(primary ? { primary } : {}),
707
777
  ...(secondary ? { secondary } : {}),
708
- ...(update.credits ?? existing.credits
778
+ ...((update.credits ?? existing.credits)
709
779
  ? { credits: update.credits ?? existing.credits }
710
780
  : {}),
711
781
  state: computeProviderStatusState(primary, secondary),
@@ -762,7 +832,9 @@ function normalizeClaudeUsageWindow(
762
832
  );
763
833
  }
764
834
 
765
- function windowLabelFromSeconds(seconds: number | undefined): string | undefined {
835
+ function windowLabelFromSeconds(
836
+ seconds: number | undefined,
837
+ ): string | undefined {
766
838
  if (seconds === undefined || seconds <= 0) return undefined;
767
839
  if (seconds % 86_400 === 0) return `${seconds / 86_400}d`;
768
840
  if (seconds % 3_600 === 0) return `${seconds / 3_600}h`;
@@ -854,7 +926,9 @@ function stringValue(value: unknown): string | undefined {
854
926
  }
855
927
 
856
928
  function numberValue(value: unknown): number | undefined {
857
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
929
+ return typeof value === "number" && Number.isFinite(value)
930
+ ? value
931
+ : undefined;
858
932
  }
859
933
 
860
934
  function numberString(value: string | undefined): number | undefined {