From aa68a974824006713c762654eef9ebc0b4eba4d8 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 20 Feb 2026 21:54:13 +0000 Subject: [PATCH] feat(cli): improve quota display layout and fallback logic in /stats --- .../ui/components/ModelStatsDisplay.test.tsx | 35 +--- .../src/ui/components/ModelStatsDisplay.tsx | 13 ++ .../cli/src/ui/components/QuotaStatsInfo.tsx | 63 +++++--- .../cli/src/ui/components/StatsDisplay.tsx | 149 ++++++------------ .../__snapshots__/StatsDisplay.test.tsx.snap | 89 ++++++----- packages/cli/src/ui/utils/displayUtils.ts | 4 +- 6 files changed, 159 insertions(+), 194 deletions(-) diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx index d47c2cca96..9592806fce 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx @@ -4,12 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { ModelStatsDisplay } from './ModelStatsDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; -import * as SettingsContext from '../contexts/SettingsContext.js'; -import type { LoadedSettings } from '../../config/settings.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; import { ToolCallDecision, LlmRole } from '@google/gemini-cli-core'; @@ -22,16 +20,7 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => { }; }); -vi.mock('../contexts/SettingsContext.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useSettings: vi.fn(), - }; -}); - const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); -const useSettingsMock = vi.mocked(SettingsContext.useSettings); const renderWithMockedStats = async ( metrics: SessionMetrics, @@ -51,17 +40,9 @@ const renderWithMockedStats = async ( startNewPrompt: vi.fn(), }); - useSettingsMock.mockReturnValue({ - merged: { - ui: { - showUserIdentity: true, - }, - }, - } as unknown as LoadedSettings); - - const result = render( + const result = renderWithProviders( , - width, + { width }, ); await result.waitUntilReady(); return result; @@ -474,14 +455,6 @@ describe('', () => { }); it('should render user identity information when provided', async () => { - useSettingsMock.mockReturnValue({ - merged: { - ui: { - showUserIdentity: true, - }, - }, - } as unknown as LoadedSettings); - useSessionStatsMock.mockReturnValue({ stats: { sessionId: 'test-session', @@ -528,7 +501,7 @@ describe('', () => { startNewPrompt: vi.fn(), }); - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( = ({ @@ -51,8 +55,14 @@ export const ModelStatsDisplay: React.FC = ({ tier, currentModel, quotaStats, + quotas, }) => { const { stats } = useSessionStats(); + const config = useConfig(); + const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; const pooledRemaining = quotaStats?.remaining; const pooledLimit = quotaStats?.limit; @@ -343,6 +353,9 @@ export const ModelStatsDisplay: React.FC = ({ remaining={pooledRemaining} limit={pooledLimit} resetTime={pooledResetTime} + quotas={quotas} + useGemini3_1={useGemini3_1} + useCustomToolModel={useCustomToolModel} /> {(showUserIdentity || isAuto) && } diff --git a/packages/cli/src/ui/components/QuotaStatsInfo.tsx b/packages/cli/src/ui/components/QuotaStatsInfo.tsx index 905a9ab9d2..35877f2a73 100644 --- a/packages/cli/src/ui/components/QuotaStatsInfo.tsx +++ b/packages/cli/src/ui/components/QuotaStatsInfo.tsx @@ -14,11 +14,19 @@ import { QUOTA_THRESHOLD_MEDIUM, } from '../utils/displayUtils.js'; +import { + type RetrieveUserQuotaResponse, + isActiveModel, +} from '@google/gemini-cli-core'; + interface QuotaStatsInfoProps { remaining: number | undefined; limit: number | undefined; resetTime?: string; showDetails?: boolean; + quotas?: RetrieveUserQuotaResponse; + useGemini3_1?: boolean; + useCustomToolModel?: boolean; } export const QuotaStatsInfo: React.FC = ({ @@ -26,23 +34,42 @@ export const QuotaStatsInfo: React.FC = ({ limit, resetTime, showDetails = true, + quotas, + useGemini3_1 = false, + useCustomToolModel = false, }) => { - const hasData = - (remaining !== undefined && remaining !== null) || - (limit !== undefined && limit !== null && limit > 0); - - if (!hasData && !showDetails) { - return null; - } - - const percentage = + let displayPercentage = limit && limit > 0 && remaining !== undefined && remaining !== null ? (remaining / limit) * 100 : undefined; + let displayResetTime = resetTime; + + // Fallback to individual bucket if pooled data is missing + if (displayPercentage === undefined && quotas?.buckets) { + const activeBuckets = quotas.buckets.filter( + (b) => + b.modelId && + isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) && + b.remainingFraction !== undefined, + ); + if (activeBuckets.length > 0) { + // Use the most restrictive bucket as representative + const representative = activeBuckets.reduce((prev, curr) => + prev.remainingFraction! < curr.remainingFraction! ? prev : curr, + ); + displayPercentage = representative.remainingFraction! * 100; + displayResetTime = representative.resetTime; + } + } + + if (displayPercentage === undefined && !showDetails) { + return null; + } + const color = - percentage !== undefined - ? getStatusColor(percentage, { + displayPercentage !== undefined + ? getStatusColor(displayPercentage, { green: QUOTA_THRESHOLD_HIGH, yellow: QUOTA_THRESHOLD_MEDIUM, }) @@ -50,23 +77,21 @@ export const QuotaStatsInfo: React.FC = ({ return ( - {hasData && ( + {displayPercentage !== undefined && ( - {remaining === 0 + {displayPercentage === 0 ? `Limit reached` - : percentage !== undefined - ? `${percentage.toFixed(0)}%` - : 'Limit reached'} + : `${displayPercentage.toFixed(0)}%`} - {remaining !== 0 && usage remaining} - {resetTime && + {displayPercentage !== 0 && usage remaining} + {displayResetTime && `, ${(function (t) { const formatted = formatResetTime(t); return formatted === 'Resetting...' || formatted === '< 1m' ? formatted : `resets in ${formatted}`; - })(resetTime)}`} + })(displayResetTime)}`} )} {showDetails && ( diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index be4412087e..38dbb4bcbc 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -88,9 +88,7 @@ const buildModelRows = ( useCustomToolModel = false, ) => { const getBaseModelName = (name: string) => name.replace('-001', ''); - const usedModelNames = new Set( - Object.keys(models).map(getBaseModelName).map(getDisplayString), - ); + const usedModelNames = new Set(Object.keys(models).map(getBaseModelName)); // 1. Models with active usage const activeRows = Object.entries(models).map(([name, metrics]) => { @@ -116,7 +114,7 @@ const buildModelRows = ( (b) => b.modelId && isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) && - !usedModelNames.has(getDisplayString(b.modelId)), + !usedModelNames.has(b.modelId), ) .map((bucket) => ({ key: bucket.modelId!, @@ -164,34 +162,22 @@ const ModelUsageTable: React.FC<{ const showQuotaColumn = !!quotas; - const nameWidth = 22; - const requestsWidth = 7; - const uncachedWidth = 15; - const cachedWidth = 14; - const outputTokensWidth = 15; - const usageLimitWidth = showQuotaColumn ? 40 : 0; + const modelColumnWidth = 30; + const requestsColumnWidth = 8; + const tokenColumnWidth = 15; const cacheEfficiencyColor = getStatusColor(cacheEfficiency, { green: CACHE_EFFICIENCY_HIGH, yellow: CACHE_EFFICIENCY_MEDIUM, }); - const totalWidth = - nameWidth + - requestsWidth + - (showQuotaColumn - ? usageLimitWidth - : uncachedWidth + cachedWidth + outputTokensWidth); - return ( {/* Header */} - - - - {modelUsageTitle} - - + + + {modelUsageTitle} + @@ -200,6 +186,9 @@ const ModelUsageTable: React.FC<{ limit={pooledLimit} resetTime={pooledResetTime} showDetails={true} + quotas={quotas} + useGemini3_1={useGemini3_1} + useCustomToolModel={useCustomToolModel} /> {isAuto && ( @@ -208,18 +197,13 @@ const ModelUsageTable: React.FC<{ )} - - + + Model - + Reqs @@ -227,32 +211,17 @@ const ModelUsageTable: React.FC<{ {!showQuotaColumn && ( <> - + Input Tokens - + Cache Reads - + Output Tokens @@ -260,7 +229,7 @@ const ModelUsageTable: React.FC<{ )} {showQuotaColumn && ( - + Usage left @@ -270,19 +239,19 @@ const ModelUsageTable: React.FC<{ {/* Divider */} + marginBottom={0} + /> {rows.length > 0 ? ( rows.map((row) => ( - + - + @@ -304,12 +268,7 @@ const ModelUsageTable: React.FC<{ {!showQuotaColumn && ( <> - + - + {row.cachedTokens} - + )} - - {row.bucket && row.bucket.remainingFraction != null && ( - - {(row.bucket.remainingFraction * 100).toFixed(1)}% - {row.bucket.resetTime && ( - - {' '} - ( - {(function (t) { - const formatted = formatResetTime(t); - return formatted === 'Resetting...' || - formatted === '< 1m' - ? formatted - : `Resets in ${formatted}`; - })(row.bucket.resetTime)} - ) - - )} - - )} - + {showQuotaColumn && ( + + {row.bucket && row.bucket.remainingFraction != null && ( + + {(row.bucket.remainingFraction * 100).toFixed(1)}% + {row.bucket.resetTime && ( + + {' '} + ( + {(function (t) { + const formatted = formatResetTime(t); + return formatted === 'Resetting...' || + formatted === '< 1m' + ? formatted + : `Resets in ${formatted}`; + })(row.bucket.resetTime)} + ) + + )} + + )} + + )} )) ) : ( 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 9fecb0c4b6..714b058cbe 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -21,8 +21,8 @@ exports[` > Code Changes Display > displays Code Changes when li │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -49,8 +49,8 @@ exports[` > Code Changes Display > hides Code Changes when no li │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -77,8 +77,8 @@ exports[` > Conditional Color Tests > renders success rate in gr │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -105,8 +105,8 @@ exports[` > Conditional Color Tests > renders success rate in re │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -133,8 +133,8 @@ exports[` > Conditional Color Tests > renders success rate in ye │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -161,9 +161,9 @@ exports[` > Conditional Rendering Tests > hides Efficiency secti │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ 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 │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -189,8 +189,8 @@ exports[` > Conditional Rendering Tests > hides User Agreement w │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -214,12 +214,13 @@ exports[` > Quota Display > renders model table quota percentage │ » Tool Time: 0s (0.0%) │ │ │ │ Model Usage │ +│ 50% usage remaining │ │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Usage left │ -│ ───────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-flash - 50.0% │ +│ Model Reqs Usage left │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-flash - 50.0% │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -247,8 +248,8 @@ exports[` > Quota Display > renders pooled quota even when no mo │ /auth to upgrade or switch to API key. │ │ For a full token breakdown, run \`/stats model\`. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -277,10 +278,10 @@ exports[` > Quota Display > renders pooled quota information for │ /auth to upgrade or switch to API key. │ │ For a full token breakdown, run \`/stats model\`. │ │ │ -│ Model Reqs Usage left │ -│ ───────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro - 10.0% │ -│ gemini-2.5-flash - 70.0% │ +│ Model Reqs Usage left │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro - 10.0% │ +│ gemini-2.5-flash - 70.0% │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -303,12 +304,13 @@ exports[` > Quota Display > renders quota information for unused │ » Tool Time: 0s (0.0%) │ │ │ │ Model Usage │ +│ 50% usage remaining, resets in 2h │ │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Usage left │ -│ ───────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-flash - 50.0% (Resets in 2h) │ +│ Model Reqs Usage left │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-flash - 50.0% (Resets in 2h) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -331,12 +333,13 @@ exports[` > Quota Display > renders quota information when quota │ » Tool Time: 0s (0.0%) │ │ │ │ Model Usage │ +│ 75% usage remaining, resets in 1h 30m │ │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Usage left │ -│ ───────────────────────────────────────────────────────────────────── │ -│ 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) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -362,8 +365,8 @@ exports[` > Title Rendering > renders the custom title when a ti │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -390,8 +393,8 @@ exports[` > Title Rendering > renders the default title when no │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -418,10 +421,10 @@ exports[` > renders a table with two models correctly 1`] = ` │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ 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. │ │ │ @@ -450,9 +453,9 @@ exports[` > renders all sections when all data is present 1`] = │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ 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. │ │ │ @@ -480,8 +483,8 @@ exports[` > renders only the Performance section in its zero sta │ Usage limits span all sessions and reset daily. │ │ /auth to upgrade or switch to API key. │ │ │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ───────────────────────────────────────────────────────────────────────── │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ No model usage recorded. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/packages/cli/src/ui/utils/displayUtils.ts b/packages/cli/src/ui/utils/displayUtils.ts index e311aa4974..6fb6faec83 100644 --- a/packages/cli/src/ui/utils/displayUtils.ts +++ b/packages/cli/src/ui/utils/displayUtils.ts @@ -16,8 +16,8 @@ export const USER_AGREEMENT_RATE_MEDIUM = 45; export const CACHE_EFFICIENCY_HIGH = 40; export const CACHE_EFFICIENCY_MEDIUM = 15; -export const QUOTA_THRESHOLD_HIGH = 20; -export const QUOTA_THRESHOLD_MEDIUM = 5; +export const QUOTA_THRESHOLD_HIGH = 50; +export const QUOTA_THRESHOLD_MEDIUM = 20; // --- Color Logic --- export const getStatusColor = (