@jameslovespancakes/pi-plus 1.0.16 → 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.
@@ -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) => {
@@ -0,0 +1,19 @@
1
+ import type { Api, Provider } from "@earendil-works/pi-ai";
2
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
3
+
4
+ /**
5
+ * One of pi's own providers, from the host's copy of pi-ai.
6
+ *
7
+ * pi hands extensions its pi-ai through a fixed set of entry points (the
8
+ * package root, `compat`, `oauth` and `providers/all`) and installs packages
9
+ * without their peers. A deep import such as `pi-ai/providers/openai-codex`
10
+ * therefore has no copy to resolve against on a clean install, and wherever a
11
+ * stray copy does exist it is a different version from the host — the exact
12
+ * way stale provider definitions have dropped tools before.
13
+ * `providers/all` is always the host's, so providers come from here.
14
+ */
15
+ export function builtinProvider<TApi extends Api>(id: string): Provider<TApi> {
16
+ const provider = builtinProviders().find((candidate) => candidate.id === id);
17
+ if (!provider) throw new Error(`This version of pi has no built-in "${id}" provider.`);
18
+ return provider as unknown as Provider<TApi>;
19
+ }
@@ -1,5 +1,4 @@
1
1
  import type { OAuthCredential } from "@earendil-works/pi-ai";
2
- import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
3
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
3
  import type { RoutingMode } from "../../../core/accounts/registry.ts";
5
4
  import { normalizeRoutingMode, type AccountQuotaState } from "../../../core/accounts/routing.ts";
@@ -13,6 +12,7 @@ import {
13
12
  saveCodexAccounts,
14
13
  type CodexAccount,
15
14
  } from "../../../core/codex/store.ts";
15
+ import { builtinProvider } from "./builtin.ts";
16
16
  import {
17
17
  chooseCredential,
18
18
  createPooledOAuthAdapter,
@@ -146,7 +146,7 @@ function markCodexRateLimited(accountId: string, headers: Record<string, string>
146
146
  export const CODEX_SPEC: PooledOAuthProviderSpec<"openai-codex-responses"> = {
147
147
  id: "openai-codex",
148
148
  label: "Codex",
149
- createProvider: openaiCodexProvider,
149
+ createProvider: () => builtinProvider("openai-codex"),
150
150
  store: CODEX_STORE,
151
151
  addPrompt: "Sign in with a DIFFERENT ChatGPT account in the browser. Continue?",
152
152
  describeAccount: (account) => describePlan(account as PooledOAuthAccount & CodexAccount),
@@ -1,17 +1,16 @@
1
- import { kimiCodingProvider } from "@earendil-works/pi-ai/providers/kimi-coding";
2
- import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
1
+ import { builtinProvider } from "./builtin.ts";
3
2
  import { createPooledOAuthAdapter, type PooledOAuthProviderSpec } from "./oauth-pool.ts";
4
3
 
5
4
  export const KIMI_SPEC: PooledOAuthProviderSpec<"anthropic-messages"> = {
6
5
  id: "kimi-coding",
7
6
  label: "Kimi",
8
- createProvider: kimiCodingProvider,
7
+ createProvider: () => builtinProvider("kimi-coding"),
9
8
  };
10
9
 
11
10
  export const XAI_SPEC: PooledOAuthProviderSpec<"openai-responses"> = {
12
11
  id: "xai",
13
12
  label: "Grok",
14
- createProvider: xaiProvider,
13
+ createProvider: () => builtinProvider("xai"),
15
14
  };
16
15
 
17
16
  export const kimiAccounts = createPooledOAuthAdapter(KIMI_SPEC);
@@ -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";