Skip to content
Open
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
35 changes: 4 additions & 31 deletions packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -22,16 +20,7 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
};
});

vi.mock('../contexts/SettingsContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SettingsContext>();
return {
...actual,
useSettings: vi.fn(),
};
});

const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const useSettingsMock = vi.mocked(SettingsContext.useSettings);

const renderWithMockedStats = async (
metrics: SessionMetrics,
Expand All @@ -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(
<ModelStatsDisplay currentModel={currentModel} />,
width,
{ width },
);
await result.waitUntilReady();
return result;
Expand Down Expand Up @@ -474,14 +455,6 @@ describe('<ModelStatsDisplay />', () => {
});

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',
Expand Down Expand Up @@ -528,7 +501,7 @@ describe('<ModelStatsDisplay />', () => {
startNewPrompt: vi.fn(),
});

const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ModelStatsDisplay
selectedAuthType="oauth"
userEmail="test@example.com"
Expand Down
51 changes: 25 additions & 26 deletions packages/cli/src/ui/components/ModelStatsDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ import {
getDisplayString,
isAutoModel,
LlmRole,
AuthType,
type RetrieveUserQuotaResponse,
} from '@google/gemini-cli-core';
import type { QuotaStats } from '../types.js';
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
import { useConfig } from '../contexts/ConfigContext.js';

interface StatRowData {
metric: string;
Expand All @@ -43,6 +46,7 @@ interface ModelStatsDisplayProps {
tier?: string;
currentModel?: string;
quotaStats?: QuotaStats;
quotas?: RetrieveUserQuotaResponse;
}

export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
Expand All @@ -51,8 +55,14 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
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;
Expand All @@ -65,21 +75,6 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
([, metrics]) => metrics.api.totalRequests > 0,
);

if (activeModels.length === 0) {
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingTop={1}
paddingX={2}
>
<Text color={theme.text.primary}>
No API calls have been made in this session.
</Text>
</Box>
);
}

const modelNames = activeModels.map(([name]) => name);

const hasThoughts = activeModels.some(
Expand Down Expand Up @@ -354,19 +349,23 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
<Text color={theme.text.primary}>{tier}</Text>
</Box>
)}
{isAuto &&
pooledRemaining !== undefined &&
pooledLimit !== undefined &&
pooledLimit > 0 && (
<QuotaStatsInfo
remaining={pooledRemaining}
limit={pooledLimit}
resetTime={pooledResetTime}
/>
)}
<QuotaStatsInfo
remaining={pooledRemaining}
limit={pooledLimit}
resetTime={pooledResetTime}
quotas={quotas}
useGemini3_1={useGemini3_1}
useCustomToolModel={useCustomToolModel}
/>
{(showUserIdentity || isAuto) && <Box height={1} />}

<Table data={rows} columns={columns} />
{activeModels.length === 0 ? (
<Text color={theme.text.primary}>
No API calls have been made in this session.
</Text>
) : (
<Table data={rows} columns={columns} />
)}
</Box>
);
};
11 changes: 10 additions & 1 deletion packages/cli/src/ui/components/QuotaDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,16 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
});

const resetInfo =
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
!terse && resetTime
? (function (t) {
const formatted = formatResetTime(t);
const info =
formatted === 'Resetting...' || formatted === '< 1m'
? formatted
: `resets in ${formatted}`;
return `, ${info}`;
})(resetTime)
: '';

if (remaining === 0) {
return (
Expand Down
76 changes: 57 additions & 19 deletions packages/cli/src/ui/components/QuotaStatsInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,50 +14,88 @@ 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<QuotaStatsInfoProps> = ({
remaining,
limit,
resetTime,
showDetails = true,
quotas,
useGemini3_1 = false,
useCustomToolModel = false,
}) => {
if (remaining === undefined || limit === undefined || limit === 0) {
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 percentage = (remaining / limit) * 100;
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
});
const color =
displayPercentage !== undefined
? getStatusColor(displayPercentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
})
: theme.text.primary;

return (
<Box flexDirection="column" marginTop={0} marginBottom={0}>
<Text color={color}>
{remaining === 0
? `Limit reached`
: `${percentage.toFixed(0)}% usage remaining`}
{resetTime && `, ${formatResetTime(resetTime)}`}
</Text>
{displayPercentage !== undefined && (
<Text color={color}>
<Text bold>
{displayPercentage === 0
? `Limit reached`
: `${displayPercentage.toFixed(0)}%`}
</Text>
{displayPercentage !== 0 && <Text> usage remaining</Text>}
{displayResetTime && `, ${formatResetTime(displayResetTime)}`}
</Text>
)}
{showDetails && (
<>
<Text color={theme.text.primary}>
Usage limit: {limit.toLocaleString()}
Usage limits span all sessions and reset daily.
</Text>
<Text color={theme.text.primary}>
Usage limits span all sessions and reset daily.
/auth to upgrade or switch to API key.
</Text>
{remaining === 0 && (
<Text color={theme.text.primary}>
Please /auth to upgrade or switch to an API key to continue.
</Text>
)}
</>
)}
</Box>
Expand Down
Loading
Loading