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.
This commit is contained in:
Spencer
2026-02-20 04:54:05 +00:00
parent 9a8e5d3940
commit 2ecdc4b533
6 changed files with 406 additions and 228 deletions
@@ -27,37 +27,59 @@ export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
resetTime,
showDetails = true,
}) => {
if (remaining === undefined || limit === undefined || limit === 0) {
const hasData =
(remaining !== undefined && remaining !== null) ||
(limit !== undefined && limit !== null && limit > 0);
if (!hasData && !showDetails) {
return null;
}
const percentage = (remaining / limit) * 100;
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
});
const percentage =
limit && limit > 0 && remaining !== undefined && remaining !== null
? (remaining / limit) * 100
: undefined;
const color =
percentage !== undefined
? getStatusColor(percentage, {
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>
{hasData && (
<Text color={color}>
<Text bold>
{remaining === 0
? `Limit reached`
: percentage !== undefined
? `${percentage.toFixed(0)}%`
: remaining !== undefined && remaining !== null
? `${remaining.toLocaleString()}`
: 'Limit reached'}
</Text>
{remaining !== 0 && (
<Text>
{percentage !== undefined ||
(remaining !== undefined && remaining !== null)
? ' usage remaining'
: ''}
</Text>
)}
{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>
)}
<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();
+185 -174
View File
@@ -157,18 +157,27 @@ const ModelUsageTable: React.FC<{
}) => {
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
if (rows.length === 0) {
const isAuto = currentModel && isAutoModel(currentModel);
const modelUsageTitle = isAuto
? `${getDisplayString(currentModel)} Usage`
: `Model Usage`;
const hasPooledQuota =
(pooledRemaining !== undefined && pooledRemaining !== null) ||
(pooledLimit !== undefined && pooledLimit !== null && pooledLimit > 0);
if (rows.length === 0 && !hasPooledQuota && !isAuto) {
return null;
}
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
const nameWidth = 25;
const nameWidth = 22;
const requestsWidth = 7;
const uncachedWidth = 15;
const cachedWidth = 14;
const outputTokensWidth = 15;
const usageLimitWidth = showQuotaColumn ? 28 : 0;
const usageLimitWidth = showQuotaColumn ? 40 : 0;
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
green: CACHE_EFFICIENCY_HIGH,
@@ -182,11 +191,6 @@ 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" marginBottom={1}>
{/* Header */}
@@ -198,178 +202,185 @@ const ModelUsageTable: React.FC<{
</Box>
</Box>
{isAuto &&
showQuotaColumn &&
pooledRemaining !== undefined &&
pooledLimit !== undefined &&
pooledLimit > 0 && (
<Box flexDirection="column" marginTop={0} marginBottom={1}>
<QuotaStatsInfo
remaining={pooledRemaining}
limit={pooledLimit}
resetTime={pooledResetTime}
/>
{(hasPooledQuota || isAuto) && (
<Box flexDirection="column" marginTop={0} marginBottom={1}>
<QuotaStatsInfo
remaining={pooledRemaining}
limit={pooledLimit}
resetTime={pooledResetTime}
showDetails={true}
/>
{isAuto && (
<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
width={requestsWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Reqs
</Text>
</Box>
{!showQuotaColumn && (
<>
<Box
width={uncachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Input Tokens
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Cache Reads
</Text>
</Box>
<Box
width={outputTokensWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Output Tokens
</Text>
</Box>
</>
)}
{showQuotaColumn && (
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
<Text bold color={theme.text.primary}>
Usage remaining
</Text>
</Box>
)}
</Box>
{/* Divider */}
<Box
borderStyle="round"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
width={totalWidth}
></Box>
{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}
>
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.inputTokens}
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text color={theme.text.secondary}>{row.cachedTokens}</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>
))}
)}
{cacheEfficiency > 0 && !showQuotaColumn && (
{rows.length > 0 && (
<>
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Text bold color={theme.text.primary}>
Model
</Text>
</Box>
<Box
width={requestsWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Reqs
</Text>
</Box>
{!showQuotaColumn && (
<>
<Box
width={uncachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Input Tokens
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Cache Reads
</Text>
</Box>
<Box
width={outputTokensWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Output Tokens
</Text>
</Box>
</>
)}
{showQuotaColumn && (
<Box width={usageLimitWidth} justifyContent="flex-end">
<Text bold color={theme.text.primary}>
Usage left
</Text>
</Box>
)}
</Box>
{/* Divider */}
<Box
borderStyle="round"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
width={totalWidth}
></Box>
{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}
>
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.inputTokens}
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text color={theme.text.secondary}>{row.cachedTokens}</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} justifyContent="flex-end">
{row.bucket && row.bucket.remainingFraction != null && (
<Text color={theme.text.secondary}>
{(row.bucket.remainingFraction * 100).toFixed(1)}%
{row.bucket.resetTime && (
<Text color={theme.text.secondary}>
{' '}
(
{formatResetTime(row.bucket.resetTime).replace(
'resets in',
'Resets in',
)}
)
</Text>
)}
</Text>
)}
</Box>
</Box>
))}
</>
)}
{cacheEfficiency > 0 && !showQuotaColumn && rows.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>
<Text color={theme.status.success}>Savings Highlight:</Text>{' '}
@@ -118,9 +118,9 @@ 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 │
│ Model Reqs Input Tokens Cache Reads Output Tokens
│ ─────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 0 100
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -146,6 +146,58 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement w
"
`;
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 │
│ 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\`. │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for auto mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -164,14 +216,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 +246,9 @@ 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
│ Model Reqs Usage left
│ ─────────────────────────────────────────────────────────────────────
│ gemini-2.5-flash - 50.0% (Resets in 2h)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -219,9 +271,9 @@ 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
│ Model Reqs Usage left
│ ─────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 75.0% (Resets in 1h 30m)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -284,10 +336,10 @@ 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 │
│ 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 +365,9 @@ 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 │
│ 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. │
│ │
+25 -5
View File
@@ -99,8 +99,23 @@ export function stripReferenceContent(text: string): string {
}
export const formatResetTime = (resetTime: string): string => {
const diff = new Date(resetTime).getTime() - Date.now();
if (diff <= 0) return '';
let date = new Date(resetTime);
// If invalid, try parsing as a number (unix timestamp)
if (isNaN(date.getTime())) {
const timestamp = parseInt(resetTime, 10);
if (!isNaN(timestamp)) {
// Could be seconds or milliseconds. If < 10^12, likely seconds.
date = new Date(timestamp < 10000000000 ? timestamp * 1000 : timestamp);
}
}
if (isNaN(date.getTime())) {
return '';
}
const diff = date.getTime() - Date.now();
if (diff <= 0) return '(Resetting...)';
const totalMinutes = Math.ceil(diff / (1000 * 60));
const hours = Math.floor(totalMinutes / 60);
@@ -113,11 +128,16 @@ 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')}`;
return `resets in ${timeStr}`;
};
+2 -3
View File
@@ -1335,11 +1335,10 @@ export class Config {
const flashQuota = this.modelQuotas.get(flashModel);
if (proQuota || flashQuota) {
// For reset time, take the one that is furthest in the future (most conservative)
// For reset time, take the one that is nearest in the future (soonest reset)
const resetTime = [proQuota?.resetTime, flashQuota?.resetTime]
.filter((t): t is string => !!t)
.sort()
.reverse()[0];
.sort()[0];
return {
remaining: (proQuota?.remaining ?? 0) + (flashQuota?.remaining ?? 0),