Compare commits

...

9 Commits

Author SHA1 Message Date
Spencer f1a7d7afdb Merge branch 'main' into feat/stats-quota-display-improvements 2026-02-20 17:48:32 -05:00
Spencer ca530967c9 Merge branch 'main' into feat/stats-quota-display-improvements 2026-02-20 17:16:28 -05:00
Spencer 0ae29ab780 refactor(cli): improve quota reset formatting robustness and add unit tests 2026-02-20 22:15:05 +00:00
Spencer 7abb4f5770 refactor(cli): consolidate quota reset formatting and restore conservative defaults 2026-02-20 22:04:27 +00:00
Spencer aa68a97482 feat(cli): improve quota display layout and fallback logic in /stats 2026-02-20 21:54:35 +00:00
Spencer 62841eea35 feat(cli): always show quota remaining in /stats 2026-02-20 21:54:35 +00:00
Spencer 9d195f0f31 refactor: address gemini-code-assist feedback on quota display
- Simplifies conditional rendering logic in QuotaStatsInfo.
- Improves chronological sorting of reset times in Config using Date objects.
- Documents the Unix timestamp parsing heuristic in formatResetTime.
- Streamlines intermediate string assignments in the formatter.
2026-02-20 21:54:35 +00:00
Spencer 211a6fb50a fix(cli): refine quota reset time formatting and call sites
- Reverts formatResetTime to return only the raw duration string.
- Moves 'resets in' prefix logic to call sites to prevent duplication.
- Fixes double parentheses in StatsDisplay for imminent resets.
- Removes brittle string replacement in favor of direct formatting.
2026-02-20 21:54:35 +00:00
Spencer 2ecdc4b533 feat(cli): improve quota display in /stats command and footer
- Updates /stats and /stats session to show available quota even without usage.
- Refines QuotaStatsInfo styling to match design: bold percentages and improved spacing.
- Fixes reset time formatting ('Resets in') and visibility in the model usage table.
- Standardizes column widths in StatsDisplay to prevent truncation of quota info.
- Improves formatResetTime resilience to handle various date formats and small offsets.
- Ensures informational quota text renders consistently for auto-models.
2026-02-20 21:54:35 +00:00
11 changed files with 585 additions and 277 deletions
@@ -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<typeof SettingsContext>();
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(
<ModelStatsDisplay currentModel={currentModel} />,
width,
{ width },
);
await result.waitUntilReady();
return result;
@@ -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',
@@ -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"
@@ -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;
@@ -43,6 +46,7 @@ interface ModelStatsDisplayProps {
tier?: string;
currentModel?: string;
quotaStats?: QuotaStats;
quotas?: RetrieveUserQuotaResponse;
}
export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
@@ -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;
@@ -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(
@@ -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>
);
};
@@ -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 (
@@ -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<QuotaStatsInfoProps> = ({
@@ -26,38 +34,68 @@ export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
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()}
</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>
)}
<Text color={theme.text.primary}>
/auth to upgrade or switch to API key.
</Text>
</>
)}
</Box>
@@ -465,9 +465,9 @@ describe('<StatsDisplay />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Usage remaining');
expect(output).toContain('Usage left');
expect(output).toContain('75.0%');
expect(output).toContain('resets in 1h 30m');
expect(output).toContain('(Resets in 1h 30m)');
expect(output).toMatchSnapshot();
vi.useRealTimers();
@@ -523,18 +523,92 @@ describe('<StatsDisplay />', () => {
// (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
expect(output).toContain('65% usage remaining');
expect(output).toContain('Usage limit: 1,100');
expect(output).toMatchSnapshot();
vi.useRealTimers();
});
it('renders pooled quota even when no models have been used and no individual quotas are available', async () => {
const metrics = createTestMetrics();
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 0,
},
getPromptCount: () => 0,
startNewPrompt: vi.fn(),
});
const { lastFrame, waitUntilReady } = renderWithProviders(
<StatsDisplay
duration="1s"
currentModel="auto-gemini-3"
quotaStats={{
remaining: 500,
limit: 1000,
}}
/>,
{ width: 100 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Auto (Gemini 3) Usage');
expect(output).toContain('50% usage remaining');
expect(output).toMatchSnapshot();
});
it('renders model table quota percentage even when resetTime is missing', async () => {
// No models in metrics, but a quota for gemini-2.5-flash without resetTime
const metrics = createTestMetrics();
const quotas: RetrieveUserQuotaResponse = {
buckets: [
{
modelId: 'gemini-2.5-flash',
remainingAmount: '50',
remainingFraction: 0.5,
// resetTime is missing
},
],
};
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
const { lastFrame, waitUntilReady } = renderWithProviders(
<StatsDisplay duration="1s" quotas={quotas} />,
{ width: 100 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('-'); // for requests
expect(output).toContain('50.0%');
expect(output).not.toContain('Resets in');
expect(output).toMatchSnapshot();
});
it('renders quota information for unused models', async () => {
const now = new Date('2025-01-01T12:00:00Z');
vi.useFakeTimers();
vi.setSystemTime(now);
// No models in metrics, but a quota for gemini-2.5-flash
// No models in metrics, but a quota for gemini-2.5-flash with resetTime
const metrics = createTestMetrics();
const resetTime = new Date(now.getTime() + 1000 * 60 * 120).toISOString(); // 2 hours from now
@@ -572,7 +646,7 @@ describe('<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();
+115 -156
View File
@@ -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!,
@@ -157,76 +155,55 @@ const ModelUsageTable: React.FC<{
}) => {
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
if (rows.length === 0) {
return null;
}
const isAuto = currentModel && isAutoModel(currentModel);
const modelUsageTitle = isAuto
? `${getDisplayString(currentModel)} Usage`
: `Model Usage`;
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
const showQuotaColumn = !!quotas;
const nameWidth = 25;
const requestsWidth = 7;
const uncachedWidth = 15;
const cachedWidth = 14;
const outputTokensWidth = 15;
const usageLimitWidth = showQuotaColumn ? 28 : 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);
const isAuto = currentModel && isAutoModel(currentModel);
const modelUsageTitle = isAuto
? `${getDisplayString(currentModel)} Usage`
: `Model Usage`;
return (
<Box flexDirection="column" marginBottom={1}>
{/* Header */}
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Text bold color={theme.text.primary} wrap="truncate-end">
{modelUsageTitle}
</Text>
</Box>
<Box>
<Text bold color={theme.text.primary} wrap="truncate-end">
{modelUsageTitle}
</Text>
</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 flexDirection="column" marginTop={0} marginBottom={1}>
<QuotaStatsInfo
remaining={pooledRemaining}
limit={pooledLimit}
resetTime={pooledResetTime}
showDetails={true}
quotas={quotas}
useGemini3_1={useGemini3_1}
useCustomToolModel={useCustomToolModel}
/>
{isAuto && (
<Text color={theme.text.primary}>
For a full token breakdown, run `/stats model`.
</Text>
)}
</Box>
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Box>
<Box width={modelColumnWidth}>
<Text bold color={theme.text.primary}>
Model
</Text>
</Box>
<Box
width={requestsWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Box width={requestsColumnWidth} justifyContent="flex-end">
<Text bold color={theme.text.primary}>
Reqs
</Text>
@@ -234,32 +211,17 @@ const ModelUsageTable: React.FC<{
{!showQuotaColumn && (
<>
<Box
width={uncachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Box width={tokenColumnWidth} justifyContent="flex-end">
<Text bold color={theme.text.primary}>
Input Tokens
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Box width={tokenColumnWidth} justifyContent="flex-end">
<Text bold color={theme.text.primary}>
Cache Reads
</Text>
</Box>
<Box
width={outputTokensWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Box width={tokenColumnWidth} justifyContent="flex-end">
<Text bold color={theme.text.primary}>
Output Tokens
</Text>
@@ -267,13 +229,9 @@ const ModelUsageTable: React.FC<{
</>
)}
{showQuotaColumn && (
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
<Box flexGrow={1} justifyContent="flex-end">
<Text bold color={theme.text.primary}>
Usage remaining
Usage left
</Text>
</Box>
)}
@@ -281,95 +239,96 @@ const ModelUsageTable: React.FC<{
{/* Divider */}
<Box
borderStyle="round"
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
width={totalWidth}
></Box>
marginBottom={0}
/>
{rows.map((row) => (
<Box key={row.key}>
<Box width={nameWidth}>
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
wrap="truncate-end"
>
{row.modelName}
</Text>
</Box>
<Box
width={requestsWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
>
{row.requests}
</Text>
</Box>
{!showQuotaColumn && (
<>
<Box
width={uncachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
{rows.length > 0 ? (
rows.map((row) => (
<Box key={row.key}>
<Box width={modelColumnWidth}>
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
wrap="truncate-end"
>
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.inputTokens}
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
{row.modelName}
</Text>
</Box>
<Box width={requestsColumnWidth} justifyContent="flex-end">
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
>
<Text color={theme.text.secondary}>{row.cachedTokens}</Text>
{row.requests}
</Text>
</Box>
{!showQuotaColumn && (
<>
<Box width={tokenColumnWidth} justifyContent="flex-end">
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.inputTokens}
</Text>
</Box>
<Box width={tokenColumnWidth} justifyContent="flex-end">
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.cachedTokens}
</Text>
</Box>
<Box width={tokenColumnWidth} justifyContent="flex-end">
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.outputTokens}
</Text>
</Box>
</>
)}
{showQuotaColumn && (
<Box flexGrow={1} justifyContent="flex-end">
{row.bucket && row.bucket.remainingFraction != null && (
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{(row.bucket.remainingFraction * 100).toFixed(1)}%
{row.bucket.resetTime && (
<Text color={theme.text.secondary}>
{' '}
(
{formatResetTime(row.bucket.resetTime, {
capitalize: true,
})}
)
</Text>
)}
</Text>
)}
</Box>
<Box
width={outputTokensWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.outputTokens}
</Text>
</Box>
</>
)}
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
{row.bucket &&
row.bucket.remainingFraction != null &&
row.bucket.resetTime && (
<Text color={theme.text.secondary} wrap="truncate-end">
{(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
{formatResetTime(row.bucket.resetTime)}
</Text>
)}
)}
</Box>
))
) : (
<Box>
<Text color={theme.text.secondary}>No model usage recorded.</Text>
</Box>
))}
)}
{cacheEfficiency > 0 && !showQuotaColumn && (
{cacheEfficiency > 0 && !showQuotaColumn && rows.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>
<Text color={theme.status.success}>Savings Highlight:</Text>{' '}
@@ -5,6 +5,8 @@ exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
│ │
│ Model Stats For Nerds │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
@@ -28,6 +30,8 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
│ │
│ Model Stats For Nerds │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
@@ -51,6 +55,8 @@ exports[`<ModelStatsDisplay /> > should display role breakdown correctly 1`] = `
│ │
│ Model Stats For Nerds │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
@@ -78,6 +84,8 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
│ │
│ Model Stats For Nerds │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
@@ -101,6 +109,8 @@ exports[`<ModelStatsDisplay /> > should filter out invalid role names 1`] = `
│ │
│ Model Stats For Nerds │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
@@ -121,6 +131,8 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
│ │
│ Model Stats For Nerds │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
@@ -144,6 +156,8 @@ exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
│ │
│ Model Stats For Nerds │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
@@ -170,6 +184,8 @@ exports[`<ModelStatsDisplay /> > should not display conditional rows if no model
│ │
│ Model Stats For Nerds │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
@@ -188,6 +204,11 @@ exports[`<ModelStatsDisplay /> > should not display conditional rows if no model
exports[`<ModelStatsDisplay /> > should render "no API calls" message when there are no active models 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ No API calls have been made in this session. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -17,6 +17,14 @@ exports[`<StatsDisplay /> > Code Changes Display > displays Code Changes when li
│ » API Time: 0s (0.0%) │
│ » Tool Time: 100ms (100.0%) │
│ │
│ Model Usage │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -37,6 +45,14 @@ exports[`<StatsDisplay /> > Code Changes Display > hides Code Changes when no li
│ » API Time: 0s (0.0%) │
│ » Tool Time: 100ms (100.0%) │
│ │
│ Model Usage │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -57,6 +73,14 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in gr
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -77,6 +101,14 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in re
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -97,6 +129,14 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in ye
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -118,9 +158,12 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 100 0 100
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 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -142,6 +185,73 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement w
│ » API Time: 0s (0.0%) │
│ » Tool Time: 123ms (100.0%) │
│ │
│ Model Usage │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<StatsDisplay /> > Quota Display > renders model table quota percentage even when resetTime is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
│ 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% │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<StatsDisplay /> > Quota Display > renders pooled quota even when no models have been used and no individual quotas are available 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Auto (Gemini 3) Usage │
│ 50% usage remaining │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ For a full token breakdown, run \`/stats model\`. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -164,14 +274,14 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ │
│ auto Usage │
│ 65% usage remaining │
│ Usage limit: 1,100 │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ For a full token breakdown, run \`/stats model\`. │
│ │
│ Model Reqs Usage remaining
│ ────────────────────────────────────────────────────────────
│ gemini-2.5-pro -
│ gemini-2.5-flash -
│ Model Reqs Usage left
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro - 10.0%
│ gemini-2.5-flash - 70.0%
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -194,9 +304,13 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
Model Reqs Usage remaining
────────────────────────────────────────────────────────────
gemini-2.5-flash - 50.0% resets in 2h
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) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -219,9 +333,13 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
Model Reqs Usage remaining
────────────────────────────────────────────────────────────
gemini-2.5-pro 1 75.0% resets in 1h 30m
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) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -243,6 +361,14 @@ exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a ti
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -263,6 +389,14 @@ exports[`<StatsDisplay /> > Title Rendering > renders the default title when no
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -284,10 +418,13 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 3 500 500 2,000
gemini-2.5-flash 5 15,000 10,000 15,000
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 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -313,9 +450,12 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » Tool Time: 123ms (55.2%) │
│ │
│ Model Usage │
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 50 50 100
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 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -339,6 +479,14 @@ exports[`<StatsDisplay /> > renders only the Performance section in its zero sta
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ No model usage recorded. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -10,9 +10,60 @@ import {
formatBytes,
formatTimeAgo,
stripReferenceContent,
formatResetTime,
} from './formatters.js';
describe('formatters', () => {
describe('formatResetTime', () => {
const NOW = new Date('2026-02-20T12:00:00Z');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('should format ISO date strings', () => {
const resetTime = '2026-02-20T13:30:00Z'; // 1h 30m from now
expect(formatResetTime(resetTime)).toBe('resets in 1h 30m');
});
it('should format unix timestamps (milliseconds)', () => {
const resetTime = (NOW.getTime() + 65 * 60 * 1000).toString(); // 1h 5m
expect(formatResetTime(resetTime)).toBe('resets in 1h 5m');
});
it('should format unix timestamps (seconds)', () => {
const resetTime = (Math.floor(NOW.getTime() / 1000) + 120).toString(); // 2m
expect(formatResetTime(resetTime)).toBe('resets in 2m');
});
it('should return "Resetting..." for past times', () => {
const resetTime = '2026-02-20T11:59:59Z';
expect(formatResetTime(resetTime)).toBe('Resetting...');
});
it('should return "< 1m" for imminent resets', () => {
const resetTime = new Date(NOW.getTime() + 30 * 1000).toISOString();
expect(formatResetTime(resetTime)).toBe('< 1m');
});
it('should support capitalization and prefix options', () => {
const resetTime = '2026-02-20T14:00:00Z'; // 2h
expect(
formatResetTime(resetTime, { capitalize: true, includePrefix: true }),
).toBe('Resets in 2h');
expect(formatResetTime(resetTime, { includePrefix: false })).toBe('2h');
});
it('should return empty string for invalid dates', () => {
expect(formatResetTime('not-a-date')).toBe('');
});
});
describe('formatBytes', () => {
it('should format bytes into KB', () => {
expect(formatBytes(12345)).toBe('12.1 KB');
+40 -7
View File
@@ -97,12 +97,35 @@ export function stripReferenceContent(text: string): string {
return text.replace(pattern, '').trim();
}
const UNIX_SECONDS_THRESHOLD = 10_000_000_000;
export const formatResetTime = (resetTime: string): string => {
const diff = new Date(resetTime).getTime() - Date.now();
if (diff <= 0) return '';
export const formatResetTime = (
resetTime: string,
options: { capitalize?: boolean; includePrefix?: boolean } = {},
): string => {
const { capitalize = false, includePrefix = true } = options;
let date = new Date(resetTime);
const totalMinutes = Math.ceil(diff / (1000 * 60));
// If invalid, try parsing as a number (unix timestamp)
if (isNaN(date.getTime())) {
const timestamp = parseInt(resetTime, 10);
if (!isNaN(timestamp)) {
// Heuristic: If the timestamp is less than 10^10, it is likely in seconds
// (as 10^10 ms is year 1970, while 10^10 s is year 2286).
date = new Date(
timestamp < UNIX_SECONDS_THRESHOLD ? timestamp * 1000 : timestamp,
);
}
}
if (isNaN(date.getTime())) {
return '';
}
const diff = date.getTime() - Date.now();
if (diff <= 0) return 'Resetting...';
const totalMinutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
@@ -113,11 +136,21 @@ export const formatResetTime = (resetTime: string): string => {
unitDisplay: 'narrow',
}).format(val);
let timeStr = '';
if (hours > 0 && minutes > 0) {
return `resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`;
timeStr = `${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`;
} else if (hours > 0) {
return `resets in ${fmt(hours, 'hour')}`;
timeStr = fmt(hours, 'hour');
} else if (minutes > 0) {
timeStr = fmt(minutes, 'minute');
} else {
timeStr = '< 1m';
}
return `resets in ${fmt(minutes, 'minute')}`;
if (timeStr === '< 1m' || !includePrefix) {
return timeStr;
}
const prefix = capitalize ? 'Resets in ' : 'resets in ';
return `${prefix}${timeStr}`;
};
+13 -10
View File
@@ -68,7 +68,12 @@ import { ideContextStore } from '../ide/ideContext.js';
import { WriteTodosTool } from '../tools/write-todos.js';
import type { FileSystemService } from '../services/fileSystemService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js';
import {
logRipgrepFallback,
logFlashFallback,
logApprovalModeSwitch,
logApprovalModeDuration,
} from '../telemetry/loggers.js';
import {
RipgrepFallbackEvent,
FlashFallbackEvent,
@@ -103,9 +108,11 @@ import type { EventEmitter } from 'node:events';
import { PolicyEngine } from '../policy/policy-engine.js';
import { ApprovalMode, type PolicyEngineConfig } from '../policy/types.js';
import { HookSystem } from '../hooks/index.js';
import type { UserTierId } from '../code_assist/types.js';
import type { RetrieveUserQuotaResponse } from '../code_assist/types.js';
import type { AdminControlsSettings } from '../code_assist/types.js';
import type {
UserTierId,
RetrieveUserQuotaResponse,
AdminControlsSettings,
} from '../code_assist/types.js';
import type { HierarchicalMemory } from './memory.js';
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
import type { Experiments } from '../code_assist/experiments/experiments.js';
@@ -119,10 +126,6 @@ import { debugLogger } from '../utils/debugLogger.js';
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import {
logApprovalModeSwitch,
logApprovalModeDuration,
} from '../telemetry/loggers.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath } from '../utils/paths.js';
import { UserHintService } from './userHintService.js';
@@ -1338,8 +1341,8 @@ export class Config {
// For reset time, take the one that is furthest in the future (most conservative)
const resetTime = [proQuota?.resetTime, flashQuota?.resetTime]
.filter((t): t is string => !!t)
.sort()
.reverse()[0];
.map((t) => ({ original: t, date: new Date(t) }))
.sort((a, b) => b.date.getTime() - a.date.getTime())[0]?.original;
return {
remaining: (proQuota?.remaining ?? 0) + (flashQuota?.remaining ?? 0),