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 +16 -10
- package/package.json +1 -1
- package/src/config.test.ts +1 -0
- package/src/provider-status.test.ts +131 -0
- package/src/provider-status.ts +233 -13
- package/src/render.test.ts +157 -0
- package/src/render.ts +5 -1
- package/src/shared.ts +5 -2
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
|
|
269
|
-
as battery-style mini gauges per window, e.g.
|
|
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.
|
|
275
|
-
`~/.pi/agent/auth.json`, falling back to Codex CLI credentials in
|
|
276
|
-
`~/.codex/auth.json
|
|
277
|
-
`~/.
|
|
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
|
|
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
package/src/config.test.ts
CHANGED
|
@@ -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 () => {
|
package/src/provider-status.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
314
|
-
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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[
|
|
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[
|
|
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 = [
|
|
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,
|