@jameslovespancakes/pi-plus 1.0.17 → 1.0.18

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
@@ -73,6 +73,14 @@ Your live quota, always in the footer:
73
73
  With more than two accounts only the two most recently used are listed, so the
74
74
  footer stays a fixed height however many you pool.
75
75
 
76
+ The right-hand column follows the model in use. It shows Codex by default and
77
+ swaps to Gemini while a `gemini/*` model is selected: one bar per quota family
78
+ (Flash, Pro, and Claude or GPT-OSS), pooled across your Gemini accounts, with
79
+ the active family highlighted. Whether a family resets weekly or every five
80
+ hours depends on the account's plan; the reset time shows which. Kimi and Grok
81
+ publish no usage endpoint, so their column shows only the last rate-limit
82
+ reading, if any.
83
+
76
84
  Account and routing commands are provider-agnostic. Sequential routing uses
77
85
  account order; quota-aware routing uses reported capacity and fairly probes
78
86
  accounts whose provider does not publish quota headers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jameslovespancakes/pi-plus",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "type": "module",
5
5
  "description": "pi and more",
6
6
  "license": "MIT",
@@ -61,12 +61,16 @@ export function parseQuota(body: any, now = Date.now()): QuotaSnapshot {
61
61
  };
62
62
  };
63
63
 
64
+ // `limits` also restates the session and weekly windows (`kind: session`,
65
+ // `weekly_all`) with no scope. Only model-scoped entries are extra limits;
66
+ // keeping the others filed the 5h and 7d windows a second time as "scoped".
64
67
  const scoped = (Array.isArray(body?.limits) ? body.limits : [])
65
68
  .map((limit: any) => {
69
+ const name = limit?.scope?.model?.display_name;
66
70
  const used = pct(limit?.percent);
67
- if (used === undefined) return undefined;
71
+ if (typeof name !== "string" || !name || used === undefined) return undefined;
68
72
  return {
69
- id: String(limit?.scope?.model?.display_name ?? limit?.id ?? "scoped").toLowerCase(),
73
+ id: name.toLowerCase(),
70
74
  usedPercent: used,
71
75
  remainingPercent: 100 - used,
72
76
  resetsAt: typeof limit?.resets_at === "string" ? limit.resets_at : undefined,
@@ -154,6 +154,45 @@ export async function fetchUserEmail(token: string, signal?: AbortSignal): Promi
154
154
  }
155
155
  }
156
156
 
157
+ /** One `retrieveUserQuota` bucket: a runtime model's remaining share of its window. */
158
+ export interface QuotaBucket {
159
+ modelId: string;
160
+ /** 0..1 of the window left. */
161
+ remainingFraction: number;
162
+ /** ISO time the window resets; absent for buckets with no window. */
163
+ resetTime?: string;
164
+ }
165
+
166
+ /**
167
+ * The account's quota, one bucket per runtime model id. Endpoints are tried in
168
+ * order and the first answer wins: quota is per account, not per endpoint.
169
+ * Throws only when no endpoint answered at all.
170
+ */
171
+ export async function fetchUserQuota(
172
+ token: string,
173
+ projectId: string,
174
+ signal?: AbortSignal,
175
+ ): Promise<QuotaBucket[]> {
176
+ for (const endpoint of GEMINI_ENDPOINTS) {
177
+ const answer = await postJson(endpoint, "retrieveUserQuota", token, { project: projectId }, signal);
178
+ if (!isRecord(answer)) continue;
179
+ const buckets = Array.isArray(answer.buckets) ? answer.buckets : [];
180
+ return buckets.flatMap((bucket): QuotaBucket[] => {
181
+ if (!isRecord(bucket) || typeof bucket.modelId !== "string") return [];
182
+ // proto3 JSON omits zero values, so an exhausted bucket arrives with no
183
+ // `remainingFraction` at all. Absent means empty, not unknown.
184
+ const fraction = bucket.remainingFraction === undefined ? 0 : Number(bucket.remainingFraction);
185
+ if (!Number.isFinite(fraction)) return [];
186
+ return [{
187
+ modelId: bucket.modelId,
188
+ remainingFraction: Math.min(1, Math.max(0, fraction)),
189
+ ...(typeof bucket.resetTime === "string" && bucket.resetTime && { resetTime: bucket.resetTime }),
190
+ }];
191
+ });
192
+ }
193
+ throw new Error("Gemini did not return quota from any endpoint.");
194
+ }
195
+
157
196
  /** One entry of `fetchAvailableModels`, keyed by its runtime model id. */
158
197
  export interface RuntimeModelInfo {
159
198
  isInternal?: boolean;
@@ -0,0 +1,58 @@
1
+ import type { QuotaBucket } from "./client.ts";
2
+
3
+ /**
4
+ * Gemini quota, grouped the way the backend pools it.
5
+ *
6
+ * `retrieveUserQuota` answers per runtime model id (`gemini-3.8-flash-high`,
7
+ * `gemini-pro-agent`, `claude-opus-4-6-thinking`, …), but the ids of one
8
+ * family draw on one shared allowance: every Flash variant reports the same
9
+ * fraction and reset, as does every Pro variant. The window differs by plan —
10
+ * a paid account resets weekly, a free one every five hours — so the bars are
11
+ * labelled by family and the reset time says which window it is.
12
+ */
13
+
14
+ export const GEMINI_QUOTA_FAMILIES = ["Flash", "Pro", "Claude", "GPT"] as const;
15
+ export type GeminiQuotaFamily = typeof GEMINI_QUOTA_FAMILIES[number];
16
+
17
+ /**
18
+ * The family a public or runtime model id draws quota from. Undefined for ids
19
+ * that are not agent models (tab completion, image, the `-lite` helpers the
20
+ * client uses for commit messages and search), so they never skew a family.
21
+ */
22
+ export function geminiQuotaFamily(modelId: string | undefined): GeminiQuotaFamily | undefined {
23
+ const id = (modelId ?? "").toLowerCase();
24
+ if (id.startsWith("claude-")) return "Claude";
25
+ if (id.startsWith("gpt-oss")) return "GPT";
26
+ if (!id.startsWith("gemini-") || /image|lite/.test(id)) return undefined;
27
+ if (/(^|-)pro(-|$)/.test(id)) return "Pro";
28
+ if (/(^|-)flash(-|$)/.test(id)) return "Flash";
29
+ return undefined;
30
+ }
31
+
32
+ export interface GeminiFamilyQuota {
33
+ family: GeminiQuotaFamily;
34
+ /** Percent left, 0..100. */
35
+ remaining: number;
36
+ resetAt?: number;
37
+ }
38
+
39
+ /**
40
+ * One figure per family: the most depleted bucket in it, so a variant that
41
+ * has run dry is never hidden behind a sibling that has not.
42
+ */
43
+ export function summarizeGeminiQuota(buckets: readonly QuotaBucket[]): GeminiFamilyQuota[] {
44
+ const byFamily = new Map<GeminiQuotaFamily, GeminiFamilyQuota>();
45
+ for (const bucket of buckets) {
46
+ const family = geminiQuotaFamily(bucket.modelId);
47
+ if (!family) continue;
48
+ const remaining = bucket.remainingFraction * 100;
49
+ const parsed = bucket.resetTime ? Date.parse(bucket.resetTime) : Number.NaN;
50
+ const resetAt = Number.isFinite(parsed) ? parsed : undefined;
51
+ const current = byFamily.get(family);
52
+ const lower = !current || remaining < current.remaining;
53
+ const sooner = current && remaining === current.remaining
54
+ && resetAt !== undefined && (current.resetAt === undefined || resetAt < current.resetAt);
55
+ if (lower || sooner) byFamily.set(family, { family, remaining, ...(resetAt !== undefined && { resetAt }) });
56
+ }
57
+ return GEMINI_QUOTA_FAMILIES.flatMap((family) => byFamily.get(family) ?? []);
58
+ }
@@ -19,6 +19,16 @@ export type UsageRow = {
19
19
  */
20
20
  export const CLAUDE_FRESH_MS = 12 * 60_000;
21
21
  export const isClaudeAccount = (row: UsageRow) => row.group.startsWith("Claude ") && !row.group.startsWith("Claude pool ×");
22
+ export const isGeminiAccount = (row: UsageRow) => row.group.startsWith("Gemini ");
23
+
24
+ /**
25
+ * Pooled providers with no usage endpoint, and the group their header-observed
26
+ * rate-limit reading is filed under. Their bars can only show what responses said.
27
+ */
28
+ export const OBSERVED_PROVIDERS: ReadonlyArray<readonly [providerId: string, group: string]> = [
29
+ ["kimi-coding", "Kimi"],
30
+ ["xai", "Grok"],
31
+ ];
22
32
  export const isFresh = (row: UsageRow, now = Date.now()) => !row.stale && !!row.checkedAt
23
33
  && now - row.checkedAt < CLAUDE_FRESH_MS && (!row.resetAt || row.resetAt > now);
24
34
 
@@ -26,7 +36,19 @@ export const isFresh = (row: UsageRow, now = Date.now()) => !row.stale && !!row.
26
36
  * Without published capacities this is explicitly an equal-account estimate.
27
37
  */
28
38
  export function combinedWindow(rows: UsageRow[], label: string, expected: number, now = Date.now(), allowPartial = false) {
29
- const matching = rows.filter((r) => isClaudeAccount(r) && r.label === label && isFresh(r, now));
39
+ return pooledWindow(rows, label, expected, isClaudeAccount, now, allowPartial);
40
+ }
41
+
42
+ /** {@link combinedWindow} for any pooled provider's account rows. */
43
+ export function pooledWindow(
44
+ rows: UsageRow[],
45
+ label: string,
46
+ expected: number,
47
+ isMember: (row: UsageRow) => boolean,
48
+ now = Date.now(),
49
+ allowPartial = false,
50
+ ) {
51
+ const matching = rows.filter((r) => isMember(r) && r.label === label && isFresh(r, now));
30
52
  const partial = matching.length !== expected;
31
53
  if (!expected || !matching.length || (partial && !allowPartial)) return undefined;
32
54
  const weighted = matching.every((r) => typeof r.capacity === "number" && r.capacity > 0);
@@ -1,13 +1,19 @@
1
- import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import { loadOAuthPool, saveOAuthAccount, sharedOAuthPoolStore, type PooledOAuthAccount } from "../accounts/oauth-pool.ts";
2
+ import { refreshAbortSignal, type AccountQuotaState } from "../accounts/routing.ts";
3
+ import { anthropicAccountIdentity, cachedAnthropicAccountIdentity } from "../anthropic/identity.ts";
4
4
  import { loadAccounts, saveAccount, type Account as AnthropicAccount } from "../anthropic/store.ts";
5
5
  import { refreshToken } from "../anthropic/oauth.ts";
6
- import { loadCodexAccounts } from "../codex/store.ts";
7
- import type { UsageRow } from "./pool.ts";
6
+ import { claimsOf } from "../codex/store.ts";
7
+ import { fetchUserQuota } from "../gemini/client.ts";
8
+ import { credentialEmail } from "../gemini/credentials.ts";
9
+ import { geminiOAuth, requestProjectId } from "../gemini/oauth.ts";
10
+ import { summarizeGeminiQuota } from "../gemini/quota.ts";
11
+ import { agentPath, readJson } from "../store.ts";
12
+ import { CLAUDE_FRESH_MS, OBSERVED_PROVIDERS, type UsageRow } from "./pool.ts";
8
13
 
9
14
  /**
10
- * Fetches subscription quota from the Claude and Codex endpoints.
15
+ * Fetches subscription quota from the Claude, Codex and Gemini endpoints, and
16
+ * reports what response headers showed for providers with no usage endpoint.
11
17
  *
12
18
  * Pure data access: no pi imports, no module-level mutable state, no rendering.
13
19
  * Everything here returns values so it can be tested without a live agent.
@@ -19,10 +25,67 @@ const TIMEOUT_MS = 10_000;
19
25
  export interface SourceResult {
20
26
  rows: UsageRow[];
21
27
  errors: string[];
28
+ /** Claude account groups expected to report. */
22
29
  groups: string[];
30
+ /** Gemini account groups expected to report. */
31
+ geminiGroups: string[];
23
32
  codexPlan?: string;
24
33
  }
25
34
 
35
+ /** Reads a provider's credential as pi stores it (pi's `readStoredCredential`). */
36
+ export type CredentialReader = (providerId: string) => unknown;
37
+
38
+ export interface SourceOptions {
39
+ /**
40
+ * How the primary (pi-owned) credential is read. It must be read as stored:
41
+ * `modelRegistry.getProviderAuth()` returns whatever account *routing*
42
+ * picked, which can be a pooled one, so figures labelled as the primary
43
+ * account could silently belong to another.
44
+ */
45
+ readCredential?: CredentialReader;
46
+ }
47
+
48
+ /** Fallback when pi's reader was not supplied: the same file pi reads. */
49
+ function readAuthFile(providerId: string): unknown {
50
+ return readJson<Record<string, unknown>>(agentPath("auth.json"), {})[providerId];
51
+ }
52
+
53
+ interface StoredOAuth {
54
+ type: "oauth";
55
+ access: string;
56
+ refresh: string;
57
+ expires: number;
58
+ [key: string]: unknown;
59
+ }
60
+
61
+ function usableOAuth(value: unknown, now = Date.now()): value is StoredOAuth {
62
+ const credential = value as Partial<StoredOAuth> | undefined;
63
+ return credential?.type === "oauth"
64
+ && typeof credential.access === "string" && credential.access.length > 0
65
+ && (typeof credential.expires !== "number" || credential.expires > now + 60_000);
66
+ }
67
+
68
+ /**
69
+ * The provider's primary OAuth credential, or undefined when pi holds none
70
+ * (not logged in, or an API key, which has no subscription quota).
71
+ *
72
+ * An expiring credential is refreshed by pi itself (`getProviderAuth`
73
+ * refreshes and persists before it routes) and then read back, so rotating
74
+ * refresh tokens are only ever spent by pi.
75
+ */
76
+ async function primaryOAuth(ctx: any, providerId: string, read: CredentialReader): Promise<StoredOAuth | undefined> {
77
+ const stored = read(providerId) as { type?: unknown } | undefined;
78
+ if (stored?.type !== "oauth") return undefined;
79
+ if (usableOAuth(stored)) return stored;
80
+
81
+ await ctx?.modelRegistry?.getProviderAuth?.(providerId);
82
+ const refreshed = read(providerId);
83
+ if (usableOAuth(refreshed)) return refreshed;
84
+ throw new Error(`login expired, run /login ${providerId}`);
85
+ }
86
+
87
+ const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
88
+
26
89
  function pct(value: unknown): number | undefined {
27
90
  if (value == null || typeof value === "boolean" || (typeof value === "string" && !value.trim())) return undefined;
28
91
  const n = typeof value === "number" ? value : Number(value);
@@ -60,14 +123,16 @@ async function claudeUsage(group: string, token: string): Promise<{ rows: UsageR
60
123
  };
61
124
  push("5h", body.five_hour);
62
125
  push("7d", body.seven_day);
63
- push("7d Opus", body.seven_day_opus ?? body.seven_day_omelette);
64
- push("7d Sonnet", body.seven_day_sonnet);
126
+ // Scoped labels are lower case in every source (the stored snapshot keeps
127
+ // lower-cased ids), or one limit pools as two half-reported windows.
128
+ push("7d opus", body.seven_day_opus ?? body.seven_day_omelette);
129
+ push("7d sonnet", body.seven_day_sonnet);
65
130
 
66
131
  for (const limit of Array.isArray(body.limits) ? body.limits : []) {
67
132
  const scoped = limit?.scope?.model?.display_name;
68
133
  const used = pct(limit?.percent);
69
- if (!scoped || used === undefined) continue;
70
- const label = `7d ${scoped}`;
134
+ if (typeof scoped !== "string" || !scoped || used === undefined) continue;
135
+ const label = `7d ${scoped.toLowerCase()}`;
71
136
  if (rows.some((row) => row.label === label)) continue;
72
137
  rows.push({ group, label, remaining: 100 - used, resetAt: resetToMs(limit?.resets_at) });
73
138
  }
@@ -131,33 +196,59 @@ function rowsFromSnapshot(group: string, quota: any): UsageRow[] | undefined {
131
196
  push("5h", quota.five_hour);
132
197
  push("7d", quota.seven_day);
133
198
  for (const scoped of Array.isArray(quota.scoped) ? quota.scoped : []) {
134
- if (typeof scoped?.remainingPercent !== "number" || !scoped?.id) continue;
135
- push(`7d ${scoped.id}`, scoped);
199
+ if (typeof scoped?.remainingPercent !== "number" || typeof scoped?.id !== "string" || !scoped.id) continue;
200
+ // Snapshots written before the fix restate the 5h/7d windows as "scoped".
201
+ if (scoped.id === "scoped") continue;
202
+ push(`7d ${scoped.id.toLowerCase()}`, scoped);
136
203
  }
137
204
  return rows.length ? rows : undefined;
138
205
  }
139
206
 
140
- export async function fetchClaudeRows(ctx: any): Promise<{ rows: UsageRow[]; errors: string[]; groups: string[] }> {
207
+ const claudeGroup = (account: AnthropicAccount) => `Claude ${account.label ?? account.id.slice(0, 8)}`;
208
+
209
+ export async function fetchClaudeRows(
210
+ ctx: any,
211
+ options: SourceOptions = {},
212
+ ): Promise<{ rows: UsageRow[]; errors: string[]; groups: string[] }> {
213
+ const read = options.readCredential ?? readAuthFile;
141
214
  const rows: UsageRow[] = [];
142
215
  const errors: string[] = [];
143
216
  const accounts: Array<{ group: string; token?: string; account?: AnthropicAccount }> = [];
217
+ const groups = new Set<string>();
144
218
 
219
+ let sidecars: AnthropicAccount[] = [];
145
220
  try {
146
- const token = (await ctx.modelRegistry.getProviderAuth("anthropic"))?.auth?.apiKey;
147
- if (token) accounts.push({ group: "Claude Personal", token });
148
- else errors.push("Claude Personal: not logged in");
221
+ sidecars = (loadAccounts()?.accounts ?? []).filter((account) => account.type === "oauth" && account.enabled !== false);
149
222
  } catch (error) {
150
- errors.push(`Claude Personal: ${error instanceof Error ? error.message : String(error)}`);
223
+ errors.push(`Claude accounts: ${errorText(error)}`);
151
224
  }
225
+ const taken = new Set(sidecars.map(claudeGroup));
226
+ const primaryGroup = taken.has("Claude Personal") ? "Claude Primary" : "Claude Personal";
152
227
 
153
228
  try {
154
- const storage = loadAccounts();
155
- for (const account of storage?.accounts ?? []) {
156
- if (account.type !== "oauth" || account.enabled === false) continue;
157
- accounts.push({ group: `Claude ${account.label ?? account.id.slice(0, 8)}`, account });
229
+ const primary = await primaryOAuth(ctx, "anthropic", read);
230
+ if (!primary) {
231
+ errors.push(`${primaryGroup}: not logged in`);
232
+ } else {
233
+ // pi's own login is often the same Claude account as a pooled one.
234
+ // Counting it twice filed every window twice and marked the pool partial.
235
+ const identity = cachedAnthropicAccountIdentity(primary.access)
236
+ ?? await anthropicAccountIdentity(primary.access).catch(() => undefined);
237
+ const twin = identity ? sidecars.find((account) => account.identity === identity) : undefined;
238
+ if (!twin) {
239
+ accounts.push({ group: primaryGroup, token: primary.access });
240
+ groups.add(primaryGroup);
241
+ }
158
242
  }
159
243
  } catch (error) {
160
- errors.push(`Claude accounts: ${error instanceof Error ? error.message : String(error)}`);
244
+ // Logged in but unreadable: still an account the pool expects.
245
+ groups.add(primaryGroup);
246
+ errors.push(`${primaryGroup}: ${errorText(error)}`);
247
+ }
248
+
249
+ for (const account of sidecars) {
250
+ accounts.push({ group: claudeGroup(account), account });
251
+ groups.add(claudeGroup(account));
161
252
  }
162
253
 
163
254
  for (const entry of accounts) {
@@ -185,35 +276,30 @@ export async function fetchClaudeRows(ctx: any): Promise<{ rows: UsageRow[]; err
185
276
  }
186
277
  }
187
278
 
188
- return { rows, errors, groups: [...new Set(["Claude Personal", ...accounts.map((entry) => entry.group)])] };
279
+ return { rows, errors, groups: [...groups] };
189
280
  }
190
281
 
191
- function codexAccountId(): string | undefined {
192
- // Prefer an enabled account from our own pool, so /usage reflects the
193
- // accounts /accounts manages rather than only pi's single credential.
194
- try {
195
- const pooled = loadCodexAccounts().accounts.find((a) => a.enabled !== false && a.accountId);
196
- if (pooled?.accountId) return pooled.accountId;
197
- } catch { /* fall through */ }
198
- try {
199
- const auth = JSON.parse(readFileSync(join(homedir(), ".pi", "agent", "auth.json"), "utf8"));
200
- const credential = auth["openai-codex"];
201
- if (credential?.accountId) return credential.accountId;
202
- if (credential?.account_id) return credential.account_id;
203
- } catch { /* fall through */ }
204
- try {
205
- const codex = JSON.parse(readFileSync(join(homedir(), ".codex", "auth.json"), "utf8"));
206
- return codex?.tokens?.account_id ?? codex?.tokens?.accountId;
207
- } catch {
208
- return undefined;
282
+ /**
283
+ * The ChatGPT account the token belongs to. It must come from the same
284
+ * credential: an id taken from a pooled account while the token is pi's
285
+ * primary asks the endpoint about one account on behalf of another.
286
+ */
287
+ function codexAccountId(credential: StoredOAuth): string | undefined {
288
+ for (const value of [credential.accountId, credential.account_id, claimsOf(credential.access).accountId]) {
289
+ if (typeof value === "string" && value) return value;
209
290
  }
291
+ return undefined;
210
292
  }
211
293
 
212
- export async function fetchCodexRows(ctx: any): Promise<{ rows: UsageRow[]; error?: string; plan?: string }> {
294
+ export async function fetchCodexRows(
295
+ ctx: any,
296
+ options: SourceOptions = {},
297
+ ): Promise<{ rows: UsageRow[]; error?: string; plan?: string }> {
213
298
  try {
214
- const token = (await ctx.modelRegistry.getProviderAuth("openai-codex"))?.auth?.apiKey;
215
- const accountId = codexAccountId();
216
- if (!token) return { rows: [], error: "Codex: not logged in" };
299
+ const primary = await primaryOAuth(ctx, "openai-codex", options.readCredential ?? readAuthFile);
300
+ if (!primary) return { rows: [], error: "Codex: not logged in" };
301
+ const token = primary.access;
302
+ const accountId = codexAccountId(primary);
217
303
  if (!accountId) return { rows: [], error: "Codex: no ChatGPT account id" };
218
304
 
219
305
  const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
@@ -277,13 +363,150 @@ export async function fetchCodexRows(ctx: any): Promise<{ rows: UsageRow[]; erro
277
363
  }
278
364
  }
279
365
 
366
+ const GEMINI = "gemini";
367
+ const GEMINI_PRIMARY_GROUP = "Gemini Primary";
368
+
369
+ /** A pooled Gemini account with a live token, refreshed and saved if it had lapsed. */
370
+ async function freshGeminiAccount(account: PooledOAuthAccount): Promise<PooledOAuthAccount> {
371
+ if (account.expires > Date.now() + 60_000) return account;
372
+ // Google does not rotate refresh tokens on use, so a refresh racing the
373
+ // serving path's own is harmless: both tokens stay valid.
374
+ const credential = await geminiOAuth.refresh(account, refreshAbortSignal());
375
+ const updated = { ...account, ...credential };
376
+ saveOAuthAccount(GEMINI, updated);
377
+ return updated;
378
+ }
379
+
380
+ /**
381
+ * Gemini quota per account from `retrieveUserQuota`, one row per model
382
+ * family (Flash, Pro, Claude, GPT). Silent when no Gemini account exists.
383
+ */
384
+ export async function fetchGeminiRows(
385
+ ctx: any,
386
+ options: SourceOptions = {},
387
+ ): Promise<{ rows: UsageRow[]; errors: string[]; groups: string[] }> {
388
+ const errors: string[] = [];
389
+ const accounts: Array<{ group: string; credential?: StoredOAuth; account?: PooledOAuthAccount }> = [];
390
+ const groups: string[] = [];
391
+ const emails = new Set<string>();
392
+
393
+ try {
394
+ const primary = await primaryOAuth(ctx, GEMINI, options.readCredential ?? readAuthFile);
395
+ if (primary) {
396
+ accounts.push({ group: GEMINI_PRIMARY_GROUP, credential: primary });
397
+ const email = credentialEmail(primary as any);
398
+ if (email) emails.add(email.toLowerCase());
399
+ }
400
+ } catch (error) {
401
+ groups.push(GEMINI_PRIMARY_GROUP);
402
+ errors.push(`${GEMINI_PRIMARY_GROUP}: ${errorText(error)}`);
403
+ }
404
+
405
+ try {
406
+ for (const account of loadOAuthPool(GEMINI).accounts) {
407
+ if (account.enabled === false || !account.access) continue;
408
+ // The same Google account signed in twice has one allowance, not two.
409
+ const email = credentialEmail(account)?.toLowerCase();
410
+ if (email && emails.has(email)) continue;
411
+ if (email) emails.add(email);
412
+ let group = `Gemini ${account.label || email || account.id.slice(0, 8)}`;
413
+ if (accounts.some((entry) => entry.group === group)) group = `${group} ${account.id.slice(0, 4)}`;
414
+ accounts.push({ group, account });
415
+ }
416
+ } catch (error) {
417
+ errors.push(`Gemini accounts: ${errorText(error)}`);
418
+ }
419
+
420
+ const results = await Promise.all(accounts.map(async (entry): Promise<{ rows: UsageRow[]; error?: string }> => {
421
+ try {
422
+ const credential = entry.account ? await freshGeminiAccount(entry.account) : entry.credential!;
423
+ const buckets = await fetchUserQuota(
424
+ credential.access,
425
+ requestProjectId(credential as any),
426
+ AbortSignal.timeout(TIMEOUT_MS),
427
+ );
428
+ const checkedAt = Date.now();
429
+ const rows = summarizeGeminiQuota(buckets).map((family): UsageRow => ({
430
+ group: entry.group,
431
+ label: family.family,
432
+ remaining: family.remaining,
433
+ resetAt: family.resetAt,
434
+ checkedAt,
435
+ }));
436
+ return rows.length ? { rows } : { rows, error: `${entry.group}: quota unavailable` };
437
+ } catch (error) {
438
+ const message = errorText(error);
439
+ return {
440
+ rows: [],
441
+ error: /invalid_grant/i.test(message)
442
+ ? `${entry.group}: login expired, run ${entry.account
443
+ ? `/accounts reauth gemini ${entry.account.label || entry.account.id}`
444
+ : "/login gemini"}`
445
+ : `${entry.group}: ${message}`,
446
+ };
447
+ }
448
+ }));
449
+
450
+ return {
451
+ rows: results.flatMap((result) => result.rows),
452
+ errors: [...errors, ...results.flatMap((result) => result.error ?? [])],
453
+ groups: [...groups, ...accounts.map((entry) => entry.group)],
454
+ };
455
+ }
456
+
457
+ function observedRemaining(quota: AccountQuotaState, now: number): number | undefined {
458
+ // A 429 reading means nothing once its block has cleared.
459
+ if (quota.blockedUntil !== undefined) return quota.blockedUntil > now ? 0 : undefined;
460
+ return quota.remainingPercent;
461
+ }
462
+
463
+ /**
464
+ * What response headers last said for providers without a usage endpoint:
465
+ * the account routing can still use most. A rate-limit reading, not a
466
+ * subscription allowance, so it is labelled "rate" and dropped once old.
467
+ */
468
+ export function observedRows(now = Date.now()): UsageRow[] {
469
+ return OBSERVED_PROVIDERS.flatMap(([providerId, group]): UsageRow[] => {
470
+ let quotas: AccountQuotaState[] = [];
471
+ try {
472
+ const store = sharedOAuthPoolStore(providerId);
473
+ quotas = [
474
+ store.primaryQuota(),
475
+ ...store.load().accounts.filter((account) => account.enabled !== false).map((account) => account.quota),
476
+ ].filter((quota): quota is AccountQuotaState => !!quota);
477
+ } catch {
478
+ return [];
479
+ }
480
+
481
+ const current = quotas.filter((quota) => observedRemaining(quota, now) !== undefined
482
+ && ((quota.blockedUntil ?? 0) > now || now - quota.checkedAt < CLAUDE_FRESH_MS));
483
+ if (current.length === 0) return [];
484
+ const best = current.reduce((left, right) =>
485
+ observedRemaining(right, now)! > observedRemaining(left, now)! ? right : left);
486
+ const blocked = (best.blockedUntil ?? 0) > now;
487
+ return [{
488
+ group,
489
+ label: "rate",
490
+ remaining: observedRemaining(best, now)!,
491
+ resetAt: blocked ? best.blockedUntil : best.resetAt,
492
+ // A live block stays current until it clears, however old the reading.
493
+ checkedAt: blocked ? now : best.checkedAt,
494
+ }];
495
+ });
496
+ }
497
+
280
498
  /** One full poll of every configured subscription source. */
281
- export async function fetchAll(ctx: any): Promise<SourceResult> {
282
- const [claude, codex] = await Promise.all([fetchClaudeRows(ctx), fetchCodexRows(ctx)]);
499
+ export async function fetchAll(ctx: any, options: SourceOptions = {}): Promise<SourceResult> {
500
+ const [claude, codex, gemini] = await Promise.all([
501
+ fetchClaudeRows(ctx, options),
502
+ fetchCodexRows(ctx, options),
503
+ fetchGeminiRows(ctx, options),
504
+ ]);
283
505
  return {
284
- rows: [...claude.rows, ...codex.rows],
285
- errors: [...claude.errors, codex.error].filter((error): error is string => !!error),
506
+ rows: [...claude.rows, ...codex.rows, ...gemini.rows, ...observedRows()],
507
+ errors: [...claude.errors, codex.error, ...gemini.errors].filter((error): error is string => !!error),
286
508
  groups: claude.groups,
509
+ geminiGroups: gemini.groups,
287
510
  codexPlan: codex.plan,
288
511
  };
289
512
  }
@@ -13,6 +13,8 @@ import {
13
13
  refreshQuality,
14
14
  } from "../../core/catalog/quality.ts";
15
15
  import { ensureFresh, usageState } from "../../services/usage-service.ts";
16
+ import { geminiQuotaFamily } from "../../core/gemini/quota.ts";
17
+ import { combinedWindow, isGeminiAccount, pooledWindow } from "../../core/quota/pool.ts";
16
18
  import { env, isFromProcessEnv, maskSecret, setEnv } from "../../core/env.ts";
17
19
  import { fitId } from "../../ui/format.ts";
18
20
 
@@ -53,14 +55,24 @@ interface Entry {
53
55
  efficiency: { intelligencePerDollar?: number; codingPerDollar?: number; agenticPerDollar?: number };
54
56
  }
55
57
 
56
- function quotaFor(provider: string): number | undefined {
57
- const rows = usageState().rows;
58
- const match = provider === "anthropic"
59
- ? rows.find((row) => row.group.includes("pool") && row.label === "5h")
60
- ?? rows.find((row) => row.group.startsWith("Claude ") && row.label === "5h")
61
- : provider === "openai-codex"
62
- ? rows.find((row) => row.group === "Codex" && row.label === "weekly")
63
- : undefined;
58
+ function quotaFor(provider: string, modelId: string): number | undefined {
59
+ const state = usageState();
60
+ const rows = state.rows;
61
+ if (provider === "anthropic") {
62
+ // The pool's figure, not whichever account happens to be listed first.
63
+ const pool = combinedWindow(rows, "5h", state.accounts, Date.now(), true);
64
+ return pool ? Math.round(pool.remaining) : undefined;
65
+ }
66
+ if (provider === "gemini") {
67
+ // Gemini pools per model family, so the figure depends on the model.
68
+ const family = geminiQuotaFamily(modelId);
69
+ const expected = state.geminiAccounts || new Set(rows.filter(isGeminiAccount).map((row) => row.group)).size;
70
+ const pool = family ? pooledWindow(rows, family, expected, isGeminiAccount, Date.now(), true) : undefined;
71
+ return pool ? Math.round(pool.remaining) : undefined;
72
+ }
73
+ const match = provider === "openai-codex"
74
+ ? rows.find((row) => row.group === "Codex" && row.label === "weekly")
75
+ : undefined;
64
76
  return match ? Math.round(match.remaining) : undefined;
65
77
  }
66
78
 
@@ -76,7 +88,7 @@ function buildEntry(model: any): Entry {
76
88
  id: `${model.provider}/${model.id}`,
77
89
  provider: model.provider,
78
90
  billing: SUBSCRIPTION_PROVIDERS.has(model.provider) ? "subscription" : "metered",
79
- quotaLeftPercent: quotaFor(model.provider),
91
+ quotaLeftPercent: quotaFor(model.provider, model.id),
80
92
  contextWindow: model.contextWindow,
81
93
  maxTokens: model.maxTokens,
82
94
  reasoning: !!model.reasoning,
@@ -1,6 +1,14 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { readStoredCredential, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
- import { refreshUsage, startPolling, stopPolling, subscribe, usageState } from "../../services/usage-service.ts";
3
+ import { isGeminiAccount } from "../../core/quota/pool.ts";
4
+ import {
5
+ configureUsageSources,
6
+ refreshUsage,
7
+ startPolling,
8
+ stopPolling,
9
+ subscribe,
10
+ usageState,
11
+ } from "../../services/usage-service.ts";
4
12
  import { renderUsageLines, usageSummaryText } from "../../ui/usage-bars.ts";
5
13
  import { formatTokens, sanitize } from "../../ui/format.ts";
6
14
 
@@ -48,7 +56,23 @@ function usageSignature(entries: any[]): string {
48
56
  return [entries.length, last?.id, usage?.input, usage?.output, usage?.cacheRead, usage?.cacheWrite, usage?.cost?.total].join(":");
49
57
  }
50
58
 
59
+ /**
60
+ * True when the column the footer shows for `provider` has nothing to draw
61
+ * yet, e.g. right after a first `/login gemini`. Waiting for the next poll
62
+ * would leave the swapped-in column empty for minutes.
63
+ */
64
+ function columnIsEmpty(provider: string | undefined): boolean {
65
+ const rows = usageState().rows;
66
+ if (provider === "gemini") return !rows.some(isGeminiAccount);
67
+ if (provider === "openai-codex") return !rows.some((row) => row.group === "Codex");
68
+ return false;
69
+ }
70
+
51
71
  export function registerFooter(pi: ExtensionAPI): void {
72
+ // The primary account's quota must be read from pi's store as-is; the
73
+ // registry would hand back whichever pooled account routing picked.
74
+ configureUsageSources({ readCredential: (providerId) => readStoredCredential(providerId) });
75
+
52
76
  let showUsage = true;
53
77
  let requestRender: (() => void) | undefined;
54
78
  let unsubscribe: (() => void) | undefined;
@@ -137,12 +161,8 @@ export function registerFooter(pi: ExtensionAPI): void {
137
161
  }
138
162
 
139
163
  if (showUsage) {
140
- lines.push(...renderUsageLines(
141
- usageState(),
142
- theme,
143
- width,
144
- model?.provider === "anthropic" ? model.id : undefined,
145
- ));
164
+ // The right-hand column follows the provider in use.
165
+ lines.push(...renderUsageLines(usageState(), theme, width, { provider: model?.provider, modelId: model?.id }));
146
166
  }
147
167
 
148
168
  // Final safety net: never emit a line wider than the terminal.
@@ -159,9 +179,11 @@ export function registerFooter(pi: ExtensionAPI): void {
159
179
  startPolling(ctx);
160
180
  });
161
181
 
162
- pi.on("model_select", async (_event, ctx) => {
182
+ pi.on("model_select", async (event, ctx) => {
163
183
  apply(ctx);
164
184
  requestRender?.();
185
+ // The swap itself is immediate; fetch only if the new column has no figures.
186
+ if (ctx.hasUI && showUsage && columnIsEmpty(event.model?.provider)) void refreshUsage(ctx, true);
165
187
  });
166
188
 
167
189
  pi.on("agent_settled", async (_event, ctx) => {
@@ -1,6 +1,6 @@
1
1
  import { agentPath, readJson, writeJson } from "../core/store.ts";
2
- import { isClaudeAccount, type UsageRow } from "../core/quota/pool.ts";
3
- import { fetchAll } from "../core/quota/usage-source.ts";
2
+ import { isClaudeAccount, isGeminiAccount, type UsageRow } from "../core/quota/pool.ts";
3
+ import { fetchAll, type SourceOptions } from "../core/quota/usage-source.ts";
4
4
 
5
5
  /** Shared subscription-usage cache and poller. */
6
6
 
@@ -14,14 +14,23 @@ export interface UsageState {
14
14
  errors: string[];
15
15
  updatedAt?: number;
16
16
  loading: boolean;
17
+ /** Claude accounts expected to report. */
17
18
  accounts: number;
19
+ /** Gemini accounts expected to report. */
20
+ geminiAccounts?: number;
18
21
  codexPlan?: string;
19
22
  /** Last observed quota drop for each account group. */
20
23
  lastUsedAt?: Record<string, number>;
21
24
  }
22
25
 
23
- const state: UsageState = { rows: [], errors: [], loading: true, accounts: 0, lastUsedAt: {} };
26
+ const state: UsageState = { rows: [], errors: [], loading: true, accounts: 0, geminiAccounts: 0, lastUsedAt: {} };
24
27
  const listeners = new Set<() => void>();
28
+ let sourceOptions: SourceOptions = {};
29
+
30
+ /** Supplies host services the sources need, such as pi's credential reader. */
31
+ export function configureUsageSources(options: SourceOptions): void {
32
+ sourceOptions = { ...sourceOptions, ...options };
33
+ }
25
34
 
26
35
  let nextAllowedFetch = 0;
27
36
  let inFlight: Promise<void> | undefined;
@@ -39,6 +48,7 @@ function loadCache(): void {
39
48
  if (Date.now() - cached.updatedAt > CACHE_MAX_AGE_MS) return;
40
49
  state.rows = cached.rows.filter((row) => !row.group.startsWith("Claude pool ×"));
41
50
  state.accounts = cached.accounts ?? 0;
51
+ state.geminiAccounts = cached.geminiAccounts ?? 0;
42
52
  state.codexPlan = cached.codexPlan;
43
53
  state.updatedAt = cached.updatedAt;
44
54
  state.lastUsedAt = cached.lastUsedAt ?? {};
@@ -49,6 +59,7 @@ function saveCache(): void {
49
59
  writeJson(cachePath(), {
50
60
  rows: state.rows,
51
61
  accounts: state.accounts,
62
+ geminiAccounts: state.geminiAccounts,
52
63
  codexPlan: state.codexPlan,
53
64
  updatedAt: state.updatedAt,
54
65
  lastUsedAt: state.lastUsedAt,
@@ -107,20 +118,24 @@ export async function refreshUsage(ctx: any, force = false): Promise<void> {
107
118
 
108
119
  inFlight = (async () => {
109
120
  try {
110
- const result = await fetchAll(ctx);
121
+ const result = await fetchAll(ctx, sourceOptions);
111
122
  const rateLimited = result.errors.some((error) => error.includes("429"));
112
123
  recordUsageDrops(result.rows);
113
124
 
114
- // Per-account merge: groups that failed this cycle keep their last figures.
125
+ // Per-account merge: accounts that failed this cycle keep their last
126
+ // figures, marked stale. Removed accounts and header-observed readings
127
+ // (which lapse by design) are not kept.
115
128
  const freshGroups = new Set(result.rows.map((row) => row.group));
129
+ const expected = new Set([...result.groups, ...result.geminiGroups]);
116
130
  const retained = state.rows
117
131
  .filter((row) => !row.group.startsWith("Claude pool ×")
118
132
  && !freshGroups.has(row.group)
119
- && (!isClaudeAccount(row) || result.groups.includes(row.group)))
133
+ && (isClaudeAccount(row) || isGeminiAccount(row) ? expected.has(row.group) : row.group === "Codex"))
120
134
  .map((row) => ({ ...row, stale: true }));
121
135
 
122
136
  state.rows = [...result.rows, ...retained];
123
137
  state.accounts = result.groups.length;
138
+ state.geminiAccounts = result.geminiGroups.length;
124
139
  state.updatedAt = Date.now(); // poll time only; rows retain their own checkedAt
125
140
  state.errors = result.errors;
126
141
  if (result.codexPlan !== undefined) state.codexPlan = result.codexPlan;
@@ -1,14 +1,34 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
- import { combinedWindow, isClaudeAccount, poolAvailability, scopedLabels, type UsageRow } from "../core/quota/pool.ts";
2
+ import { geminiQuotaFamily, GEMINI_QUOTA_FAMILIES } from "../core/gemini/quota.ts";
3
+ import {
4
+ combinedWindow,
5
+ isClaudeAccount,
6
+ isFresh,
7
+ isGeminiAccount,
8
+ OBSERVED_PROVIDERS,
9
+ poolAvailability,
10
+ pooledWindow,
11
+ scopedLabels,
12
+ type UsageRow,
13
+ } from "../core/quota/pool.ts";
3
14
  import type { UsageState } from "../services/usage-service.ts";
4
15
  import { formatReset, formatShortReset, hasTruecolor, levelColor, themeLevel } from "./format.ts";
5
16
 
6
17
  /**
7
- * Renders the Claude/Codex quota bars. Takes state as an argument rather than
8
- * importing the service, so the renderer stays a pure function of its input.
18
+ * Renders the quota bars: Claude on the left, and on the right the provider in
19
+ * use: Gemini, Kimi or Grok while one of their models is selected, Codex
20
+ * otherwise. Takes state as an argument rather than importing the service, so
21
+ * the renderer stays a pure function of its input.
9
22
  */
10
23
 
11
- type Cell = { label: string; remaining?: number; resetAt?: number; partial?: boolean };
24
+ type Cell = { label: string; remaining?: number; resetAt?: number; partial?: boolean; active?: boolean };
25
+ type Column = { title: string; cells: Cell[] };
26
+
27
+ /** The session's current model; its provider picks the right-hand column. */
28
+ export interface ActiveModel {
29
+ provider?: string;
30
+ modelId?: string;
31
+ }
12
32
 
13
33
  function pooled(state: UsageState, label: string): Cell | undefined {
14
34
  return combinedWindow(state.rows, label, state.accounts, Date.now(), true);
@@ -19,6 +39,11 @@ function codexCell(state: UsageState, display: string, match: (label: string) =>
19
39
  return row ? { label: display, remaining: row.remaining, resetAt: row.resetAt } : { label: display };
20
40
  }
21
41
 
42
+ /** Gemini accounts the pool expects, falling back to those that reported. */
43
+ function geminiExpected(state: UsageState): number {
44
+ return state.geminiAccounts || new Set(state.rows.filter(isGeminiAccount).map((row) => row.group)).size;
45
+ }
46
+
22
47
  /**
23
48
  * Turns a scoped limit id into something that fits the label column.
24
49
  *
@@ -33,7 +58,7 @@ function scopedDisplayName(label: string): string {
33
58
  return family.charAt(0).toUpperCase() + family.slice(1);
34
59
  }
35
60
 
36
- function buildColumns(state: UsageState, modelId?: string): { claude: Cell[]; codex: Cell[] } {
61
+ function claudeColumn(state: UsageState, modelId?: string): Column {
37
62
  // Three fixed tiers, so the block keeps its shape whether or not a scoped
38
63
  // limit is currently reported.
39
64
  const scoped = scopedLabels(state.rows, modelId)[0];
@@ -41,18 +66,76 @@ function buildColumns(state: UsageState, modelId?: string): { claude: Cell[]; co
41
66
  ? { ...(pooled(state, scoped) ?? { label: scoped }), label: scopedDisplayName(scoped) }
42
67
  : { label: "Fable" };
43
68
 
44
- const claude: Cell[] = [
69
+ const cells: Cell[] = [
45
70
  { ...(pooled(state, "5h") ?? { label: "5h" }), label: "5h" },
46
71
  { ...(pooled(state, "7d") ?? { label: "7d" }), label: "weekly" },
47
72
  scopedCell,
48
73
  ];
49
74
 
75
+ const availability = poolAvailability(state.rows, state.accounts, modelId);
76
+ const status = availability.ready
77
+ ? `${availability.ready}/${availability.total} ready`
78
+ : availability.unknown ? "unknown/stale" : "exhausted";
79
+ return { title: `Claude Σ${state.accounts} · ${status}`, cells };
80
+ }
81
+
82
+ function codexColumn(state: UsageState): Column {
50
83
  // Codex reports only the two windows; it has no scoped equivalent.
51
- const codex: Cell[] = [
84
+ const cells: Cell[] = [
52
85
  codexCell(state, "5h", (label) => label === "5h") ?? { label: "5h" },
53
86
  codexCell(state, "weekly", (label) => label === "weekly") ?? { label: "weekly" },
54
87
  ];
55
- return { claude, codex };
88
+ return { title: state.codexPlan ? `Codex · ${state.codexPlan}` : "Codex", cells };
89
+ }
90
+
91
+ /**
92
+ * Gemini pools quota per model family, so each bar is a family. The third is
93
+ * whichever third-party family is in use (Claude unless GPT-OSS is), and the
94
+ * active family's label is highlighted.
95
+ */
96
+ function geminiColumn(state: UsageState, modelId?: string): Column {
97
+ const expected = geminiExpected(state);
98
+ const active = geminiQuotaFamily(modelId);
99
+ const labels = ["Flash", "Pro", active === "GPT" ? "GPT" : "Claude"];
100
+ const cells: Cell[] = labels.map((label) => ({
101
+ ...(pooledWindow(state.rows, label, expected, isGeminiAccount, Date.now(), true) ?? {}),
102
+ label,
103
+ active: label === active,
104
+ }));
105
+
106
+ let title = expected > 1 ? `Gemini Σ${expected}` : "Gemini";
107
+ if (active && expected > 0) {
108
+ // Ready = can serve the active model now: its family has quota left.
109
+ const groups = [...new Set(state.rows.filter(isGeminiAccount).map((row) => row.group))];
110
+ let ready = 0;
111
+ let unknown = Math.max(0, expected - groups.length);
112
+ for (const group of groups) {
113
+ const row = state.rows.find((candidate) => candidate.group === group && candidate.label === active);
114
+ if (!row || !isFresh(row)) unknown++;
115
+ else if (row.remaining > 0) ready++;
116
+ }
117
+ title += ` · ${ready ? `${ready}/${expected} ready` : unknown ? "unknown/stale" : "exhausted"}`;
118
+ }
119
+ return { title, cells };
120
+ }
121
+
122
+ /** Providers with no usage endpoint: the last rate-limit reading, if any. */
123
+ function observedColumn(state: UsageState, group: string): Column {
124
+ const row = state.rows.find((candidate) => candidate.group === group && candidate.label === "rate");
125
+ return row
126
+ ? { title: group, cells: [{ label: "rate", remaining: row.remaining, resetAt: row.resetAt }] }
127
+ : { title: `${group} · usage not reported`, cells: [] };
128
+ }
129
+
130
+ /** The right-hand column: the active provider's, else Codex. */
131
+ function sideColumn(state: UsageState, active?: ActiveModel): Column {
132
+ if (active?.provider === "gemini") return geminiColumn(state, active.modelId);
133
+ if (active?.provider === "openai-codex") return codexColumn(state);
134
+ const observed = OBSERVED_PROVIDERS.find(([providerId]) => providerId === active?.provider);
135
+ if (observed) return observedColumn(state, observed[1]);
136
+ // Nothing more specific in use: Codex, unless only Gemini has figures.
137
+ const codex = state.rows.some((row) => row.group === "Codex");
138
+ return !codex && state.rows.some(isGeminiAccount) ? geminiColumn(state) : codexColumn(state);
56
139
  }
57
140
 
58
141
  /** `Work 61% · Personal 88%`. Empty when there is nothing extra to say. */
@@ -77,13 +160,13 @@ function renderAccountSummary(state: UsageState, cellWidth: number): string | un
77
160
  }
78
161
 
79
162
  function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: number): string {
80
- const label = cell.label.slice(0, labelWidth).padEnd(labelWidth);
163
+ const label = theme.fg(cell.active ? "accent" : "muted", cell.label.slice(0, labelWidth).padEnd(labelWidth));
81
164
  const reset = formatShortReset(cell.resetAt);
82
165
  const resetWidth = 4;
83
166
  const barWidth = Math.max(4, cellWidth - labelWidth - 6 - resetWidth - 3);
84
167
 
85
168
  if (cell.remaining === undefined) {
86
- return `${theme.fg("muted", label)} ${theme.fg("dim", "·".repeat(barWidth))} ${theme.fg("dim", " n/a")}${" ".repeat(resetWidth + 1)}`;
169
+ return `${label} ${theme.fg("dim", "·".repeat(barWidth))} ${theme.fg("dim", " n/a")}${" ".repeat(resetWidth + 1)}`;
87
170
  }
88
171
 
89
172
  const filled = Math.round((cell.remaining / 100) * barWidth);
@@ -102,10 +185,10 @@ function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: numbe
102
185
  percent = theme.fg(color, percentText);
103
186
  }
104
187
 
105
- return `${theme.fg("muted", label)} ${bar} ${percent} ${theme.fg("dim", reset.padEnd(resetWidth))}`;
188
+ return `${label} ${bar} ${percent} ${theme.fg("dim", reset.padEnd(resetWidth))}`;
106
189
  }
107
190
 
108
- export function renderUsageLines(state: UsageState, theme: any, width: number, modelId?: string): string[] {
191
+ export function renderUsageLines(state: UsageState, theme: any, width: number, active?: ActiveModel): string[] {
109
192
  if (state.loading) return [theme.fg("dim", " usage: loading…")];
110
193
  if (state.rows.length === 0) {
111
194
  if (state.errors.length > 0) return state.errors.map((error) => theme.fg("warning", ` ${error}`));
@@ -115,18 +198,15 @@ export function renderUsageLines(state: UsageState, theme: any, width: number, m
115
198
  const gap = 3;
116
199
  const cellWidth = Math.max(22, Math.floor((width - 2 - gap) / 2));
117
200
  const labelWidth = 6;
118
- const { claude, codex } = buildColumns(state, modelId);
119
- const availability = poolAvailability(state.rows, state.accounts, modelId);
120
- const status = availability.ready
121
- ? `${availability.ready}/${availability.total} ready`
122
- : availability.unknown ? "unknown/stale" : "exhausted";
123
- const claudeTitle = `Claude Σ${state.accounts} · ${status}`;
124
- const codexTitle = state.codexPlan ? `Codex · ${state.codexPlan}` : "Codex";
125
- const lines = [` ${theme.fg("accent", claudeTitle.padEnd(cellWidth))}${" ".repeat(gap)}${theme.fg("accent", codexTitle)}`];
126
-
127
- for (let index = 0; index < Math.max(claude.length, codex.length); index += 1) {
128
- const left = claude[index] ? renderCell(theme, claude[index], labelWidth, cellWidth) : " ".repeat(cellWidth);
129
- const right = codex[index] ? renderCell(theme, codex[index], labelWidth, cellWidth) : "";
201
+ // Claude's scoped limit follows the model only while Anthropic serves it;
202
+ // the same Claude id through Gemini draws on Gemini's quota instead.
203
+ const claude = claudeColumn(state, active?.provider === "anthropic" ? active.modelId : undefined);
204
+ const side = sideColumn(state, active);
205
+ const lines = [` ${theme.fg("accent", claude.title.padEnd(cellWidth))}${" ".repeat(gap)}${theme.fg("accent", side.title)}`];
206
+
207
+ for (let index = 0; index < Math.max(claude.cells.length, side.cells.length); index += 1) {
208
+ const left = claude.cells[index] ? renderCell(theme, claude.cells[index], labelWidth, cellWidth) : " ".repeat(cellWidth);
209
+ const right = side.cells[index] ? renderCell(theme, side.cells[index], labelWidth, cellWidth) : "";
130
210
  lines.push(` ${left}${" ".repeat(gap)}${right}`);
131
211
  }
132
212
 
@@ -146,7 +226,15 @@ export function usageSummaryText(state: UsageState): string {
146
226
  ? `${pool.partial ? "~" : ""}${Math.round(pool.remaining)}% left${pool.partial ? " (partial: reporting accounts only)" : ""} ${formatReset(pool.resetAt)}`
147
227
  : "unknown/stale"}`;
148
228
  });
149
- const summary = [...combined, ...state.rows.map(
229
+ // Gemini's combined figures only add information with more than one account.
230
+ const geminiAccounts = geminiExpected(state);
231
+ const gemini = geminiAccounts > 1 ? GEMINI_QUOTA_FAMILIES.flatMap((label) => {
232
+ const pool = pooledWindow(state.rows, label, geminiAccounts, isGeminiAccount, Date.now(), true);
233
+ return pool
234
+ ? [`Gemini combined ${label}: ${pool.partial ? "~" : ""}${Math.round(pool.remaining)}% left ${formatReset(pool.resetAt)}`.trim()]
235
+ : [];
236
+ }) : [];
237
+ const summary = [...combined, ...gemini, ...state.rows.map(
150
238
  (row: UsageRow) => `${row.group} ${row.label}: ${Math.round(row.remaining)}% left ${formatReset(row.resetAt)}${row.stale ? " (stale)" : ""}`.trim(),
151
239
  )];
152
240
  return [...summary, ...state.errors].join("\n") || "No usage data";