From 2ecdc4b5333c4393f2b4d610dca18c290e3f2ac7 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 20 Feb 2026 04:54:05 +0000 Subject: [PATCH] feat(cli): improve quota display in /stats command and footer - Updates /stats and /stats session to show available quota even without usage. - Refines QuotaStatsInfo styling to match design: bold percentages and improved spacing. - Fixes reset time formatting ('Resets in') and visibility in the model usage table. - Standardizes column widths in StatsDisplay to prevent truncation of quota info. - Improves formatResetTime resilience to handle various date formats and small offsets. - Ensures informational quota text renders consistently for auto-models. --- .../cli/src/ui/components/QuotaStatsInfo.tsx | 62 ++- .../src/ui/components/StatsDisplay.test.tsx | 84 +++- .../cli/src/ui/components/StatsDisplay.tsx | 359 +++++++++--------- .../__snapshots__/StatsDisplay.test.tsx.snap | 94 ++++- packages/cli/src/ui/utils/formatters.ts | 30 +- packages/core/src/config/config.ts | 5 +- 6 files changed, 406 insertions(+), 228 deletions(-) diff --git a/packages/cli/src/ui/components/QuotaStatsInfo.tsx b/packages/cli/src/ui/components/QuotaStatsInfo.tsx index 22325db147..b9611d309b 100644 --- a/packages/cli/src/ui/components/QuotaStatsInfo.tsx +++ b/packages/cli/src/ui/components/QuotaStatsInfo.tsx @@ -27,37 +27,59 @@ export const QuotaStatsInfo: React.FC = ({ resetTime, showDetails = true, }) => { - if (remaining === undefined || limit === undefined || limit === 0) { + const hasData = + (remaining !== undefined && remaining !== null) || + (limit !== undefined && limit !== null && limit > 0); + + if (!hasData && !showDetails) { return null; } - const percentage = (remaining / limit) * 100; - const color = getStatusColor(percentage, { - green: QUOTA_THRESHOLD_HIGH, - yellow: QUOTA_THRESHOLD_MEDIUM, - }); + const percentage = + limit && limit > 0 && remaining !== undefined && remaining !== null + ? (remaining / limit) * 100 + : undefined; + + const color = + percentage !== undefined + ? getStatusColor(percentage, { + green: QUOTA_THRESHOLD_HIGH, + yellow: QUOTA_THRESHOLD_MEDIUM, + }) + : theme.text.primary; return ( - - {remaining === 0 - ? `Limit reached` - : `${percentage.toFixed(0)}% usage remaining`} - {resetTime && `, ${formatResetTime(resetTime)}`} - + {hasData && ( + + + {remaining === 0 + ? `Limit reached` + : percentage !== undefined + ? `${percentage.toFixed(0)}%` + : remaining !== undefined && remaining !== null + ? `${remaining.toLocaleString()}` + : 'Limit reached'} + + {remaining !== 0 && ( + + {percentage !== undefined || + (remaining !== undefined && remaining !== null) + ? ' usage remaining' + : ''} + + )} + {resetTime && `, ${formatResetTime(resetTime)}`} + + )} {showDetails && ( <> - - Usage limit: {limit.toLocaleString()} - Usage limits span all sessions and reset daily. - {remaining === 0 && ( - - Please /auth to upgrade or switch to an API key to continue. - - )} + + /auth to upgrade or switch to API key. + )} diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index af7e1b884d..4d465875af 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -465,9 +465,9 @@ describe('', () => { await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('Usage remaining'); + expect(output).toContain('Usage left'); expect(output).toContain('75.0%'); - expect(output).toContain('resets in 1h 30m'); + expect(output).toContain('(Resets in 1h 30m)'); expect(output).toMatchSnapshot(); vi.useRealTimers(); @@ -523,18 +523,92 @@ describe('', () => { // (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5% expect(output).toContain('65% usage remaining'); - expect(output).toContain('Usage limit: 1,100'); expect(output).toMatchSnapshot(); vi.useRealTimers(); }); + it('renders pooled quota even when no models have been used and no individual quotas are available', async () => { + const metrics = createTestMetrics(); + + useSessionStatsMock.mockReturnValue({ + stats: { + sessionId: 'test-session-id', + sessionStartTime: new Date(), + metrics, + lastPromptTokenCount: 0, + promptCount: 0, + }, + getPromptCount: () => 0, + startNewPrompt: vi.fn(), + }); + + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 100 }, + ); + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('Auto (Gemini 3) Usage'); + expect(output).toContain('50% usage remaining'); + expect(output).toMatchSnapshot(); + }); + + it('renders model table quota percentage even when resetTime is missing', async () => { + // No models in metrics, but a quota for gemini-2.5-flash without resetTime + const metrics = createTestMetrics(); + + const quotas: RetrieveUserQuotaResponse = { + buckets: [ + { + modelId: 'gemini-2.5-flash', + remainingAmount: '50', + remainingFraction: 0.5, + // resetTime is missing + }, + ], + }; + + useSessionStatsMock.mockReturnValue({ + stats: { + sessionId: 'test-session-id', + sessionStartTime: new Date(), + metrics, + lastPromptTokenCount: 0, + promptCount: 5, + }, + getPromptCount: () => 5, + startNewPrompt: vi.fn(), + }); + + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 100 }, + ); + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('gemini-2.5-flash'); + expect(output).toContain('-'); // for requests + expect(output).toContain('50.0%'); + expect(output).not.toContain('Resets in'); + expect(output).toMatchSnapshot(); + }); + it('renders quota information for unused models', async () => { const now = new Date('2025-01-01T12:00:00Z'); vi.useFakeTimers(); vi.setSystemTime(now); - // No models in metrics, but a quota for gemini-2.5-flash + // No models in metrics, but a quota for gemini-2.5-flash with resetTime const metrics = createTestMetrics(); const resetTime = new Date(now.getTime() + 1000 * 60 * 120).toISOString(); // 2 hours from now @@ -572,7 +646,7 @@ describe('', () => { expect(output).toContain('gemini-2.5-flash'); expect(output).toContain('-'); // for requests expect(output).toContain('50.0%'); - expect(output).toContain('resets in 2h'); + expect(output).toContain('(Resets in 2h)'); expect(output).toMatchSnapshot(); vi.useRealTimers(); diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index d12dd4eb07..c90cff6cc6 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -157,18 +157,27 @@ const ModelUsageTable: React.FC<{ }) => { const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel); - if (rows.length === 0) { + const isAuto = currentModel && isAutoModel(currentModel); + const modelUsageTitle = isAuto + ? `${getDisplayString(currentModel)} Usage` + : `Model Usage`; + + const hasPooledQuota = + (pooledRemaining !== undefined && pooledRemaining !== null) || + (pooledLimit !== undefined && pooledLimit !== null && pooledLimit > 0); + + if (rows.length === 0 && !hasPooledQuota && !isAuto) { return null; } const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket); - const nameWidth = 25; + const nameWidth = 22; const requestsWidth = 7; const uncachedWidth = 15; const cachedWidth = 14; const outputTokensWidth = 15; - const usageLimitWidth = showQuotaColumn ? 28 : 0; + const usageLimitWidth = showQuotaColumn ? 40 : 0; const cacheEfficiencyColor = getStatusColor(cacheEfficiency, { green: CACHE_EFFICIENCY_HIGH, @@ -182,11 +191,6 @@ const ModelUsageTable: React.FC<{ ? usageLimitWidth : uncachedWidth + cachedWidth + outputTokensWidth); - const isAuto = currentModel && isAutoModel(currentModel); - const modelUsageTitle = isAuto - ? `${getDisplayString(currentModel)} Usage` - : `Model Usage`; - return ( {/* Header */} @@ -198,178 +202,185 @@ const ModelUsageTable: React.FC<{ - {isAuto && - showQuotaColumn && - pooledRemaining !== undefined && - pooledLimit !== undefined && - pooledLimit > 0 && ( - - + {(hasPooledQuota || isAuto) && ( + + + {isAuto && ( For a full token breakdown, run `/stats model`. - - )} - - - - - Model - - - - - Reqs - - - - {!showQuotaColumn && ( - <> - - - Input Tokens - - - - - Cache Reads - - - - - Output Tokens - - - - )} - {showQuotaColumn && ( - - - Usage remaining - - - )} - - - {/* Divider */} - - - {rows.map((row) => ( - - - - {row.modelName} - - - - - {row.requests} - - - {!showQuotaColumn && ( - <> - - - {row.inputTokens} - - - - {row.cachedTokens} - - - - {row.outputTokens} - - - )} - - {row.bucket && - row.bucket.remainingFraction != null && - row.bucket.resetTime && ( - - {(row.bucket.remainingFraction * 100).toFixed(1)}%{' '} - {formatResetTime(row.bucket.resetTime)} - - )} - - ))} + )} - {cacheEfficiency > 0 && !showQuotaColumn && ( + {rows.length > 0 && ( + <> + + + + Model + + + + + Reqs + + + + {!showQuotaColumn && ( + <> + + + Input Tokens + + + + + Cache Reads + + + + + Output Tokens + + + + )} + {showQuotaColumn && ( + + + Usage left + + + )} + + + {/* Divider */} + + + {rows.map((row) => ( + + + + {row.modelName} + + + + + {row.requests} + + + {!showQuotaColumn && ( + <> + + + {row.inputTokens} + + + + {row.cachedTokens} + + + + {row.outputTokens} + + + + )} + + {row.bucket && row.bucket.remainingFraction != null && ( + + {(row.bucket.remainingFraction * 100).toFixed(1)}% + {row.bucket.resetTime && ( + + {' '} + ( + {formatResetTime(row.bucket.resetTime).replace( + 'resets in', + 'Resets in', + )} + ) + + )} + + )} + + + ))} + + )} + + {cacheEfficiency > 0 && !showQuotaColumn && rows.length > 0 && ( Savings Highlight:{' '} diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap index cc31c301ba..8e5f8e34ee 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -118,9 +118,9 @@ exports[` > Conditional Rendering Tests > hides Efficiency secti │ » Tool Time: 0s (0.0%) │ │ │ │ Model Usage │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ──────────────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 1 100 0 100 │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ───────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 100 0 100 │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -146,6 +146,58 @@ exports[` > Conditional Rendering Tests > hides User Agreement w " `; +exports[` > Quota Display > renders model table quota percentage even when resetTime is missing 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Interaction Summary │ +│ Session ID: test-session-id │ +│ Tool Calls: 0 ( ✓ 0 x 0 ) │ +│ Success Rate: 0.0% │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 0s │ +│ » API Time: 0s (0.0%) │ +│ » Tool Time: 0s (0.0%) │ +│ │ +│ Model Usage │ +│ Model Reqs Usage left │ +│ ───────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-flash - 50.0% │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[` > Quota Display > renders pooled quota even when no models have been used and no individual quotas are available 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Interaction Summary │ +│ Session ID: test-session-id │ +│ Tool Calls: 0 ( ✓ 0 x 0 ) │ +│ Success Rate: 0.0% │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 0s │ +│ » API Time: 0s (0.0%) │ +│ » Tool Time: 0s (0.0%) │ +│ │ +│ Auto (Gemini 3) Usage │ +│ 50% usage remaining │ +│ Usage limits span all sessions and reset daily. │ +│ /auth to upgrade or switch to API key. │ +│ For a full token breakdown, run \`/stats model\`. │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + exports[` > Quota Display > renders pooled quota information for auto mode 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -164,14 +216,14 @@ exports[` > Quota Display > renders pooled quota information for │ │ │ auto Usage │ │ 65% usage remaining │ -│ Usage limit: 1,100 │ │ Usage limits span all sessions and reset daily. │ +│ /auth to upgrade or switch to API key. │ │ For a full token breakdown, run \`/stats model\`. │ │ │ -│ Model Reqs Usage remaining │ -│ ──────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro - │ -│ gemini-2.5-flash - │ +│ Model Reqs Usage left │ +│ ───────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro - 10.0% │ +│ gemini-2.5-flash - 70.0% │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -194,9 +246,9 @@ exports[` > Quota Display > renders quota information for unused │ » Tool Time: 0s (0.0%) │ │ │ │ Model Usage │ -│ Model Reqs Usage remaining │ -│ ──────────────────────────────────────────────────────────── │ -│ gemini-2.5-flash - 50.0% resets in 2h │ +│ Model Reqs Usage left │ +│ ───────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-flash - 50.0% (Resets in 2h) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -219,9 +271,9 @@ exports[` > Quota Display > renders quota information when quota │ » Tool Time: 0s (0.0%) │ │ │ │ Model Usage │ -│ Model Reqs Usage remaining │ -│ ──────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 1 75.0% resets in 1h 30m │ +│ Model Reqs Usage left │ +│ ───────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 75.0% (Resets in 1h 30m) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -284,10 +336,10 @@ exports[` > renders a table with two models correctly 1`] = ` │ » Tool Time: 0s (0.0%) │ │ │ │ Model Usage │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ──────────────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 3 500 500 2,000 │ -│ gemini-2.5-flash 5 15,000 10,000 15,000 │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ───────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 3 500 500 2,000 │ +│ gemini-2.5-flash 5 15,000 10,000 15,000 │ │ │ │ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │ │ │ @@ -313,9 +365,9 @@ exports[` > renders all sections when all data is present 1`] = │ » Tool Time: 123ms (55.2%) │ │ │ │ Model Usage │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ──────────────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 1 50 50 100 │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ───────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 50 50 100 │ │ │ │ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │ │ │ diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 3b4335bac9..d85549e68f 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -99,8 +99,23 @@ export function stripReferenceContent(text: string): string { } export const formatResetTime = (resetTime: string): string => { - const diff = new Date(resetTime).getTime() - Date.now(); - if (diff <= 0) return ''; + let date = new Date(resetTime); + + // If invalid, try parsing as a number (unix timestamp) + if (isNaN(date.getTime())) { + const timestamp = parseInt(resetTime, 10); + if (!isNaN(timestamp)) { + // Could be seconds or milliseconds. If < 10^12, likely seconds. + date = new Date(timestamp < 10000000000 ? timestamp * 1000 : timestamp); + } + } + + if (isNaN(date.getTime())) { + return ''; + } + + const diff = date.getTime() - Date.now(); + if (diff <= 0) return '(Resetting...)'; const totalMinutes = Math.ceil(diff / (1000 * 60)); const hours = Math.floor(totalMinutes / 60); @@ -113,11 +128,16 @@ export const formatResetTime = (resetTime: string): string => { unitDisplay: 'narrow', }).format(val); + let timeStr = ''; if (hours > 0 && minutes > 0) { - return `resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`; + timeStr = `${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`; } else if (hours > 0) { - return `resets in ${fmt(hours, 'hour')}`; + timeStr = fmt(hours, 'hour'); + } else if (minutes > 0) { + timeStr = fmt(minutes, 'minute'); + } else { + timeStr = '< 1m'; } - return `resets in ${fmt(minutes, 'minute')}`; + return `resets in ${timeStr}`; }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fc4f7c2ff7..9cd8fb9521 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1335,11 +1335,10 @@ export class Config { const flashQuota = this.modelQuotas.get(flashModel); if (proQuota || flashQuota) { - // For reset time, take the one that is furthest in the future (most conservative) + // For reset time, take the one that is nearest in the future (soonest reset) const resetTime = [proQuota?.resetTime, flashQuota?.resetTime] .filter((t): t is string => !!t) - .sort() - .reverse()[0]; + .sort()[0]; return { remaining: (proQuota?.remaining ?? 0) + (flashQuota?.remaining ?? 0),