Skip to content

Commit 69188c8

Browse files
authored
Add usage limit remaining in /stats (#13843)
1 parent 5949d56 commit 69188c8

File tree

10 files changed

+290
-32
lines changed

10 files changed

+290
-32
lines changed

packages/cli/src/ui/commands/statsCommand.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import { CodeAssistServer, getCodeAssistServer } from '@google/gemini-cli-core';
78
import type { HistoryItemStats } from '../types.js';
89
import { MessageType } from '../types.js';
910
import { formatDuration } from '../utils/formatters.js';
@@ -13,7 +14,7 @@ import {
1314
CommandKind,
1415
} from './types.js';
1516

16-
function defaultSessionView(context: CommandContext) {
17+
async function defaultSessionView(context: CommandContext) {
1718
const now = new Date();
1819
const { sessionStartTime } = context.session.stats;
1920
if (!sessionStartTime) {
@@ -33,6 +34,16 @@ function defaultSessionView(context: CommandContext) {
3334
duration: formatDuration(wallDuration),
3435
};
3536

37+
if (context.services.config) {
38+
const server = getCodeAssistServer(context.services.config);
39+
if (server instanceof CodeAssistServer && server.projectId) {
40+
const quota = await server.retrieveUserQuota({
41+
project: server.projectId,
42+
});
43+
statsItem.quotas = quota;
44+
}
45+
}
46+
3647
context.ui.addItem(statsItem, Date.now());
3748
}
3849

@@ -41,16 +52,16 @@ export const statsCommand: SlashCommand = {
4152
altNames: ['usage'],
4253
description: 'Check session stats. Usage: /stats [session|model|tools]',
4354
kind: CommandKind.BUILT_IN,
44-
action: (context: CommandContext) => {
45-
defaultSessionView(context);
55+
action: async (context: CommandContext) => {
56+
await defaultSessionView(context);
4657
},
4758
subCommands: [
4859
{
4960
name: 'session',
5061
description: 'Show session-specific usage statistics',
5162
kind: CommandKind.BUILT_IN,
52-
action: (context: CommandContext) => {
53-
defaultSessionView(context);
63+
action: async (context: CommandContext) => {
64+
await defaultSessionView(context);
5465
},
5566
},
5667
{

packages/cli/src/ui/components/HistoryItemDisplay.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
115115
<Help commands={commands} />
116116
)}
117117
{itemForDisplay.type === 'stats' && (
118-
<StatsDisplay duration={itemForDisplay.duration} />
118+
<StatsDisplay
119+
duration={itemForDisplay.duration}
120+
quotas={itemForDisplay.quotas}
121+
/>
119122
)}
120123
{itemForDisplay.type === 'model_stats' && <ModelStatsDisplay />}
121124
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}

packages/cli/src/ui/components/StatsDisplay.test.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { describe, it, expect, vi } from 'vitest';
99
import { StatsDisplay } from './StatsDisplay.js';
1010
import * as SessionContext from '../contexts/SessionContext.js';
1111
import type { SessionMetrics } from '../contexts/SessionContext.js';
12-
import { ToolCallDecision } from '@google/gemini-cli-core';
12+
import {
13+
ToolCallDecision,
14+
type RetrieveUserQuotaResponse,
15+
} from '@google/gemini-cli-core';
1316

1417
// Mock the context to provide controlled data for testing
1518
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@@ -387,4 +390,65 @@ describe('<StatsDisplay />', () => {
387390
expect(output).toMatchSnapshot();
388391
});
389392
});
393+
394+
describe('Quota Display', () => {
395+
it('renders quota information when quotas are provided', () => {
396+
const now = new Date('2025-01-01T12:00:00Z');
397+
vi.useFakeTimers();
398+
vi.setSystemTime(now);
399+
400+
const metrics = createTestMetrics({
401+
models: {
402+
'gemini-2.5-pro': {
403+
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
404+
tokens: {
405+
prompt: 100,
406+
candidates: 100,
407+
total: 250,
408+
cached: 50,
409+
thoughts: 0,
410+
tool: 0,
411+
},
412+
},
413+
},
414+
});
415+
416+
const resetTime = new Date(now.getTime() + 1000 * 60 * 90).toISOString(); // 1 hour 30 minutes from now
417+
418+
const quotas: RetrieveUserQuotaResponse = {
419+
buckets: [
420+
{
421+
modelId: 'gemini-2.5-pro',
422+
remainingFraction: 0.75,
423+
resetTime,
424+
},
425+
],
426+
};
427+
428+
useSessionStatsMock.mockReturnValue({
429+
stats: {
430+
sessionId: 'test-session-id',
431+
sessionStartTime: new Date(),
432+
metrics,
433+
lastPromptTokenCount: 0,
434+
promptCount: 5,
435+
},
436+
437+
getPromptCount: () => 5,
438+
startNewPrompt: vi.fn(),
439+
});
440+
441+
const { lastFrame } = render(
442+
<StatsDisplay duration="1s" quotas={quotas} />,
443+
);
444+
const output = lastFrame();
445+
446+
expect(output).toContain('Usage limit remaining');
447+
expect(output).toContain('75.0%');
448+
expect(output).toContain('(Resets in 1h 30m)');
449+
expect(output).toMatchSnapshot();
450+
451+
vi.useRealTimers();
452+
});
453+
});
390454
});

packages/cli/src/ui/components/StatsDisplay.tsx

Lines changed: 91 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
USER_AGREEMENT_RATE_MEDIUM,
2020
} from '../utils/displayUtils.js';
2121
import { computeSessionStats } from '../utils/computeStats.js';
22+
import type { RetrieveUserQuotaResponse } from '@google/gemini-cli-core';
2223

2324
// A more flexible and powerful StatRow component
2425
interface StatRowProps {
@@ -69,15 +70,41 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
6970
</Box>
7071
);
7172

73+
const formatResetTime = (resetTime: string): string => {
74+
const diff = new Date(resetTime).getTime() - Date.now();
75+
if (diff <= 0) return '';
76+
77+
const totalMinutes = Math.ceil(diff / (1000 * 60));
78+
const hours = Math.floor(totalMinutes / 60);
79+
const minutes = totalMinutes % 60;
80+
81+
const fmt = (val: number, unit: 'hour' | 'minute') =>
82+
new Intl.NumberFormat('en', {
83+
style: 'unit',
84+
unit,
85+
unitDisplay: 'narrow',
86+
}).format(val);
87+
88+
if (hours > 0 && minutes > 0) {
89+
return `(Resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')})`;
90+
} else if (hours > 0) {
91+
return `(Resets in ${fmt(hours, 'hour')})`;
92+
}
93+
94+
return `(Resets in ${fmt(minutes, 'minute')})`;
95+
};
96+
7297
const ModelUsageTable: React.FC<{
7398
models: Record<string, ModelMetrics>;
7499
totalCachedTokens: number;
75100
cacheEfficiency: number;
76-
}> = ({ models, totalCachedTokens, cacheEfficiency }) => {
101+
quotas?: RetrieveUserQuotaResponse;
102+
}> = ({ models, totalCachedTokens, cacheEfficiency, quotas }) => {
77103
const nameWidth = 25;
78104
const requestsWidth = 8;
79105
const inputTokensWidth = 15;
80106
const outputTokensWidth = 15;
107+
const usageLimitWidth = quotas ? 30 : 0;
81108

82109
return (
83110
<Box flexDirection="column" marginTop={1}>
@@ -103,6 +130,13 @@ const ModelUsageTable: React.FC<{
103130
Output Tokens
104131
</Text>
105132
</Box>
133+
{quotas && (
134+
<Box width={usageLimitWidth} justifyContent="flex-end">
135+
<Text bold color={theme.text.primary}>
136+
Usage limit remaining
137+
</Text>
138+
</Box>
139+
)}
106140
</Box>
107141
{/* Divider */}
108142
<Box
@@ -112,44 +146,73 @@ const ModelUsageTable: React.FC<{
112146
borderLeft={false}
113147
borderRight={false}
114148
borderColor={theme.border.default}
115-
width={nameWidth + requestsWidth + inputTokensWidth + outputTokensWidth}
149+
width={
150+
nameWidth +
151+
requestsWidth +
152+
inputTokensWidth +
153+
outputTokensWidth +
154+
usageLimitWidth
155+
}
116156
></Box>
117157

118158
{/* Rows */}
119-
{Object.entries(models).map(([name, modelMetrics]) => (
120-
<Box key={name}>
121-
<Box width={nameWidth}>
122-
<Text color={theme.text.primary}>{name.replace('-001', '')}</Text>
123-
</Box>
124-
<Box width={requestsWidth} justifyContent="flex-end">
125-
<Text color={theme.text.primary}>
126-
{modelMetrics.api.totalRequests}
127-
</Text>
128-
</Box>
129-
<Box width={inputTokensWidth} justifyContent="flex-end">
130-
<Text color={theme.status.warning}>
131-
{modelMetrics.tokens.prompt.toLocaleString()}
132-
</Text>
133-
</Box>
134-
<Box width={outputTokensWidth} justifyContent="flex-end">
135-
<Text color={theme.status.warning}>
136-
{modelMetrics.tokens.candidates.toLocaleString()}
137-
</Text>
159+
{Object.entries(models).map(([name, modelMetrics]) => {
160+
const modelName = name.replace('-001', '');
161+
const bucket = quotas?.buckets?.find((b) => b.modelId === modelName);
162+
163+
return (
164+
<Box key={name}>
165+
<Box width={nameWidth}>
166+
<Text color={theme.text.primary}>{modelName}</Text>
167+
</Box>
168+
<Box width={requestsWidth} justifyContent="flex-end">
169+
<Text color={theme.text.primary}>
170+
{modelMetrics.api.totalRequests}
171+
</Text>
172+
</Box>
173+
<Box width={inputTokensWidth} justifyContent="flex-end">
174+
<Text color={theme.status.warning}>
175+
{modelMetrics.tokens.prompt.toLocaleString()}
176+
</Text>
177+
</Box>
178+
<Box width={outputTokensWidth} justifyContent="flex-end">
179+
<Text color={theme.status.warning}>
180+
{modelMetrics.tokens.candidates.toLocaleString()}
181+
</Text>
182+
</Box>
183+
<Box width={usageLimitWidth} justifyContent="flex-end">
184+
{bucket &&
185+
bucket.remainingFraction != null &&
186+
bucket.resetTime && (
187+
<Text color={theme.text.secondary}>
188+
{(bucket.remainingFraction * 100).toFixed(1)}%{' '}
189+
{formatResetTime(bucket.resetTime)}
190+
</Text>
191+
)}
192+
</Box>
138193
</Box>
139-
</Box>
140-
))}
194+
);
195+
})}
141196
{cacheEfficiency > 0 && (
142197
<Box flexDirection="column" marginTop={1}>
143198
<Text color={theme.text.primary}>
144199
<Text color={theme.status.success}>Savings Highlight:</Text>{' '}
145200
{totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)}
146201
%) of input tokens were served from the cache, reducing costs.
147202
</Text>
148-
<Box height={1} />
203+
</Box>
204+
)}
205+
{models && (
206+
<>
207+
<Box marginTop={1} marginBottom={2}>
208+
<Text color={theme.text.primary}>
209+
{`Usage limits span all sessions and reset daily.\n/auth to upgrade or switch to API key.`}
210+
</Text>
211+
</Box>
149212
<Text color={theme.text.secondary}>
150213
» Tip: For a full token breakdown, run `/stats model`.
151214
</Text>
152-
</Box>
215+
</>
153216
)}
154217
</Box>
155218
);
@@ -158,11 +221,13 @@ const ModelUsageTable: React.FC<{
158221
interface StatsDisplayProps {
159222
duration: string;
160223
title?: string;
224+
quotas?: RetrieveUserQuotaResponse;
161225
}
162226

163227
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
164228
duration,
165229
title,
230+
quotas,
166231
}) => {
167232
const { stats } = useSessionStats();
168233
const { metrics } = stats;
@@ -276,6 +341,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
276341
models={models}
277342
totalCachedTokens={computed.totalCachedTokens}
278343
cacheEfficiency={computed.cacheEfficiency}
344+
quotas={quotas}
279345
/>
280346
)}
281347
</Box>

packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
2424
│ │
2525
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
2626
│ │
27+
│ Usage limits span all sessions and reset daily. │
28+
│ /auth to upgrade or switch to API key. │
29+
│ │
30+
│ │
2731
│ » Tip: For a full token breakdown, run \`/stats model\`. │
2832
│ │
2933
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"

0 commit comments

Comments
 (0)