diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index c1dfa9a3..38a09c43 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -78,6 +78,93 @@ function formatResetTimeISO(isoTime: string): string { return formatResetTime(seconds); } +type CodexWindowKind = + | 'usage-5h' + | 'usage-weekly' + | 'code-review-5h' + | 'code-review-weekly' + | 'code-review' + | 'unknown'; + +function getCodexWindowKind(label: string): CodexWindowKind { + const lower = (label || '').toLowerCase(); + const isCodeReview = lower.includes('code review') || lower.includes('code_review'); + const isPrimary = lower.includes('primary'); + const isSecondary = lower.includes('secondary'); + + if (isCodeReview) { + if (isPrimary) return 'code-review-5h'; + if (isSecondary) return 'code-review-weekly'; + return 'code-review'; + } + + if (isPrimary) return 'usage-5h'; + if (isSecondary) return 'usage-weekly'; + return 'unknown'; +} + +function getCodexWindowDisplayLabel(label: string): string { + switch (getCodexWindowKind(label)) { + case 'usage-5h': + return '5h usage limit'; + case 'usage-weekly': + return 'Weekly usage limit'; + case 'code-review-5h': + return 'Code review (5h)'; + case 'code-review-weekly': + return 'Code review (weekly)'; + case 'code-review': + return 'Code review'; + case 'unknown': + return label; + } +} + +function getCodexCoreUsageWindows(windows: CodexQuotaResult['windows']): { + fiveHourWindow: CodexQuotaResult['windows'][number] | null; + weeklyWindow: CodexQuotaResult['windows'][number] | null; +} { + let fiveHourWindow: CodexQuotaResult['windows'][number] | null = null; + let weeklyWindow: CodexQuotaResult['windows'][number] | null = null; + const nonCodeReviewWindows: CodexQuotaResult['windows'] = []; + + for (const window of windows) { + const kind = getCodexWindowKind(window.label); + if (kind === 'usage-5h') { + if (!fiveHourWindow) fiveHourWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'usage-weekly') { + if (!weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'unknown') { + nonCodeReviewWindows.push(window); + } + } + + if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) { + const withReset = nonCodeReviewWindows + .filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0) + .sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0)); + + if (!fiveHourWindow) { + fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null; + } + + if (!weeklyWindow) { + weeklyWindow = + withReset.length > 1 + ? withReset[withReset.length - 1] + : nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null; + } + } + + return { fiveHourWindow, weeklyWindow }; +} + function displayAntigravityQuotaSection( quotaResult: Awaited> ): void { @@ -143,22 +230,32 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR continue; } + const { fiveHourWindow, weeklyWindow } = getCodexCoreUsageWindows(quota.windows); + const coreUsageWindows = [fiveHourWindow, weeklyWindow].filter( + (w, index, arr): w is NonNullable => !!w && arr.indexOf(w) === index + ); + const statusWindows = coreUsageWindows.length > 0 ? coreUsageWindows : quota.windows; + const avgQuota = - quota.windows.length > 0 - ? quota.windows.reduce((sum, w) => sum + w.remainingPercent, 0) / quota.windows.length + statusWindows.length > 0 + ? statusWindows.reduce((sum, w) => sum + w.remainingPercent, 0) / statusWindows.length : 0; const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`); - for (const window of quota.windows) { + const orderedWindows = [fiveHourWindow, weeklyWindow, ...quota.windows].filter( + (w, index, arr): w is NonNullable => !!w && arr.indexOf(w) === index + ); + + for (const window of orderedWindows) { const bar = formatQuotaBar(window.remainingPercent); const resetLabel = window.resetAfterSeconds ? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`) : ''; console.log( - ` ${window.label.padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` + ` ${getCodexWindowDisplayLabel(window.label).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` ); } console.log(''); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index ba990e63..6e71c344 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -2,7 +2,13 @@ * Account Card Component for Flow Visualization */ -import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils'; +import { + cn, + getCodexQuotaBreakdown, + getProviderMinQuota, + getProviderResetTime, + isCodexQuotaResult, +} from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, Loader2, Pause, Play, KeyRound } from 'lucide-react'; import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats'; @@ -96,6 +102,14 @@ export function AccountCard({ // Use shared helper for provider-specific minimum quota const minQuota = getProviderMinQuota(account.provider, quota); const resetTime = getProviderResetTime(account.provider, quota); + const codexBreakdown = + account.provider === 'codex' && quota && isCodexQuotaResult(quota) + ? getCodexQuotaBreakdown(quota.windows) + : null; + const codexQuotaRows = [ + { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null }, + { label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, + ].filter((row): row is { label: string; value: number } => row.value !== null); // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = @@ -229,6 +243,15 @@ export function AccountCard({ {minQuota}% + {account.provider === 'codex' && codexQuotaRows.length > 0 && ( +
+ {codexQuotaRows.map((row) => ( + + {row.label} {row.value}% + + ))} +
+ )}
row.value !== null); return (
-
- - {minQuota}% -
+ {account.provider === 'codex' && codexQuotaRows.length > 0 ? ( +
+ {codexQuotaRows.map((row) => ( +
+ + {row.label} + + + + {row.value}% + +
+ ))} +
+ ) : ( +
+ + {minQuota}% +
+ )}
{quota && } diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 177394e8..01826405 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -7,6 +7,8 @@ import { Clock } from 'lucide-react'; import { cn, formatResetTime, + getCodexQuotaBreakdown, + getCodexWindowDisplayLabel, getModelsWithTiers, groupModelsByTier, isAgyQuotaResult, @@ -64,13 +66,29 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro // Codex provider tooltip if (isCodexQuotaResult(quota)) { + const { fiveHourWindow, weeklyWindow, codeReviewWindows, unknownWindows } = + getCodexQuotaBreakdown(quota.windows); + const orderedWindows = [fiveHourWindow, weeklyWindow, ...codeReviewWindows, ...unknownWindows] + .filter((w): w is NonNullable => !!w) + .filter( + (w, index, arr) => + arr.findIndex( + (candidate) => candidate.label === w.label && candidate.resetAt === w.resetAt + ) === index + ); + return (

Rate Limits:

{quota.planType &&

Plan: {quota.planType}

} - {quota.windows.map((w) => ( -
- {w.label} + {orderedWindows.map((w, index) => ( +
+ + {getCodexWindowDisplayLabel(w.label)} + {w.remainingPercent}%
))} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 6c2df585..9959eb71 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -306,12 +306,144 @@ export function groupModelsByTier(models: TieredModel[]): Map 0) { + const withReset = nonCodeReviewWindows + .filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0) + .sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0)); + + if (!fiveHourWindow) { + fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null; + } + + if (!weeklyWindow) { + weeklyWindow = + withReset.length > 1 + ? withReset[withReset.length - 1] + : nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null; + } + } + + return { + fiveHourWindow, + weeklyWindow, + codeReviewWindows, + unknownWindows, + }; +} + /** * Get minimum remaining percentage across Codex rate limit windows */ export function getMinCodexQuota(windows: CodexQuotaWindow[]): number | null { if (!windows || windows.length === 0) return null; - const percentages = windows.map((w) => w.remainingPercent); + + const { fiveHourWindow, weeklyWindow } = getCodexQuotaBreakdown(windows); + const usageWindows = [fiveHourWindow, weeklyWindow].filter( + (w, index, arr): w is CodexQuotaWindow => !!w && arr.indexOf(w) === index + ); + + // Primary account quota should be driven by core usage windows, not code-review windows. + const sourceWindows = usageWindows.length > 0 ? usageWindows : windows; + const percentages = sourceWindows.map((w) => w.remainingPercent); return Math.min(...percentages); } @@ -320,7 +452,13 @@ export function getMinCodexQuota(windows: CodexQuotaWindow[]): number | null { */ export function getCodexResetTime(windows: CodexQuotaWindow[]): string | null { if (!windows || windows.length === 0) return null; - const resets = windows.map((w) => w.resetAt).filter((t): t is string => t !== null); + + const { fiveHourWindow, weeklyWindow } = getCodexQuotaBreakdown(windows); + const usageWindows = [fiveHourWindow, weeklyWindow].filter( + (w, index, arr): w is CodexQuotaWindow => !!w && arr.indexOf(w) === index + ); + const sourceWindows = usageWindows.length > 0 ? usageWindows : windows; + const resets = sourceWindows.map((w) => w.resetAt).filter((t): t is string => t !== null); if (resets.length === 0) return null; return resets.sort()[0]; } diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index a1f1c1e4..7735c853 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -386,7 +386,8 @@ describe('getMinCodexQuota', () => { resetAt: '2026-01-30T14:30:32Z', }, ]; - expect(getMinCodexQuota(windows)).toBe(8.7); + // Core account quota should ignore code-review windows. + expect(getMinCodexQuota(windows)).toBe(21.1); }); }); }); @@ -439,10 +440,10 @@ describe('getCodexResetTime', () => { usedPercent: 50, remainingPercent: 50, resetAfterSeconds: 1800, - resetAt: '2026-01-30T16:00:00Z', + resetAt: '2026-01-30T09:00:00Z', }, ]; - // Should return earliest (alphabetically sorted) + // Should ignore code-review reset when choosing main account reset. expect(getCodexResetTime(windows)).toBe('2026-01-30T10:00:00Z'); }); }); @@ -516,9 +517,10 @@ describe('getCodexResetTime', () => { usedPercent: 75, remainingPercent: 25, resetAfterSeconds: 5400, - resetAt: '2026-01-30T18:45:00Z', + resetAt: '2026-01-30T08:15:00Z', }, ]; + // Code-review windows should not drive the main account reset. expect(getCodexResetTime(windows)).toBe('2026-01-30T09:30:00Z'); }); });