Skip to content
This repository was archived by the owner on Feb 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 52 additions & 25 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,23 +693,50 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
accounts: menuAccounts,
input: process.stdin,
output: process.stdout,
handlers: {
onCheckQuotas: async () => {
await Promise.all(
accounts.map(async (acc, index) => {
if (acc.enabled === false) return;
const live = accountManager.getAccountByIndex(index);
if (!live) return;
const auth = accountManager.toAuthDetails(live);
if (auth.access && auth.expires > Date.now()) {
await codexStatus.fetchFromBackend(live, auth.access);
}
}),
);
const snapshots = await codexStatus.getAllSnapshots();
const report = renderQuotaReport(menuAccounts, snapshots, Date.now());
process.stdout.write(report.join("\n") + "\n");
},
handlers: {
onCheckQuotas: async () => {
await Promise.all(
accounts.map(async (acc, index) => {
if (acc.enabled === false) return;
const live = accountManager.getAccountByIndex(index);
if (!live) return;
try {
let accessToken: string | null = null;
const auth = accountManager.toAuthDetails(live);
if (auth.access && auth.expires > Date.now()) {
accessToken = auth.access;
} else {
const refreshed = await accountManager.refreshAccountWithFallback(live);
if (refreshed.type === "success") {
if (refreshed.headers) {
await codexStatus.updateFromHeaders(
live,
Object.fromEntries(refreshed.headers.entries()),
);
}
const refreshedAuth = {
type: "oauth" as const,
access: refreshed.access,
refresh: refreshed.refresh,
expires: refreshed.expires,
};
accountManager.updateFromAuth(live, refreshedAuth);
await accountManager.saveToDisk();
accessToken = refreshed.access;
}
}
if (accessToken) {
await codexStatus.fetchFromBackend(live, accessToken);
}
} catch {
// Keep rendering other accounts even if one fails quota refresh/fetch.
}
}),
);
const snapshots = await codexStatus.getAllSnapshots();
const report = renderQuotaReport(accounts, snapshots, Date.now());
process.stdout.write(report.join("\n") + "\n");
},
onConfigureModels: async () => {
process.stdout.write(
"Edit your opencode.jsonc (or opencode.json) to configure models.\n",
Expand Down Expand Up @@ -769,18 +796,18 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
authorize: async (_inputs?: Record<string, string>) => {
let replaceExisting = false;

const existingStorage = await loadAccounts();
if (existingStorage?.accounts?.length && process.stdin.isTTY && process.stdout.isTTY) {
const menuResult = await runInteractiveAuthMenu({ allowExit: true });
if (menuResult === "exit") {
return {
const existingStorage = await loadAccounts();
if (existingStorage?.accounts?.length && process.stdin.isTTY && process.stdout.isTTY) {
const menuResult = await runInteractiveAuthMenu({ allowExit: true });
if (menuResult === "exit") {
return {
url: "about:blank",
method: "code" as const,
instructions: "Login cancelled.",
callback: async () => ({ type: "failed" as const }),
};
callback: async () => ({ type: "failed" as const }),
};
}
}
}

const { pkce, state, url } = await createAuthorizationFlow();
let serverInfo = null;
Expand Down
10 changes: 8 additions & 2 deletions lib/ui/auth-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ function colorize(text: string, color: string, useColor: boolean): string {
return useColor ? `${color}${text}${ANSI.reset}` : text;
}

function formatAccountDisplayName(account: AccountInfo): string {
const base = account.email || `Account ${account.index + 1}`;
const plan = typeof account.plan === "string" ? account.plan.trim() : "";
return plan ? `${base} (${plan})` : base;
}

function getStatusBadge(status: AccountStatus | undefined, useColor: boolean): string {
switch (status) {
case "rate-limited":
Expand Down Expand Up @@ -76,7 +82,7 @@ export function formatStatusBadges(
}

function buildAccountLabel(account: AccountInfo, useColor: boolean): string {
const baseLabel = account.email || `Account ${account.index + 1}`;
const baseLabel = formatAccountDisplayName(account);
const badges = formatStatusBadges(account, useColor);
return badges ? `${baseLabel} ${badges}` : baseLabel;
}
Expand Down Expand Up @@ -190,7 +196,7 @@ export async function showAccountDetails(
): Promise<AccountAction> {
const useColor = options.useColor ?? shouldUseColor();
const output = options.output ?? process.stdout;
const label = account.email || `Account ${account.index + 1}`;
const label = formatAccountDisplayName(account);
const badges = formatStatusBadges(account, useColor);

const bold = useColor ? ANSI.bold : "";
Expand Down
201 changes: 123 additions & 78 deletions lib/ui/codex-quota-report.ts
Original file line number Diff line number Diff line change
@@ -1,106 +1,151 @@
import { createHash } from "node:crypto";

import type { CodexRateLimitSnapshot } from "../codex-status.js";
import { ANSI, shouldUseColor } from "./tty/ansi.js";

type AccountLike = {
accountId?: string;
email?: string;
plan?: string;
refreshToken?: string;
accountId?: string;
email?: string;
plan?: string;
refreshToken?: string;
};

const LINE = "=".repeat(52);
const BAR_WIDTH = 20;

function colorize(text: string, color: string, useColor: boolean): string {
return useColor ? `${color}${text}${ANSI.reset}` : text;
}

function getSnapshotKey(account: AccountLike): string | null {
if (account.accountId && account.email && account.plan) {
return `${account.accountId}|${account.email.toLowerCase()}|${account.plan}`;
}
if (account.refreshToken) {
return createHash("sha256").update(account.refreshToken).digest("hex");
}
return null;
if (account.accountId && account.email && account.plan) {
return `${account.accountId}|${account.email.toLowerCase()}|${account.plan}`;
}
if (account.refreshToken) {
return createHash("sha256").update(account.refreshToken).digest("hex");
}
return null;
}

function findSnapshot(
account: AccountLike,
snapshots: CodexRateLimitSnapshot[],
account: AccountLike,
snapshots: CodexRateLimitSnapshot[],
): CodexRateLimitSnapshot | undefined {
const key = getSnapshotKey(account);
if (key) {
const direct = snapshots.find((snapshot) => (snapshot as any).key === key);
if (direct) return direct;
}
const key = getSnapshotKey(account);
if (key) {
const direct = snapshots.find((snapshot) => (snapshot as any).key === key);
if (direct) return direct;
}

return snapshots.find(
(snapshot) =>
snapshot.accountId === account.accountId &&
snapshot.email?.toLowerCase() === account.email?.toLowerCase() &&
snapshot.plan === account.plan,
);
return snapshots.find(
(snapshot) =>
snapshot.accountId === account.accountId &&
snapshot.email?.toLowerCase() === account.email?.toLowerCase() &&
snapshot.plan === account.plan,
);
}

function formatReset(resetAt: number, now: number): string {
if (!resetAt || resetAt <= now) return "";
const date = new Date(resetAt);
const timeStr = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
if (resetAt - now <= 24 * 60 * 60 * 1000) {
return ` (resets ${timeStr})`;
}
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return ` (resets ${timeStr} ${date.getDate()} ${months[date.getMonth()]})`;
if (!resetAt || resetAt <= now) return "";
const date = new Date(resetAt);
const timeStr = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
if (resetAt - now <= 24 * 60 * 60 * 1000) {
return ` (resets ${timeStr})`;
}
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
return ` (resets ${timeStr} ${date.getDate()} ${months[date.getMonth()]})`;
}

function renderBar(usedPercent: number | undefined | null): { bar: string; percent: string } {
if (usedPercent === undefined || usedPercent === null || Number.isNaN(usedPercent)) {
const empty = "░".repeat(BAR_WIDTH);
return { bar: empty, percent: "???" };
}
const left = Math.max(0, Math.min(100, Math.round(100 - usedPercent)));
const filled = Math.round((left / 100) * BAR_WIDTH);
const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
const percent = `${String(left).padStart(3, " ")}%`;
return { bar, percent };
function renderBar(
usedPercent: number | undefined | null,
useColor: boolean,
): { bar: string; percent: string } {
if (
usedPercent === undefined ||
usedPercent === null ||
Number.isNaN(usedPercent)
) {
const empty = "░".repeat(BAR_WIDTH);
return { bar: empty, percent: "???" };
}
const left = Math.max(0, Math.min(100, Math.round(100 - usedPercent)));
const filled = Math.round((left / 100) * BAR_WIDTH);
const filledBar = "█".repeat(filled);
const emptyBar = "░".repeat(BAR_WIDTH - filled);
const fillColor =
left >= 70 ? ANSI.green : left >= 35 ? ANSI.yellow : ANSI.red;
const bar = `${colorize(filledBar, fillColor, useColor)}${colorize(emptyBar, ANSI.dim, useColor)}`;
const percent = `${String(left).padStart(3, " ")}%`;
return { bar, percent };
}

function formatAccountName(account: AccountLike): string {
const label = account.email || account.accountId || "Unknown account";
const plan = typeof account.plan === "string" ? account.plan.trim() : "";
return plan ? `${label} (${plan})` : label;
}

export function renderQuotaReport(
accounts: AccountLike[],
snapshots: CodexRateLimitSnapshot[],
now = Date.now(),
accounts: AccountLike[],
snapshots: CodexRateLimitSnapshot[],
now = Date.now(),
useColor = shouldUseColor(),
): string[] {
const lines: string[] = ["Checking quotas for all accounts..."];
for (const account of accounts) {
const email = account.email || account.accountId || "Unknown account";
const snapshot = findSnapshot(account, snapshots);
lines.push(LINE);
lines.push(` ${email}`);
lines.push(LINE);
lines.push(" +- Codex CLI Quota");

const primary = renderBar(snapshot?.primary?.usedPercent);
const primaryReset = snapshot?.primary?.resetAt
? formatReset(snapshot.primary.resetAt, now)
: primary.percent === "???"
? " ???"
: "";
lines.push(` | |- GPT-5 ${primary.bar} ${primary.percent}${primaryReset}`);
const lines: string[] = [];
lines.push(`${colorize("┌", ANSI.dim, useColor)} Quota Report`);
for (const account of accounts) {
const name = formatAccountName(account);
const snapshot = findSnapshot(account, snapshots);
lines.push(`${colorize("│", ANSI.cyan, useColor)}`);
lines.push(
`${colorize("├", ANSI.cyan, useColor)} ${colorize(name, ANSI.bold, useColor)}`,
);
lines.push(
`${colorize("│", ANSI.cyan, useColor)} ${colorize("Codex CLI Quota", ANSI.dim, useColor)}`,
);

const secondary = renderBar(snapshot?.secondary?.usedPercent);
const secondaryReset = snapshot?.secondary?.resetAt
? formatReset(snapshot.secondary.resetAt, now)
: secondary.percent === "???"
? " ???"
: "";
lines.push(` | |- Weekly ${secondary.bar} ${secondary.percent}${secondaryReset}`);
const primary = renderBar(snapshot?.primary?.usedPercent, useColor);
const primaryReset = snapshot?.primary?.resetAt
? formatReset(snapshot.primary.resetAt, now)
: primary.percent === "???"
? " ???"
: "";
lines.push(
`${colorize("│", ANSI.cyan, useColor)} ${colorize("●", ANSI.green, useColor)} 5h ${primary.bar} ${primary.percent}${primaryReset}`,
);

const creditInfo = snapshot?.credits;
const creditStr = creditInfo
? creditInfo.unlimited
? "unlimited"
: `${creditInfo.balance} credits`
: "0 credits";
lines.push(` |---- Credits ${creditStr}`);
}
const secondary = renderBar(snapshot?.secondary?.usedPercent, useColor);
const secondaryReset = snapshot?.secondary?.resetAt
? formatReset(snapshot.secondary.resetAt, now)
: secondary.percent === "???"
? " ???"
: "";
lines.push(
`${colorize("│", ANSI.cyan, useColor)} ${colorize("●", ANSI.green, useColor)} Weekly ${secondary.bar} ${secondary.percent}${secondaryReset}`,
);

return lines;
const creditInfo = snapshot?.credits;
const creditStr = creditInfo
? creditInfo.unlimited
? "unlimited"
: `${creditInfo.balance} credits`
: "0 credits";
lines.push(
`${colorize("│", ANSI.cyan, useColor)} ${colorize("●", ANSI.green, useColor)} Credits ${creditStr}`,
);
}
lines.push(`${colorize("└", ANSI.cyan, useColor)}`);
return lines;
}
1 change: 1 addition & 0 deletions test/auth-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe("auth menu helpers", () => {

const accountItem = items.find((item) => item.label.includes(account.email));
expect(accountItem).toBeTruthy();
expect(accountItem!.label).toContain(`(${account.plan})`);
expect(accountItem!.label).toContain("[enabled]");
expect(accountItem!.label).toContain("[last active]");
expect(accountItem!.hint).toContain("used");
Expand Down
Loading