diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index de6a33caf9..4e79bd4fc8 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -77,7 +77,6 @@ describe('', () => { expect(output).toContain('Performance'); expect(output).toContain('Interaction Summary'); expect(output).not.toContain('Efficiency & Optimizations'); - expect(output).not.toContain('Model'); // The table header expect(output).toMatchSnapshot(); }); @@ -450,5 +449,51 @@ describe('', () => { vi.useRealTimers(); }); + + it('renders quota information for unused models', () => { + 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 + const metrics = createTestMetrics(); + + const resetTime = new Date(now.getTime() + 1000 * 60 * 120).toISOString(); // 2 hours from now + + const quotas: RetrieveUserQuotaResponse = { + buckets: [ + { + modelId: 'gemini-2.5-flash', + remainingFraction: 0.5, + resetTime, + }, + ], + }; + + useSessionStatsMock.mockReturnValue({ + stats: { + sessionId: 'test-session-id', + sessionStartTime: new Date(), + metrics, + lastPromptTokenCount: 0, + promptCount: 5, + }, + getPromptCount: () => 5, + startNewPrompt: vi.fn(), + }); + + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + 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).toMatchSnapshot(); + + vi.useRealTimers(); + }); }); }); diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 7290640ba6..5aaaeaef2c 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -19,7 +19,10 @@ import { USER_AGREEMENT_RATE_MEDIUM, } from '../utils/displayUtils.js'; import { computeSessionStats } from '../utils/computeStats.js'; -import type { RetrieveUserQuotaResponse } from '@google/gemini-cli-core'; +import { + type RetrieveUserQuotaResponse, + VALID_GEMINI_MODELS, +} from '@google/gemini-cli-core'; // A more flexible and powerful StatRow component interface StatRowProps { @@ -70,6 +73,50 @@ const Section: React.FC = ({ title, children }) => ( ); +// Logic for building the unified list of table rows +const buildModelRows = ( + models: Record, + quotas?: RetrieveUserQuotaResponse, +) => { + const getBaseModelName = (name: string) => name.replace('-001', ''); + const usedModelNames = new Set(Object.keys(models).map(getBaseModelName)); + + // 1. Models with active usage + const activeRows = Object.entries(models).map(([name, metrics]) => { + const modelName = getBaseModelName(name); + return { + key: name, + modelName, + requests: metrics.api.totalRequests, + inputTokens: metrics.tokens.prompt.toLocaleString(), + outputTokens: metrics.tokens.candidates.toLocaleString(), + bucket: quotas?.buckets?.find((b) => b.modelId === modelName), + isActive: true, + }; + }); + + // 2. Models with quota only + const quotaRows = + quotas?.buckets + ?.filter( + (b) => + b.modelId && + VALID_GEMINI_MODELS.has(b.modelId) && + !usedModelNames.has(b.modelId), + ) + .map((bucket) => ({ + key: bucket.modelId!, + modelName: bucket.modelId!, + requests: '-', + inputTokens: '-', + outputTokens: '-', + bucket, + isActive: false, + })) || []; + + return [...activeRows, ...quotaRows]; +}; + const formatResetTime = (resetTime: string): string => { const diff = new Date(resetTime).getTime() - Date.now(); if (diff <= 0) return ''; @@ -100,6 +147,12 @@ const ModelUsageTable: React.FC<{ cacheEfficiency: number; quotas?: RetrieveUserQuotaResponse; }> = ({ models, totalCachedTokens, cacheEfficiency, quotas }) => { + const rows = buildModelRows(models, quotas); + + if (rows.length === 0) { + return null; + } + const nameWidth = 25; const requestsWidth = 8; const inputTokensWidth = 15; @@ -138,6 +191,7 @@ const ModelUsageTable: React.FC<{ )} + {/* Divider */} - {/* Rows */} - {Object.entries(models).map(([name, modelMetrics]) => { - const modelName = name.replace('-001', ''); - const bucket = quotas?.buckets?.find((b) => b.modelId === modelName); - - return ( - - - {modelName} - - - - {modelMetrics.api.totalRequests} - - - - - {modelMetrics.tokens.prompt.toLocaleString()} - - - - - {modelMetrics.tokens.candidates.toLocaleString()} - - - - {bucket && - bucket.remainingFraction != null && - bucket.resetTime && ( - - {(bucket.remainingFraction * 100).toFixed(1)}%{' '} - {formatResetTime(bucket.resetTime)} - - )} - + {rows.map((row) => ( + + + {row.modelName} - ); - })} + + + {row.requests} + + + + + {row.inputTokens} + + + + + {row.outputTokens} + + + + {row.bucket && + row.bucket.remainingFraction != null && + row.bucket.resetTime && ( + + {(row.bucket.remainingFraction * 100).toFixed(1)}%{' '} + {formatResetTime(row.bucket.resetTime)} + + )} + + + ))} + {cacheEfficiency > 0 && ( @@ -202,6 +257,7 @@ const ModelUsageTable: React.FC<{ )} + {models && ( <> @@ -335,15 +391,12 @@ export const StatsDisplay: React.FC = ({ - - {Object.keys(models).length > 0 && ( - - )} + ); }; 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 ded84b667f..1e0b3597d3 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -151,6 +151,36 @@ exports[` > Conditional Rendering Tests > hides User Agreement w ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; +exports[` > Quota Display > renders quota information for unused models 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 Reqs Input Tokens Output Tokens Usage limit remaining │ +│ ───────────────────────────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-flash - - - 50.0% (Resets in 2h) │ +│ │ +│ Usage limits span all sessions and reset daily. │ +│ /auth to upgrade or switch to API key. │ +│ │ +│ │ +│ » Tip: For a full token breakdown, run \`/stats model\`. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + exports[` > Quota Display > renders quota information when quotas are provided 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index f3afad61bc..215019ef03 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -9,6 +9,13 @@ export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash'; export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite'; +export const VALID_GEMINI_MODELS = new Set([ + PREVIEW_GEMINI_MODEL, + DEFAULT_GEMINI_MODEL, + DEFAULT_GEMINI_FLASH_MODEL, + DEFAULT_GEMINI_FLASH_LITE_MODEL, +]); + export const DEFAULT_GEMINI_MODEL_AUTO = 'auto'; // Model aliases for user convenience.