pi-chatgpt-pool 0.1.0 → 0.2.1

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
@@ -20,7 +20,7 @@ Run `/chatgpt-pool` and choose **+ Add account**, or add them directly:
20
20
  /login
21
21
  ```
22
22
 
23
- `/login` lists each account as `ChatGPT (<label>)`. Sign in to each one with a different ChatGPT account. Then pick a pooled model:
23
+ `/login` lists each account as `ChatGPT (<label>)`, or under a name you choose with `/chatgpt-pool name`. Renaming keeps the account signed in. Sign in to each one with a different ChatGPT account. Then pick a pooled model:
24
24
 
25
25
  ```text
26
26
  /model chatgpt/gpt-5.6-sol
@@ -32,14 +32,35 @@ The pool offers the ChatGPT subscription models: the OpenAI models that pi also
32
32
 
33
33
  | Command | What it does |
34
34
  | --- | --- |
35
- | `/chatgpt-pool` | Open the pool: each account's usage, reset times, and banked resets. Pick an account to spend a reset, clear its limit, or remove it |
35
+ | `/chatgpt-pool` | Open the pool panel (see below) |
36
36
  | `/chatgpt-pool add <label>` | Add an account slot. Sign in to it with `/login` |
37
+ | `/chatgpt-pool name <label> <name>` | Set the name the account shows in `/login`, for example `Work (jal@mastra.ai)`. Leave the name empty to go back to `ChatGPT (<label>)` |
37
38
  | `/chatgpt-pool remove <label>` | Remove an account. Run `/logout` for it first to delete its token |
38
39
  | `/chatgpt-pool spend <label>` | Spend one banked reset on that account, after you confirm |
39
40
  | `/chatgpt-pool strategy <name>` | Set the routing strategy: `fill-first`, `round-robin`, `least-used`, or `use-it-or-lose-it` |
40
41
  | `/chatgpt-pool reset` | Forget recorded limits, for example after a limit clears early |
41
42
 
42
- The command autocompletes its actions and account labels. The footer shows `chatgpt: <account>` while a pooled model is selected, plus how many accounts are limited and when the next one comes back.
43
+ The command autocompletes its actions and account labels.
44
+
45
+ ## Pool panel
46
+
47
+ `/chatgpt-pool` opens a panel with every account, its state (ready, limited with a countdown, or not signed in), a usage bar per window with the time until it resets, and its banked resets.
48
+
49
+ | Key | Action |
50
+ | --- | --- |
51
+ | `↑` `↓` or `j` `k` | Select an account |
52
+ | `s` | Spend a banked reset on the selected account, after you confirm |
53
+ | `c` | Clear the selected account's recorded limit |
54
+ | `n` | Set the name the selected account shows in `/login` |
55
+ | `d` | Remove the selected account |
56
+ | `a` | Add an account |
57
+ | `t` | Choose the strategy |
58
+ | `r` | Refresh usage |
59
+ | `esc` or `q` | Close |
60
+
61
+ Over RPC the same actions are offered as a list instead.
62
+
63
+ While a pooled model is selected, the footer shows the account in use with its usage and reset time, for example `chatgpt: work 42% · resets in 3d 4h`, plus how many accounts are limited. When a conversation moves to another account, pi shows a notice such as `ChatGPT pool: personal is limited (back in 1h 30m), switched to work.`
43
64
 
44
65
  `/chatgpt-pool` and `spend` need the interactive UI; in `--print` or `--mode json` they stop with an error instead of spending anything. From a shell, use pi's own commands:
45
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chatgpt-pool",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pool several Sign in with ChatGPT accounts in pi, with routing strategies, failover, and banked resets",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -37,11 +37,13 @@
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "*",
40
- "@earendil-works/pi-coding-agent": "*"
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@earendil-works/pi-ai": "^0.99.1",
44
45
  "@earendil-works/pi-coding-agent": "^0.99.1",
46
+ "@earendil-works/pi-tui": "^0.99.1",
45
47
  "@types/node": "^24.0.0",
46
48
  "typescript": "^5.9.0"
47
49
  },
package/src/format.ts ADDED
@@ -0,0 +1,30 @@
1
+ export function formatWindow(seconds: number): string {
2
+ if (seconds % 604800 === 0) return seconds === 604800 ? "weekly" : `${seconds / 604800}-week`;
3
+ if (seconds % 86400 === 0) return seconds === 86400 ? "daily" : `${seconds / 86400}-day`;
4
+ if (seconds % 3600 === 0) return `${seconds / 3600}h`;
5
+ return `${Math.round(seconds / 60)}m`;
6
+ }
7
+
8
+ export function formatReset(at: number): string {
9
+ const date = new Date(at);
10
+ const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
11
+ return date.toDateString() === new Date().toDateString()
12
+ ? `at ${time}`
13
+ : `${date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" })} at ${time}`;
14
+ }
15
+
16
+ export function formatIn(ms: number): string {
17
+ const minutes = Math.max(1, Math.ceil(ms / 60_000));
18
+ const days = Math.floor(minutes / 1440);
19
+ const hours = Math.floor((minutes % 1440) / 60);
20
+ const rest = minutes % 60;
21
+ if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
22
+ if (hours > 0) return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
23
+ return `${rest}m`;
24
+ }
25
+
26
+ export function usageBar(percent: number, width: number) {
27
+ const clamped = Math.min(100, Math.max(0, percent));
28
+ const filled = Math.round((clamped / 100) * width);
29
+ return { filled: "█".repeat(filled), empty: "░".repeat(width - filled) };
30
+ }
package/src/index.ts CHANGED
@@ -6,7 +6,9 @@ import { BorderedLoader, type ExtensionAPI, type ExtensionContext, getAgentDir }
6
6
  import {
7
7
  type Account,
8
8
  isStrategy,
9
+ failoverMessage,
9
10
  limitCooldown,
11
+ loginName,
10
12
  nextAccountId,
11
13
  orderAccounts,
12
14
  parseResetAt,
@@ -15,6 +17,8 @@ import {
15
17
  type Strategy,
16
18
  type UsageSnapshot,
17
19
  } from "./pool.ts";
20
+ import { formatIn, formatReset, formatWindow } from "./format.ts";
21
+ import { type AccountStatus, type PanelAction, PoolPanel } from "./panel.ts";
18
22
  import { fetchUsage, spendReset, type Usage } from "./usage.ts";
19
23
 
20
24
  const POOL_PROVIDER = "chatgpt";
@@ -76,7 +80,7 @@ function subscriptionModels(providers: readonly Provider[]): Model<Api>[] {
76
80
  }
77
81
 
78
82
  function accountProvider(openai: Provider, account: Account, onReset: ResetListener): Provider {
79
- const name = `ChatGPT (${account.label})`;
83
+ const name = loginName(account);
80
84
  const signIn = openai.auth.oauth;
81
85
  if (!signIn) throw new Error("pi's openai provider has no Sign in with ChatGPT flow; update pi to 0.99.1 or later");
82
86
  const models: Model<Api>[] = subscriptionModels(builtinProviders()).map((model) => ({
@@ -96,20 +100,6 @@ function accountProvider(openai: Provider, account: Account, onReset: ResetListe
96
100
  };
97
101
  }
98
102
 
99
- function formatWindow(seconds: number): string {
100
- if (seconds % 604800 === 0) return seconds === 604800 ? "weekly" : `${seconds / 604800}-week`;
101
- if (seconds % 3600 === 0) return `${seconds / 3600}h`;
102
- return `${Math.round(seconds / 60)}m`;
103
- }
104
-
105
- function formatReset(at: number): string {
106
- const date = new Date(at);
107
- const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
108
- return date.toDateString() === new Date().toDateString()
109
- ? `at ${time}`
110
- : `${date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" })} at ${time}`;
111
- }
112
-
113
103
  export default function chatgptPool(pi: ExtensionAPI) {
114
104
  const providers = builtinProviders();
115
105
  const openai = providers.find((provider) => provider.id === "openai");
@@ -142,6 +132,14 @@ export default function chatgptPool(pi: ExtensionAPI) {
142
132
  );
143
133
  return new Map([...usageCache].map(([id, entry]) => [id, entry.snapshot]));
144
134
  };
135
+ const refreshUsage = async (ctx: ExtensionContext, accountId: string) => {
136
+ const cached = usageCache.get(accountId);
137
+ if (cached && Date.now() - cached.at < USAGE_TTL_MS) return;
138
+ const token = await ctx.modelRegistry.getApiKeyForProvider(accountId).catch(() => undefined);
139
+ if (!token) return;
140
+ await fetchUsage(token).then((usage) => rememberUsage(accountId, usage), () => undefined);
141
+ };
142
+
145
143
  const reportedResets = new Map<string, number>();
146
144
  const onReset: ResetListener = (accountId, resetsAt) => reportedResets.set(accountId, resetsAt);
147
145
 
@@ -165,11 +163,16 @@ export default function chatgptPool(pi: ExtensionAPI) {
165
163
  if (provider !== POOL_PROVIDER) return ctx.ui.setStatus(STATUS_KEY, undefined);
166
164
  const now = Date.now();
167
165
  const active = accounts.find((account) => account.id === lastRouted);
166
+ const snapshot = active && usageCache.get(active.id)?.snapshot;
168
167
  const limited = accounts.filter((account) => (account.resetsAt ?? 0) > now);
169
168
  const parts = [`chatgpt: ${active?.label ?? "pool"}`];
169
+ if (snapshot) {
170
+ parts[0] += ` ${Math.round(snapshot.usedPercent)}%`;
171
+ parts.push(`resets in ${formatIn(snapshot.resetsAt - now)}`);
172
+ }
170
173
  if (limited.length > 0) {
171
174
  const next = Math.min(...limited.map((account) => account.resetsAt ?? now));
172
- parts.push(`${limited.length} limited, next back ${formatReset(next)}`);
175
+ parts.push(`${limited.length} limited, next back in ${formatIn(next - now)}`);
173
176
  }
174
177
  ctx.ui.setStatus(STATUS_KEY, parts.join(" · "));
175
178
  };
@@ -212,10 +215,18 @@ export default function chatgptPool(pi: ExtensionAPI) {
212
215
  : "Every ChatGPT account in the pool is signed out or limited. Run /chatgpt-pool to see reset times and banked resets.",
213
216
  );
214
217
  }
218
+ if (sticky !== undefined && sticky !== routed.provider && ctx.hasUI) {
219
+ const from = accounts.find((candidate) => candidate.id === sticky);
220
+ const until = from?.resetsAt && from.resetsAt > Date.now() ? ` (back in ${formatIn(from.resetsAt - Date.now())})` : "";
221
+ ctx.ui.notify(`ChatGPT pool: ${from?.label ?? sticky} is limited${until}, switched to ${account.label}.`, "info");
222
+ }
215
223
  if (lastRouted !== routed.provider) {
216
224
  lastRouted = routed.provider;
217
225
  updateStatus(ctx);
218
226
  }
227
+ refreshUsage(ctx, routed.provider)
228
+ .then(() => updateStatus(ctx, POOL_PROVIDER))
229
+ .catch(() => undefined);
219
230
  return { model: routed, thinkingLevel: request.thinkingLevel };
220
231
  },
221
232
  });
@@ -237,13 +248,11 @@ export default function chatgptPool(pi: ExtensionAPI) {
237
248
  return {
238
249
  message: {
239
250
  ...message,
240
- errorMessage: `ChatGPT account "${account.label}" is rate limited; switching to another account in the pool.`,
251
+ errorMessage: failoverMessage(account.label),
241
252
  },
242
253
  };
243
254
  });
244
255
 
245
- type AccountStatus = { account: Account; signedIn: boolean; usage?: Usage; error?: string };
246
-
247
256
  const loadStatuses = (ctx: ExtensionContext, signal?: AbortSignal): Promise<AccountStatus[]> =>
248
257
  Promise.all(
249
258
  accounts.map(async (account): Promise<AccountStatus> => {
@@ -275,7 +284,7 @@ export default function chatgptPool(pi: ExtensionAPI) {
275
284
 
276
285
  const describe = ({ account, signedIn, usage, error }: AccountStatus): string => {
277
286
  const limitedUntil = account.resetsAt && account.resetsAt > Date.now() ? account.resetsAt : undefined;
278
- if (!signedIn) return `${account.label} · not signed in, run /login and pick ChatGPT (${account.label})`;
287
+ if (!signedIn) return `${account.label} · not signed in, run /login and pick ${loginName(account)}`;
279
288
  const state = limitedUntil ? `limited until ${formatReset(limitedUntil)}` : "ready";
280
289
  if (!usage) return `${account.label} · ${state} · usage unavailable${error ? ` (${error})` : ""}`;
281
290
  const windows = usage.windows.map(
@@ -297,7 +306,25 @@ export default function chatgptPool(pi: ExtensionAPI) {
297
306
  update((config) => ({ ...config, accounts: [...config.accounts, account] }));
298
307
  registerPooledModels();
299
308
  pi.registerProvider(accountProvider(openai, account, onReset));
300
- ctx.ui.notify(`Added ChatGPT (${label}). Run /login and pick it to sign in.`, "info");
309
+ ctx.ui.notify(`Added ${loginName(account)}. Run /login and pick it to sign in.`, "info");
310
+ };
311
+
312
+ const setLoginName = (ctx: ExtensionContext, account: Account, name: string) => {
313
+ const next = { ...account, name: name.trim() || undefined };
314
+ update((config) => ({
315
+ ...config,
316
+ accounts: config.accounts.map((candidate) => (candidate.id === account.id ? next : candidate)),
317
+ }));
318
+ pi.registerProvider(accountProvider(openai, next, onReset));
319
+ ctx.ui.notify(`${account.label} now shows as "${loginName(next)}" in /login. Its sign-in is unchanged.`, "info");
320
+ };
321
+
322
+ const askLoginName = async (ctx: ExtensionContext, account: Account) => {
323
+ const name = await ctx.ui.input(
324
+ `Name shown in /login for ${account.label} (empty for "ChatGPT (${account.label})")`,
325
+ loginName(account),
326
+ );
327
+ if (name !== undefined) setLoginName(ctx, account, name);
301
328
  };
302
329
 
303
330
  const removeAccount = (ctx: ExtensionContext, account: Account) => {
@@ -352,7 +379,52 @@ export default function chatgptPool(pi: ExtensionAPI) {
352
379
  if (next && isStrategy(next)) setStrategy(ctx, next);
353
380
  };
354
381
 
382
+ const runPanel = async (ctx: ExtensionContext) => {
383
+ let statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
384
+ while (statuses) {
385
+ updateStatus(ctx);
386
+ const current = statuses.map((status) => ({
387
+ ...status,
388
+ account: accounts.find((account) => account.id === status.account.id) ?? status.account,
389
+ }));
390
+ const strategyName = STRATEGIES[strategy].split(":")[0];
391
+ const action = await ctx.ui.custom<PanelAction>(
392
+ (tui, theme, _keybindings, done) => new PoolPanel(tui, theme, current, strategyName, lastRouted, done),
393
+ );
394
+ if (!action || action.type === "close") return;
395
+ const status = "accountId" in action ? current.find((entry) => entry.account.id === action.accountId) : undefined;
396
+ let reload = false;
397
+ if (action.type === "refresh") {
398
+ reload = true;
399
+ } else if (action.type === "add") {
400
+ const label = (await ctx.ui.input("Label for the new ChatGPT account", "work"))?.trim();
401
+ if (label) addAccount(ctx, label);
402
+ reload = !!label;
403
+ } else if (action.type === "strategy") {
404
+ await chooseStrategy(ctx);
405
+ } else if (status && action.type === "spend") {
406
+ await spend(ctx, status.account, status.usage);
407
+ reload = true;
408
+ } else if (status && action.type === "name") {
409
+ await askLoginName(ctx, status.account);
410
+ } else if (status && action.type === "clear") {
411
+ setResetsAt(status.account.id, undefined);
412
+ ctx.ui.notify(`ChatGPT (${status.account.label}) is back in rotation.`, "info");
413
+ } else if (status && action.type === "remove") {
414
+ if (await ctx.ui.confirm(`Remove ${status.account.label}?`, "It will no longer be used by the pool.")) {
415
+ removeAccount(ctx, status.account);
416
+ statuses = statuses.filter((entry) => entry.account.id !== status.account.id);
417
+ }
418
+ }
419
+ if (reload) {
420
+ usageCache.clear();
421
+ statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
422
+ }
423
+ }
424
+ };
425
+
355
426
  const openPool = async (ctx: ExtensionContext) => {
427
+ if (ctx.mode === "tui") return runPanel(ctx);
356
428
  const statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
357
429
  if (!statuses) return;
358
430
  updateStatus(ctx);
@@ -390,12 +462,13 @@ export default function chatgptPool(pi: ExtensionAPI) {
390
462
  { value: "add", label: "add", description: "Add an account slot" },
391
463
  { value: "remove", label: "remove", description: "Remove an account" },
392
464
  { value: "spend", label: "spend", description: "Spend a banked reset, after confirming" },
465
+ { value: "name", label: "name", description: "Set the name an account shows in /login" },
393
466
  { value: "reset", label: "reset", description: "Forget recorded limits" },
394
467
  { value: "strategy", label: "strategy", description: "Choose how new conversations pick an account" },
395
468
  ];
396
469
 
397
470
  pi.registerCommand("chatgpt-pool", {
398
- description: "Manage pooled ChatGPT accounts: usage, banked resets, strategy, add, remove, spend, reset",
471
+ description: "Manage pooled ChatGPT accounts: usage, banked resets, strategy, add, remove, name, spend, reset",
399
472
  getArgumentCompletions(prefix) {
400
473
  const [action, ...rest] = prefix.split(" ");
401
474
  if (rest.length === 0) return SUBCOMMANDS.filter((item) => item.value.startsWith(action));
@@ -404,7 +477,8 @@ export default function chatgptPool(pi: ExtensionAPI) {
404
477
  .filter(([name]) => name.startsWith(rest.join(" ")))
405
478
  .map(([name, description]) => ({ value: `strategy ${name}`, label: name, description }));
406
479
  }
407
- if (action !== "remove" && action !== "spend") return null;
480
+ if (action !== "remove" && action !== "spend" && action !== "name") return null;
481
+ if (action === "name" && rest.length > 1) return null;
408
482
  const partial = rest.join(" ");
409
483
  return accounts
410
484
  .filter((account) => account.label.startsWith(partial))
@@ -425,8 +499,16 @@ export default function chatgptPool(pi: ExtensionAPI) {
425
499
  return removeAccount(ctx, account);
426
500
  }
427
501
 
502
+ if (action === "name") {
503
+ const [target = "", ...nameParts] = rest;
504
+ const account = findAccount(target);
505
+ if (!account) return ctx.ui.notify("Usage: /chatgpt-pool name <label> <name shown in /login>", "warning");
506
+ if (nameParts.length > 0 || !ctx.hasUI) return setLoginName(ctx, account, nameParts.join(" "));
507
+ return askLoginName(ctx, account);
508
+ }
509
+
428
510
  if (action === "reset") {
429
- update((config) => ({ ...config, accounts: config.accounts.map(({ id, label }) => ({ id, label })) }));
511
+ update((config) => ({ ...config, accounts: config.accounts.map((account) => ({ ...account, resetsAt: undefined })) }));
430
512
  updateStatus(ctx);
431
513
  return ctx.ui.notify("Cleared recorded limits. Every signed-in account is back in rotation.", "info");
432
514
  }
@@ -451,7 +533,7 @@ export default function chatgptPool(pi: ExtensionAPI) {
451
533
  return spend(ctx, account);
452
534
  }
453
535
 
454
- if (action) return ctx.ui.notify(`Unknown action "${action}". Use add, remove, spend, reset, or strategy.`, "warning");
536
+ if (action) return ctx.ui.notify(`Unknown action "${action}". Use add, remove, name, spend, reset, or strategy.`, "warning");
455
537
  if (accounts.length === 0) {
456
538
  const label = (await ctx.ui.input("No ChatGPT accounts yet. Label for the first one", "personal"))?.trim();
457
539
  if (label) addAccount(ctx, label);
package/src/panel.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { formatIn, formatWindow, usageBar } from "./format.ts";
4
+ import { type Account, loginName } from "./pool.ts";
5
+ import type { Usage } from "./usage.ts";
6
+
7
+ export type AccountStatus = { account: Account; signedIn: boolean; usage?: Usage; error?: string };
8
+
9
+ export type PanelAction =
10
+ | { type: "close" }
11
+ | { type: "refresh" }
12
+ | { type: "add" }
13
+ | { type: "strategy" }
14
+ | { type: "spend" | "clear" | "remove" | "name"; accountId: string };
15
+
16
+ const ACCOUNT_KEYS = new Map<string, "spend" | "clear" | "remove" | "name">([
17
+ ["s", "spend"],
18
+ ["c", "clear"],
19
+ ["d", "remove"],
20
+ ["n", "name"],
21
+ ]);
22
+ const POOL_KEYS = new Map<string, "refresh" | "add" | "strategy">([
23
+ ["r", "refresh"],
24
+ ["a", "add"],
25
+ ["t", "strategy"],
26
+ ]);
27
+ const BAR_WIDTH = 16;
28
+ const HINTS = [
29
+ "↑↓ select · s spend reset · c clear limit · n login name · d remove · a add · t strategy · r refresh · esc close",
30
+ "↑↓ · s spend · c clear · n name · d remove · a add · t strategy · r refresh · esc",
31
+ "s spend · c clear · n name · d del · a add · esc",
32
+ ];
33
+
34
+ export class PoolPanel implements Component {
35
+ private selected = 0;
36
+
37
+ constructor(
38
+ private readonly tui: TUI,
39
+ private readonly theme: Theme,
40
+ private readonly statuses: readonly AccountStatus[],
41
+ private readonly strategyName: string,
42
+ private readonly activeId: string | undefined,
43
+ private readonly done: (action: PanelAction) => void,
44
+ ) {}
45
+
46
+ handleInput(data: string): void {
47
+ if (matchesKey(data, Key.escape) || data === "q") return this.done({ type: "close" });
48
+ if (matchesKey(data, Key.up) || data === "k") return this.move(-1);
49
+ if (matchesKey(data, Key.down) || data === "j") return this.move(1);
50
+ const poolAction = POOL_KEYS.get(data);
51
+ const accountAction = ACCOUNT_KEYS.get(data);
52
+ const account = this.statuses[this.selected]?.account;
53
+ if (poolAction) this.done({ type: poolAction });
54
+ else if (accountAction && account) this.done({ type: accountAction, accountId: account.id });
55
+ }
56
+
57
+ invalidate(): void {}
58
+
59
+ render(width: number): string[] {
60
+ const { theme } = this;
61
+ const rule = theme.fg("borderAccent", "─".repeat(width));
62
+ const lines = [
63
+ rule,
64
+ `${theme.fg("accent", theme.bold("ChatGPT pool"))} ${theme.fg("muted", `strategy: ${this.strategyName}`)}`,
65
+ "",
66
+ ];
67
+ if (this.statuses.length === 0) lines.push(theme.fg("muted", "No accounts yet. Press a to add one."), "");
68
+ this.statuses.forEach((status, index) => lines.push(...this.renderAccount(status, index === this.selected), ""));
69
+ const hint = HINTS.find((text) => visibleWidth(text) <= width) ?? HINTS[HINTS.length - 1];
70
+ lines.push(theme.fg("dim", hint), rule);
71
+ return lines.map((line) => truncateToWidth(line, width));
72
+ }
73
+
74
+ private move(delta: number): void {
75
+ if (this.statuses.length === 0) return;
76
+ this.selected = (this.selected + delta + this.statuses.length) % this.statuses.length;
77
+ this.tui.requestRender();
78
+ }
79
+
80
+ private renderAccount({ account, signedIn, usage, error }: AccountStatus, selected: boolean): string[] {
81
+ const { theme } = this;
82
+ const now = Date.now();
83
+ const pointer = selected ? theme.fg("accent", "› ") : " ";
84
+ const name = selected ? theme.fg("accent", theme.bold(account.label)) : theme.bold(account.label);
85
+ const active = account.id === this.activeId ? theme.fg("muted", " (in use)") : "";
86
+ const limitedUntil = account.resetsAt && account.resetsAt > now ? account.resetsAt : undefined;
87
+ const state = !signedIn
88
+ ? theme.fg("muted", "not signed in")
89
+ : limitedUntil
90
+ ? theme.fg("warning", `limited · back in ${formatIn(limitedUntil - now)}`)
91
+ : theme.fg("success", "ready");
92
+ const banked =
93
+ usage && usage.banked > 0
94
+ ? theme.fg("accent", ` ${usage.banked} banked reset${usage.banked === 1 ? "" : "s"}`) +
95
+ (usage.usableNow > 0 ? theme.fg("success", ` (${usage.usableNow} usable now)`) : "")
96
+ : "";
97
+ const header = `${pointer}${name}${active} ${state}${banked}`;
98
+
99
+ if (!signedIn) return [header, theme.fg("dim", ` run /login and pick ${loginName(account)}`)];
100
+ if (!usage) return [header, theme.fg("dim", ` usage unavailable${error ? `: ${error}` : ""}`)];
101
+ return [
102
+ header,
103
+ ...usage.windows.map((window) => {
104
+ const bar = usageBar(window.usedPercent, BAR_WIDTH);
105
+ const color = window.usedPercent >= 90 ? "error" : window.usedPercent >= 70 ? "warning" : "success";
106
+ const label = formatWindow(window.windowSeconds).padEnd(7);
107
+ const percent = `${Math.round(window.usedPercent)}%`.padStart(4);
108
+ return ` ${theme.fg("muted", label)} ${theme.fg(color, bar.filled)}${theme.fg("dim", bar.empty)} ${percent} ${theme.fg("muted", `resets in ${formatIn(window.resetsAt - now)}`)}`;
109
+ }),
110
+ ];
111
+ }
112
+ }
package/src/pool.ts CHANGED
@@ -1,8 +1,16 @@
1
- export type Account = { id: string; label: string; resetsAt?: number };
1
+ export type Account = { id: string; label: string; name?: string; resetsAt?: number };
2
+
3
+ export function loginName(account: Account): string {
4
+ return account.name || `ChatGPT (${account.label})`;
5
+ }
2
6
 
3
7
  const USAGE_LIMIT = /usage.?limit|subscription_sharing_usage_limit_exceeded|insufficient_quota|quota/i;
4
8
  const RATE_LIMIT = /rate.?limit|too many requests|\b429\b/i;
5
9
 
10
+ export function failoverMessage(label: string): string {
11
+ return `ChatGPT account "${label}" hit its rate limit; switching to another account in the pool.`;
12
+ }
13
+
6
14
  export const USAGE_COOLDOWN_MS = 60 * 60 * 1000;
7
15
  export const RATE_COOLDOWN_MS = 60 * 1000;
8
16