mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 07:30:52 -07:00
Feature/quota visibility 16795 (#18203)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,10 @@ vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
})),
|
||||
}));
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { StreamingState, ToolCallStatus } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./LoadingIndicator.js', () => ({
|
||||
@@ -145,6 +148,12 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
activeHooks: [],
|
||||
isBackgroundShellVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
@@ -155,31 +164,30 @@ const createMockUIActions = (): UIActions =>
|
||||
setShellModeActive: vi.fn(),
|
||||
onEscapePromptChange: vi.fn(),
|
||||
vimHandleInput: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
}) as Partial<UIActions> as UIActions;
|
||||
|
||||
const createMockConfig = (overrides = {}) => ({
|
||||
getModel: vi.fn(() => 'gemini-1.5-pro'),
|
||||
getTargetDir: vi.fn(() => '/test/dir'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getAccessibility: vi.fn(() => ({})),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
getToolRegistry: () => ({
|
||||
getTool: vi.fn(),
|
||||
}),
|
||||
getSkillManager: () => ({
|
||||
getSkills: () => [],
|
||||
getDisplayableSkills: () => [],
|
||||
}),
|
||||
getMcpClientManager: () => ({
|
||||
getMcpServers: () => ({}),
|
||||
getBlockedMcpServers: () => [],
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
const createMockConfig = (overrides = {}): Config =>
|
||||
({
|
||||
getModel: vi.fn(() => 'gemini-1.5-pro'),
|
||||
getTargetDir: vi.fn(() => '/test/dir'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getAccessibility: vi.fn(() => ({})),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
getToolRegistry: () => ({
|
||||
getTool: vi.fn(),
|
||||
}),
|
||||
getSkillManager: () => ({
|
||||
getSkills: () => [],
|
||||
getDisplayableSkills: () => [],
|
||||
}),
|
||||
getMcpClientManager: () => ({
|
||||
getMcpServers: () => ({}),
|
||||
getBlockedMcpServers: () => [],
|
||||
}),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderComposer = (
|
||||
uiState: UIState,
|
||||
settings = createMockSettings(),
|
||||
@@ -187,8 +195,8 @@ const renderComposer = (
|
||||
uiActions = createMockUIActions(),
|
||||
) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={config as any}>
|
||||
<SettingsContext.Provider value={settings as any}>
|
||||
<ConfigContext.Provider value={config as unknown as Config}>
|
||||
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
@@ -197,7 +205,6 @@ const renderComposer = (
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
describe('Composer', () => {
|
||||
describe('Footer Display Settings', () => {
|
||||
@@ -229,8 +236,11 @@ describe('Composer', () => {
|
||||
sessionStats: {
|
||||
sessionId: 'test-session',
|
||||
sessionStartTime: new Date(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metrics: {} as any,
|
||||
metrics: {
|
||||
models: {},
|
||||
tools: {},
|
||||
files: {},
|
||||
} as SessionMetrics,
|
||||
lastPromptTokenCount: 150,
|
||||
promptCount: 5,
|
||||
},
|
||||
@@ -251,8 +261,9 @@ describe('Composer', () => {
|
||||
vi.mocked(useVimMode).mockReturnValueOnce({
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
toggleVimEnabled: vi.fn(),
|
||||
setVimMode: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useVimMode>);
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings, config);
|
||||
|
||||
@@ -541,9 +552,12 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
showErrorDetails: true,
|
||||
filteredConsoleMessages: [
|
||||
{ level: 'error', message: 'Test error', timestamp: new Date() },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any,
|
||||
{
|
||||
type: 'error',
|
||||
content: 'Test error',
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -59,8 +59,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
Boolean(uiState.authConsentRequest) ||
|
||||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
|
||||
Boolean(uiState.loopDetectionConfirmationRequest) ||
|
||||
Boolean(uiState.proQuotaRequest) ||
|
||||
Boolean(uiState.validationRequest) ||
|
||||
Boolean(uiState.quota.proQuotaRequest) ||
|
||||
Boolean(uiState.quota.validationRequest) ||
|
||||
Boolean(uiState.customDialog);
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingTop={1}
|
||||
paddingX={2}
|
||||
>
|
||||
{typeof prompt === 'string' ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -75,7 +75,12 @@ describe('DialogManager', () => {
|
||||
terminalWidth: 80,
|
||||
confirmUpdateExtensionRequests: [],
|
||||
showIdeRestartPrompt: false,
|
||||
proQuotaRequest: null,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
shouldShowIdePrompt: false,
|
||||
isFolderTrustDialogOpen: false,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
@@ -99,8 +104,7 @@ describe('DialogManager', () => {
|
||||
it('renders nothing by default', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DialogManager {...defaultProps} />,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ uiState: baseUiState as any },
|
||||
{ uiState: baseUiState as Partial<UIState> as UIState },
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
@@ -115,12 +119,17 @@ describe('DialogManager', () => {
|
||||
],
|
||||
[
|
||||
{
|
||||
proQuotaRequest: {
|
||||
failedModel: 'a',
|
||||
fallbackModel: 'b',
|
||||
message: 'c',
|
||||
isTerminalQuotaError: false,
|
||||
resolve: vi.fn(),
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: {
|
||||
failedModel: 'a',
|
||||
fallbackModel: 'b',
|
||||
message: 'c',
|
||||
isTerminalQuotaError: false,
|
||||
resolve: vi.fn(),
|
||||
},
|
||||
validationRequest: null,
|
||||
},
|
||||
},
|
||||
'ProQuotaDialog',
|
||||
@@ -185,8 +194,10 @@ describe('DialogManager', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DialogManager {...defaultProps} />,
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uiState: { ...baseUiState, ...uiStateOverride } as any,
|
||||
uiState: {
|
||||
...baseUiState,
|
||||
...uiStateOverride,
|
||||
} as Partial<UIState> as UIState,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toContain(expectedComponent);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -71,24 +71,30 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.proQuotaRequest) {
|
||||
if (uiState.quota.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
failedModel={uiState.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.proQuotaRequest.fallbackModel}
|
||||
message={uiState.proQuotaRequest.message}
|
||||
isTerminalQuotaError={uiState.proQuotaRequest.isTerminalQuotaError}
|
||||
isModelNotFoundError={!!uiState.proQuotaRequest.isModelNotFoundError}
|
||||
failedModel={uiState.quota.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.quota.proQuotaRequest.fallbackModel}
|
||||
message={uiState.quota.proQuotaRequest.message}
|
||||
isTerminalQuotaError={
|
||||
uiState.quota.proQuotaRequest.isTerminalQuotaError
|
||||
}
|
||||
isModelNotFoundError={
|
||||
!!uiState.quota.proQuotaRequest.isModelNotFoundError
|
||||
}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.validationRequest) {
|
||||
if (uiState.quota.validationRequest) {
|
||||
return (
|
||||
<ValidationDialog
|
||||
validationLink={uiState.validationRequest.validationLink}
|
||||
validationDescription={uiState.validationRequest.validationDescription}
|
||||
learnMoreUrl={uiState.validationRequest.learnMoreUrl}
|
||||
validationLink={uiState.quota.validationRequest.validationLink}
|
||||
validationDescription={
|
||||
uiState.quota.validationRequest.validationDescription
|
||||
}
|
||||
learnMoreUrl={uiState.quota.validationRequest.learnMoreUrl}
|
||||
onChoice={uiActions.handleValidationChoice}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { Footer } from './Footer.js';
|
||||
@@ -131,6 +131,69 @@ describe('<Footer />', () => {
|
||||
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
|
||||
});
|
||||
|
||||
it('displays the usage indicator when usage is low', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: {
|
||||
remaining: 15,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(lastFrame()).toContain('15%');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides the usage indicator when usage is not near limit', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: {
|
||||
remaining: 85,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(lastFrame()).not.toContain('Usage remaining');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('displays "Limit reached" message when remaining is 0', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: {
|
||||
remaining: 0,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(lastFrame()).toContain('Limit reached');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('displays the model name and abbreviated context percentage', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 99,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,7 @@ import process from 'node:process';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { QuotaDisplay } from './QuotaDisplay.js';
|
||||
import { DebugProfiler } from './DebugProfiler.js';
|
||||
import { isDevelopment } from '../../utils/installationInfo.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
@@ -43,6 +44,7 @@ export const Footer: React.FC = () => {
|
||||
nightly,
|
||||
isTrustedFolder,
|
||||
terminalWidth,
|
||||
quotaStats,
|
||||
} = {
|
||||
model: uiState.currentModel,
|
||||
targetDir: config.getTargetDir(),
|
||||
@@ -56,6 +58,7 @@ export const Footer: React.FC = () => {
|
||||
nightly: uiState.nightly,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
terminalWidth: uiState.terminalWidth,
|
||||
quotaStats: uiState.quota.stats,
|
||||
};
|
||||
|
||||
const showMemoryUsage =
|
||||
@@ -159,6 +162,17 @@ export const Footer: React.FC = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{quotaStats && (
|
||||
<>
|
||||
{' '}
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -125,6 +125,18 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
selectedAuthType={itemForDisplay.selectedAuthType}
|
||||
userEmail={itemForDisplay.userEmail}
|
||||
tier={itemForDisplay.tier}
|
||||
currentModel={itemForDisplay.currentModel}
|
||||
quotaStats={
|
||||
itemForDisplay.pooledRemaining !== undefined ||
|
||||
itemForDisplay.pooledLimit !== undefined ||
|
||||
itemForDisplay.pooledResetTime !== undefined
|
||||
? {
|
||||
remaining: itemForDisplay.pooledRemaining,
|
||||
limit: itemForDisplay.pooledLimit,
|
||||
resetTime: itemForDisplay.pooledResetTime,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && (
|
||||
@@ -132,6 +144,18 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
selectedAuthType={itemForDisplay.selectedAuthType}
|
||||
userEmail={itemForDisplay.userEmail}
|
||||
tier={itemForDisplay.tier}
|
||||
currentModel={itemForDisplay.currentModel}
|
||||
quotaStats={
|
||||
itemForDisplay.pooledRemaining !== undefined ||
|
||||
itemForDisplay.pooledLimit !== undefined ||
|
||||
itemForDisplay.pooledResetTime !== undefined
|
||||
? {
|
||||
remaining: itemForDisplay.pooledRemaining,
|
||||
limit: itemForDisplay.pooledLimit,
|
||||
resetTime: itemForDisplay.pooledResetTime,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -33,7 +33,11 @@ vi.mock('../contexts/SettingsContext.js', async (importOriginal) => {
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
const useSettingsMock = vi.mocked(SettingsContext.useSettings);
|
||||
|
||||
const renderWithMockedStats = (metrics: SessionMetrics, width?: number) => {
|
||||
const renderWithMockedStats = (
|
||||
metrics: SessionMetrics,
|
||||
width?: number,
|
||||
currentModel: string = 'gemini-2.5-pro',
|
||||
) => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionId: 'test-session',
|
||||
@@ -55,7 +59,7 @@ const renderWithMockedStats = (metrics: SessionMetrics, width?: number) => {
|
||||
},
|
||||
} as unknown as LoadedSettings);
|
||||
|
||||
return render(<ModelStatsDisplay />, width);
|
||||
return render(<ModelStatsDisplay currentModel={currentModel} />, width);
|
||||
};
|
||||
|
||||
describe('<ModelStatsDisplay />', () => {
|
||||
@@ -380,6 +384,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
},
|
||||
},
|
||||
80,
|
||||
'auto-gemini-3',
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { Table, type Column } from './Table.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { getDisplayString, isAutoModel } from '@google/gemini-cli-core';
|
||||
import type { QuotaStats } from '../types.js';
|
||||
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
|
||||
|
||||
interface StatRowData {
|
||||
metric: string;
|
||||
@@ -29,14 +32,23 @@ interface ModelStatsDisplayProps {
|
||||
selectedAuthType?: string;
|
||||
userEmail?: string;
|
||||
tier?: string;
|
||||
currentModel?: string;
|
||||
quotaStats?: QuotaStats;
|
||||
}
|
||||
|
||||
export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
selectedAuthType,
|
||||
userEmail,
|
||||
tier,
|
||||
currentModel,
|
||||
quotaStats,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
|
||||
const pooledRemaining = quotaStats?.remaining;
|
||||
const pooledLimit = quotaStats?.limit;
|
||||
const pooledResetTime = quotaStats?.resetTime;
|
||||
|
||||
const { models } = stats.metrics;
|
||||
const settings = useSettings();
|
||||
const showUserIdentity = settings.merged.ui.showUserIdentity;
|
||||
@@ -49,7 +61,7 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingY={1}
|
||||
paddingTop={1}
|
||||
paddingX={2}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
@@ -223,16 +235,21 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
})),
|
||||
];
|
||||
|
||||
const isAuto = currentModel && isAutoModel(currentModel);
|
||||
const statsTitle = isAuto
|
||||
? `${getDisplayString(currentModel)} Stats For Nerds`
|
||||
: 'Model Stats For Nerds';
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingTop={1}
|
||||
paddingX={2}
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Model Stats For Nerds
|
||||
{statsTitle}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
|
||||
@@ -258,7 +275,17 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
<Text color={theme.text.primary}>{tier}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showUserIdentity && (selectedAuthType || tier) && <Box height={1} />}
|
||||
{isAuto &&
|
||||
pooledRemaining !== undefined &&
|
||||
pooledLimit !== undefined &&
|
||||
pooledLimit > 0 && (
|
||||
<QuotaStatsInfo
|
||||
remaining={pooledRemaining}
|
||||
limit={pooledLimit}
|
||||
resetTime={pooledResetTime}
|
||||
/>
|
||||
)}
|
||||
{(showUserIdentity || isAuto) && <Box height={1} />}
|
||||
|
||||
<Table data={rows} columns={columns} />
|
||||
</Box>
|
||||
|
||||
73
packages/cli/src/ui/components/QuotaDisplay.test.tsx
Normal file
73
packages/cli/src/ui/components/QuotaDisplay.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { QuotaDisplay } from './QuotaDisplay.js';
|
||||
|
||||
describe('QuotaDisplay', () => {
|
||||
it('should not render when remaining is undefined', () => {
|
||||
const { lastFrame } = render(
|
||||
<QuotaDisplay remaining={undefined} limit={100} />,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('should not render when limit is undefined', () => {
|
||||
const { lastFrame } = render(
|
||||
<QuotaDisplay remaining={100} limit={undefined} />,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('should not render when limit is 0', () => {
|
||||
const { lastFrame } = render(<QuotaDisplay remaining={100} limit={0} />);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('should not render when usage > 20%', () => {
|
||||
const { lastFrame } = render(<QuotaDisplay remaining={85} limit={100} />);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('should render yellow when usage < 20%', () => {
|
||||
const { lastFrame } = render(<QuotaDisplay remaining={15} limit={100} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render red when usage < 5%', () => {
|
||||
const { lastFrame } = render(<QuotaDisplay remaining={4} limit={100} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with reset time when provided', () => {
|
||||
const resetTime = new Date(Date.now() + 3600000).toISOString(); // 1 hour from now
|
||||
const { lastFrame } = render(
|
||||
<QuotaDisplay remaining={15} limit={100} resetTime={resetTime} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should NOT render reset time when terse is true', () => {
|
||||
const resetTime = new Date(Date.now() + 3600000).toISOString();
|
||||
const { lastFrame } = render(
|
||||
<QuotaDisplay
|
||||
remaining={15}
|
||||
limit={100}
|
||||
resetTime={resetTime}
|
||||
terse={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render terse limit reached message', () => {
|
||||
const { lastFrame } = render(
|
||||
<QuotaDisplay remaining={0} limit={100} terse={true} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
64
packages/cli/src/ui/components/QuotaDisplay.tsx
Normal file
64
packages/cli/src/ui/components/QuotaDisplay.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import {
|
||||
getStatusColor,
|
||||
QUOTA_THRESHOLD_HIGH,
|
||||
QUOTA_THRESHOLD_MEDIUM,
|
||||
} from '../utils/displayUtils.js';
|
||||
import { formatResetTime } from '../utils/formatters.js';
|
||||
|
||||
interface QuotaDisplayProps {
|
||||
remaining: number | undefined;
|
||||
limit: number | undefined;
|
||||
resetTime?: string;
|
||||
terse?: boolean;
|
||||
}
|
||||
|
||||
export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
remaining,
|
||||
limit,
|
||||
resetTime,
|
||||
terse = false,
|
||||
}) => {
|
||||
if (remaining === undefined || limit === undefined || limit === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = (remaining / limit) * 100;
|
||||
|
||||
if (percentage > QUOTA_THRESHOLD_HIGH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const color = getStatusColor(percentage, {
|
||||
green: QUOTA_THRESHOLD_HIGH,
|
||||
yellow: QUOTA_THRESHOLD_MEDIUM,
|
||||
});
|
||||
|
||||
const resetInfo =
|
||||
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
|
||||
|
||||
if (remaining === 0) {
|
||||
return (
|
||||
<Text color={color}>
|
||||
{terse
|
||||
? 'Limit reached'
|
||||
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{terse
|
||||
? `${percentage.toFixed(0)}%`
|
||||
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
65
packages/cli/src/ui/components/QuotaStatsInfo.tsx
Normal file
65
packages/cli/src/ui/components/QuotaStatsInfo.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatResetTime } from '../utils/formatters.js';
|
||||
import {
|
||||
getStatusColor,
|
||||
QUOTA_THRESHOLD_HIGH,
|
||||
QUOTA_THRESHOLD_MEDIUM,
|
||||
} from '../utils/displayUtils.js';
|
||||
|
||||
interface QuotaStatsInfoProps {
|
||||
remaining: number | undefined;
|
||||
limit: number | undefined;
|
||||
resetTime?: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
|
||||
remaining,
|
||||
limit,
|
||||
resetTime,
|
||||
showDetails = true,
|
||||
}) => {
|
||||
if (remaining === undefined || limit === undefined || limit === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = (remaining / limit) * 100;
|
||||
const color = getStatusColor(percentage, {
|
||||
green: QUOTA_THRESHOLD_HIGH,
|
||||
yellow: QUOTA_THRESHOLD_MEDIUM,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={0} marginBottom={0}>
|
||||
<Text color={color}>
|
||||
{remaining === 0
|
||||
? `Limit reached`
|
||||
: `${percentage.toFixed(0)}% usage remaining`}
|
||||
{resetTime && `, ${formatResetTime(resetTime)}`}
|
||||
</Text>
|
||||
{showDetails && (
|
||||
<>
|
||||
<Text color={theme.text.primary}>
|
||||
Usage limit: {limit.toLocaleString()}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
Usage limits span all sessions and reset daily.
|
||||
</Text>
|
||||
{remaining === 0 && (
|
||||
<Text color={theme.text.primary}>
|
||||
Please /auth to upgrade or switch to an API key to continue.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -421,6 +421,7 @@ describe('<StatsDisplay />', () => {
|
||||
buckets: [
|
||||
{
|
||||
modelId: 'gemini-2.5-pro',
|
||||
remainingAmount: '75',
|
||||
remainingFraction: 0.75,
|
||||
resetTime,
|
||||
},
|
||||
@@ -446,9 +447,64 @@ describe('<StatsDisplay />', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Usage left');
|
||||
expect(output).toContain('Usage remaining');
|
||||
expect(output).toContain('75.0%');
|
||||
expect(output).toContain('(Resets in 1h 30m)');
|
||||
expect(output).toContain('resets in 1h 30m');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders pooled quota information for auto mode', () => {
|
||||
const now = new Date('2025-01-01T12:00:00Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const metrics = createTestMetrics();
|
||||
const quotas: RetrieveUserQuotaResponse = {
|
||||
buckets: [
|
||||
{
|
||||
modelId: 'gemini-2.5-pro',
|
||||
remainingAmount: '10',
|
||||
remainingFraction: 0.1, // limit = 100
|
||||
},
|
||||
{
|
||||
modelId: 'gemini-2.5-flash',
|
||||
remainingAmount: '700',
|
||||
remainingFraction: 0.7, // limit = 1000
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionId: 'test-session-id',
|
||||
sessionStartTime: new Date(),
|
||||
metrics,
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 5,
|
||||
},
|
||||
getPromptCount: () => 5,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<StatsDisplay
|
||||
duration="1s"
|
||||
quotas={quotas}
|
||||
currentModel="auto"
|
||||
quotaStats={{
|
||||
remaining: 710,
|
||||
limit: 1100,
|
||||
}}
|
||||
/>,
|
||||
{ width: 100 },
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// (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();
|
||||
@@ -468,6 +524,7 @@ describe('<StatsDisplay />', () => {
|
||||
buckets: [
|
||||
{
|
||||
modelId: 'gemini-2.5-flash',
|
||||
remainingAmount: '50',
|
||||
remainingFraction: 0.5,
|
||||
resetTime,
|
||||
},
|
||||
@@ -495,7 +552,7 @@ describe('<StatsDisplay />', () => {
|
||||
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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { formatDuration, formatResetTime } from '../utils/formatters.js';
|
||||
import type { ModelMetrics } from '../contexts/SessionContext.js';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import {
|
||||
@@ -24,8 +24,12 @@ import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import {
|
||||
type RetrieveUserQuotaResponse,
|
||||
VALID_GEMINI_MODELS,
|
||||
getDisplayString,
|
||||
isAutoModel,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import type { QuotaStats } from '../types.js';
|
||||
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
|
||||
|
||||
// A more flexible and powerful StatRow component
|
||||
interface StatRowProps {
|
||||
@@ -122,36 +126,25 @@ const buildModelRows = (
|
||||
return [...activeRows, ...quotaRows];
|
||||
};
|
||||
|
||||
const formatResetTime = (resetTime: string): string => {
|
||||
const diff = new Date(resetTime).getTime() - Date.now();
|
||||
if (diff <= 0) return '';
|
||||
|
||||
const totalMinutes = Math.ceil(diff / (1000 * 60));
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
const fmt = (val: number, unit: 'hour' | 'minute') =>
|
||||
new Intl.NumberFormat('en', {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: 'narrow',
|
||||
}).format(val);
|
||||
|
||||
if (hours > 0 && minutes > 0) {
|
||||
return `(Resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')})`;
|
||||
} else if (hours > 0) {
|
||||
return `(Resets in ${fmt(hours, 'hour')})`;
|
||||
}
|
||||
|
||||
return `(Resets in ${fmt(minutes, 'minute')})`;
|
||||
};
|
||||
|
||||
const ModelUsageTable: React.FC<{
|
||||
models: Record<string, ModelMetrics>;
|
||||
quotas?: RetrieveUserQuotaResponse;
|
||||
cacheEfficiency: number;
|
||||
totalCachedTokens: number;
|
||||
}> = ({ models, quotas, cacheEfficiency, totalCachedTokens }) => {
|
||||
currentModel?: string;
|
||||
pooledRemaining?: number;
|
||||
pooledLimit?: number;
|
||||
pooledResetTime?: string;
|
||||
}> = ({
|
||||
models,
|
||||
quotas,
|
||||
cacheEfficiency,
|
||||
totalCachedTokens,
|
||||
currentModel,
|
||||
pooledRemaining,
|
||||
pooledLimit,
|
||||
pooledResetTime,
|
||||
}) => {
|
||||
const rows = buildModelRows(models, quotas);
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -179,13 +172,43 @@ const ModelUsageTable: React.FC<{
|
||||
? usageLimitWidth
|
||||
: uncachedWidth + cachedWidth + outputTokensWidth);
|
||||
|
||||
const isAuto = currentModel && isAutoModel(currentModel);
|
||||
const modelUsageTitle = isAuto
|
||||
? `${getDisplayString(currentModel)} Usage`
|
||||
: `Model Usage`;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{/* Header */}
|
||||
<Box alignItems="flex-end">
|
||||
<Box width={nameWidth}>
|
||||
<Text bold color={theme.text.primary} wrap="truncate-end">
|
||||
Model Usage
|
||||
{modelUsageTitle}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isAuto &&
|
||||
showQuotaColumn &&
|
||||
pooledRemaining !== undefined &&
|
||||
pooledLimit !== undefined &&
|
||||
pooledLimit > 0 && (
|
||||
<Box flexDirection="column" marginTop={0} marginBottom={1}>
|
||||
<QuotaStatsInfo
|
||||
remaining={pooledRemaining}
|
||||
limit={pooledLimit}
|
||||
resetTime={pooledResetTime}
|
||||
/>
|
||||
<Text color={theme.text.primary}>
|
||||
For a full token breakdown, run `/stats model`.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box alignItems="flex-end">
|
||||
<Box width={nameWidth}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Model
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -198,6 +221,7 @@ const ModelUsageTable: React.FC<{
|
||||
Reqs
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{!showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
@@ -239,7 +263,7 @@ const ModelUsageTable: React.FC<{
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Usage left
|
||||
Usage remaining
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
@@ -259,7 +283,10 @@ const ModelUsageTable: React.FC<{
|
||||
{rows.map((row) => (
|
||||
<Box key={row.key}>
|
||||
<Box width={nameWidth}>
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
<Text
|
||||
color={row.isActive ? theme.text.primary : theme.text.secondary}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{row.modelName}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -344,19 +371,6 @@ const ModelUsageTable: React.FC<{
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showQuotaColumn && (
|
||||
<>
|
||||
<Box marginTop={1} marginBottom={2}>
|
||||
<Text color={theme.text.primary}>
|
||||
{`Usage limits span all sessions and reset daily.\n/auth to upgrade or switch to API key.`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
» Tip: For a full token breakdown, run `/stats model`.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -368,6 +382,8 @@ interface StatsDisplayProps {
|
||||
selectedAuthType?: string;
|
||||
userEmail?: string;
|
||||
tier?: string;
|
||||
currentModel?: string;
|
||||
quotaStats?: QuotaStats;
|
||||
}
|
||||
|
||||
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
@@ -377,12 +393,19 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
selectedAuthType,
|
||||
userEmail,
|
||||
tier,
|
||||
currentModel,
|
||||
quotaStats,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { metrics } = stats;
|
||||
const { models, tools, files } = metrics;
|
||||
const computed = computeSessionStats(metrics);
|
||||
const settings = useSettings();
|
||||
|
||||
const pooledRemaining = quotaStats?.remaining;
|
||||
const pooledLimit = quotaStats?.limit;
|
||||
const pooledResetTime = quotaStats?.resetTime;
|
||||
|
||||
const showUserIdentity = settings.merged.ui.showUserIdentity;
|
||||
|
||||
const successThresholds = {
|
||||
@@ -415,7 +438,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingTop={1}
|
||||
paddingX={2}
|
||||
overflow="hidden"
|
||||
>
|
||||
@@ -508,6 +531,10 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
quotas={quotas}
|
||||
cacheEfficiency={computed.cacheEfficiency}
|
||||
totalCachedTokens={computed.totalCachedTokens}
|
||||
currentModel={currentModel}
|
||||
pooledRemaining={pooledRemaining}
|
||||
pooledLimit={pooledLimit}
|
||||
pooledResetTime={pooledResetTime}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,8 @@ import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { TransientMessageType } from '../../utils/events.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
|
||||
@@ -68,7 +70,6 @@ const createMockConfig = (overrides = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderStatusDisplay = (
|
||||
props: { hideContextSummary: boolean } = { hideContextSummary: false },
|
||||
uiState: UIState = createMockUIState(),
|
||||
@@ -76,15 +77,14 @@ const renderStatusDisplay = (
|
||||
config = createMockConfig(),
|
||||
) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={config as any}>
|
||||
<SettingsContext.Provider value={settings as any}>
|
||||
<ConfigContext.Provider value={config as unknown as Config}>
|
||||
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<StatusDisplay {...props} />
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
describe('StatusDisplay', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -64,7 +64,7 @@ export const ToolStatsDisplay: React.FC = () => {
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingY={1}
|
||||
paddingTop={1}
|
||||
paddingX={2}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
@@ -98,7 +98,7 @@ export const ToolStatsDisplay: React.FC = () => {
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingTop={1}
|
||||
paddingX={2}
|
||||
width={70}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model Limit reached"`;
|
||||
|
||||
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model 15%"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro /model (100%)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model (100% context left)"`;
|
||||
@@ -9,3 +13,5 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...directories/to/make/it/long no sandbox (see /docs)"`;
|
||||
|
||||
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model"`;
|
||||
|
||||
@@ -5,6 +5,7 @@ exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
@@ -18,7 +19,6 @@ exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
|
||||
│ ↳ Thoughts 2 │
|
||||
│ ↳ Tool 1 │
|
||||
│ ↳ Output 20 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -27,6 +27,7 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro gemini-2.5-flash │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
@@ -40,7 +41,6 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
|
||||
│ ↳ Thoughts 2 0 │
|
||||
│ ↳ Tool 0 3 │
|
||||
│ ↳ Output 20 10 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -49,6 +49,7 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro gemini-2.5-flash │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
@@ -62,7 +63,6 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
|
||||
│ ↳ Thoughts 10 20 │
|
||||
│ ↳ Tool 5 10 │
|
||||
│ ↳ Output 200 400 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -71,6 +71,7 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
@@ -84,14 +85,14 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
|
||||
│ ↳ Thoughts 111,111,111 │
|
||||
│ ↳ Tool 222,222,222 │
|
||||
│ ↳ Output 123,456,789 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ Auto (Gemini 3) Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-3-pro-preview gemini-3-flash-preview │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
@@ -106,7 +107,6 @@ exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-
|
||||
│ ↳ Thoughts 100 200 │
|
||||
│ ↳ Tool 50 100 │
|
||||
│ ↳ Output 4,000 8,000 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -115,6 +115,7 @@ exports[`<ModelStatsDisplay /> > should not display conditional rows if no model
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
@@ -125,7 +126,6 @@ exports[`<ModelStatsDisplay /> > should not display conditional rows if no model
|
||||
│ Total 30 │
|
||||
│ ↳ Input 10 │
|
||||
│ ↳ Output 20 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -133,6 +133,5 @@ exports[`<ModelStatsDisplay /> > should render "no API calls" message when there
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No API calls have been made in this session. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `"15%"`;
|
||||
|
||||
exports[`QuotaDisplay > should render red when usage < 5% 1`] = `"/stats 4% usage remaining"`;
|
||||
|
||||
exports[`QuotaDisplay > should render terse limit reached message 1`] = `"Limit reached"`;
|
||||
|
||||
exports[`QuotaDisplay > should render with reset time when provided 1`] = `"/stats 15% usage remaining, resets in 1h"`;
|
||||
|
||||
exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `"/stats 15% usage remaining"`;
|
||||
@@ -18,11 +18,11 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 10 500 500 2,000 │
|
||||
│ │
|
||||
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -17,7 +17,6 @@ exports[`<StatsDisplay /> > Code Changes Display > displays Code Changes when li
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 100ms (100.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -37,7 +36,6 @@ exports[`<StatsDisplay /> > Code Changes Display > hides Code Changes when no li
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 100ms (100.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -57,7 +55,6 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in gr
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -77,7 +74,6 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in re
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -97,7 +93,6 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in ye
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -118,10 +113,10 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 0 100 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -141,7 +136,36 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement w
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 123ms (100.0%) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for auto mode 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 Usage │
|
||||
│ 65% usage remaining │
|
||||
│ Usage limit: 1,100 │
|
||||
│ Usage limits span all sessions and reset daily. │
|
||||
│ For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
│ Model Reqs Usage remaining │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro - │
|
||||
│ gemini-2.5-flash - │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -162,16 +186,10 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Usage left │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Usage 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\`. │
|
||||
│ │
|
||||
│ gemini-2.5-flash - 50.0% resets in 2h │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -192,16 +210,10 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Usage left │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Usage remaining │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 75.0% (Resets in 1h 30m) │
|
||||
│ │
|
||||
│ 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\`. │
|
||||
│ │
|
||||
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -221,7 +233,6 @@ exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a ti
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -241,7 +252,6 @@ exports[`<StatsDisplay /> > Title Rendering > renders the default title when no
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -262,13 +272,13 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ 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 │
|
||||
│ │
|
||||
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -290,12 +300,12 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
|
||||
│ » Tool Time: 123ms (55.2%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ Model Usage │
|
||||
│ 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. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -315,6 +325,5 @@ exports[`<StatsDisplay /> > renders only the Performance section in its zero sta
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -16,7 +16,6 @@ exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly
|
||||
│ » Modified: 0 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 100.0% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -37,7 +36,6 @@ exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctl
|
||||
│ » Modified: 1 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 33.3% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -58,7 +56,6 @@ exports[`<ToolStatsDisplay /> > should handle large values without wrapping or o
|
||||
│ » Modified: 12345 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 55.6% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -78,7 +75,6 @@ exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
|
||||
│ » Modified: 0 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: -- │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -86,6 +82,5 @@ exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No tool calls have been made in this session. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user