test(cli): fix flaky QuotaDisplay snapshot and env leakage in StatusDisplay

This commit is contained in:
jacob314
2026-03-02 12:22:11 -08:00
parent dc6741097c
commit 355f5c070d
20 changed files with 246 additions and 118 deletions
-1
View File
@@ -183,7 +183,6 @@ describe('resolveWorkspacePolicyState', () => {
setAutoAcceptWorkspacePolicies(originalValue);
}
});
it('should not return workspace policies if cwd is the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
@@ -235,8 +235,8 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('15%');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
expect(lastFrame()).toContain('85%');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -5,10 +5,20 @@
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QuotaDisplay } from './QuotaDisplay.js';
describe('QuotaDisplay', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'America/Los_Angeles');
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-02T20:29:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it('should not render when remaining is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={undefined} limit={100} />,
@@ -36,7 +46,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should not render when usage > 20%', async () => {
it('should not render when usage < 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={85} limit={100} />,
);
@@ -45,7 +55,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render yellow when usage < 20%', async () => {
it('should render warning when used >= 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={15} limit={100} />,
);
@@ -54,7 +64,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render red when usage < 5%', async () => {
it('should render critical when used >= 95%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={4} limit={100} />,
);
+22 -20
View File
@@ -7,9 +7,9 @@
import type React from 'react';
import { Text } from 'ink';
import {
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { formatResetTime } from '../utils/formatters.js';
@@ -34,32 +34,34 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
return null;
}
const percentage = (remaining / limit) * 100;
const usedPercentage = 100 - (remaining / limit) * 100;
if (!forceShow && percentage > QUOTA_THRESHOLD_HIGH) {
if (!forceShow && usedPercentage < QUOTA_USED_WARNING_THRESHOLD) {
return null;
}
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
const resetInfo =
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
let text: string;
if (remaining === 0) {
let text = terse
? 'Limit reached'
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`;
if (lowercase) text = text.toLowerCase();
return <Text color={color}>{text}</Text>;
const resetMsg = resetTime
? `, resets in ${formatResetTime(resetTime, true)}`
: '';
text = terse ? 'Limit reached' : `Limit reached${resetMsg}`;
} else {
text = terse
? `${usedPercentage.toFixed(0)}%`
: `${usedPercentage.toFixed(0)}% used${
resetTime ? ` (Limit resets in ${formatResetTime(resetTime)})` : ''
}`;
}
let text = terse
? `${percentage.toFixed(0)}%`
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`;
if (lowercase) text = text.toLowerCase();
if (lowercase) {
text = text.toLowerCase();
}
return <Text color={color}>{text}</Text>;
};
@@ -9,9 +9,9 @@ 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,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
interface QuotaStatsInfoProps {
@@ -31,19 +31,24 @@ export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
return null;
}
const percentage = (remaining / limit) * 100;
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
const usedPercentage = 100 - (remaining / limit) * 100;
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
return (
<Box flexDirection="column" marginTop={0} marginBottom={0}>
<Text color={color}>
{remaining === 0
? `Limit reached`
: `${percentage.toFixed(0)}% usage remaining`}
{resetTime && `, ${formatResetTime(resetTime)}`}
? `Limit reached${
resetTime ? `, resets in ${formatResetTime(resetTime, true)}` : ''
}`
: `${usedPercentage.toFixed(0)}% used${
resetTime
? ` (Limit resets in ${formatResetTime(resetTime)})`
: ''
}`}
</Text>
{showDetails && (
<>
@@ -465,9 +465,9 @@ describe('<StatsDisplay />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Usage remaining');
expect(output).toContain('75.0%');
expect(output).toContain('resets in 1h 30m');
expect(output).toContain('Model usage');
expect(output).toContain('25% used');
expect(output).toContain('Limit resets in');
expect(output).toMatchSnapshot();
vi.useRealTimers();
@@ -521,8 +521,8 @@ describe('<StatsDisplay />', () => {
await waitUntilReady();
const output = lastFrame();
// (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
expect(output).toContain('65% usage remaining');
// (1 - 710/1100) * 100 = 35.5%
expect(output).toContain('35% used');
expect(output).toContain('Usage limit: 1,100');
expect(output).toMatchSnapshot();
@@ -571,8 +571,8 @@ 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('50% used');
expect(output).toContain('Limit resets in');
expect(output).toMatchSnapshot();
vi.useRealTimers();
+91 -20
View File
@@ -19,6 +19,9 @@ import {
USER_AGREEMENT_RATE_MEDIUM,
CACHE_EFFICIENCY_HIGH,
CACHE_EFFICIENCY_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import {
@@ -168,7 +171,26 @@ const ModelUsageTable: React.FC<{
const uncachedWidth = 15;
const cachedWidth = 14;
const outputTokensWidth = 15;
const usageLimitWidth = showQuotaColumn ? 28 : 0;
const usageLimitWidth = showQuotaColumn ? 85 : 0;
const renderProgressBar = (usedFraction: number, color: string) => {
const totalSteps = 20;
let filledSteps = Math.round(usedFraction * totalSteps);
// If something is used (fraction > 0) but rounds to 0, show 1 tick.
// If < 100% (fraction < 1) but rounds to 20, show 19 ticks.
if (usedFraction > 0 && usedFraction < 1) {
filledSteps = Math.min(Math.max(filledSteps, 1), totalSteps - 1);
}
const emptySteps = totalSteps - filledSteps;
return (
<Box flexDirection="row">
<Text color={color}>{'▬'.repeat(filledSteps)}</Text>
<Text color={theme.border.default}>{'▬'.repeat(emptySteps)}</Text>
</Box>
);
};
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
green: CACHE_EFFICIENCY_HIGH,
@@ -183,19 +205,21 @@ const ModelUsageTable: React.FC<{
: uncachedWidth + cachedWidth + outputTokensWidth);
const isAuto = currentModel && isAutoModel(currentModel);
const modelUsageTitle = isAuto
? `${getDisplayString(currentModel)} Usage`
: `Model Usage`;
const modelUsageTitle = isAuto ? (
<Text color={theme.text.primary} wrap="truncate-end">
<Text bold>Model Usage:</Text> {getDisplayString(currentModel)}
</Text>
) : (
<Text bold color={theme.text.primary} wrap="truncate-end">
Model Usage
</Text>
);
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 width={totalWidth}>{modelUsageTitle}</Box>
</Box>
{isAuto &&
@@ -270,10 +294,11 @@ const ModelUsageTable: React.FC<{
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
alignItems="flex-start"
paddingLeft={4}
>
<Text bold color={theme.text.primary}>
Usage remaining
Model usage
</Text>
</Box>
)}
@@ -355,16 +380,62 @@ const ModelUsageTable: React.FC<{
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
alignItems="flex-start"
paddingLeft={4}
>
{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>
)}
{row.bucket && row.bucket.remainingFraction != null && (
<Box flexDirection="row">
{(() => {
const actualUsedFraction = 1 - row.bucket.remainingFraction;
// If we have session activity but 0% server usage, show 0.1% as a hint.
const effectiveUsedFraction =
actualUsedFraction === 0 && row.isActive
? 0.001
: actualUsedFraction;
const usedPercentage = effectiveUsedFraction * 100;
const statusColor =
getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
}) ??
(row.isActive ? theme.text.primary : theme.ui.comment);
const percentageText =
usedPercentage > 0 && usedPercentage < 1
? `${usedPercentage.toFixed(1)}% used`
: `${usedPercentage.toFixed(0)}% used`;
return (
<>
{renderProgressBar(effectiveUsedFraction, statusColor)}
<Box marginLeft={1}>
<Text wrap="truncate-end">
{row.bucket.remainingFraction === 0 ? (
<Text color={theme.status.error}>
Limit reached
{row.bucket.resetTime &&
`, resets in ${formatResetTime(row.bucket.resetTime, true)}`}
</Text>
) : (
<>
<Text color={statusColor}>{percentageText}</Text>
<Text color={theme.text.secondary}>
{row.bucket.resetTime &&
formatResetTime(row.bucket.resetTime)
? ` (Limit resets in ${formatResetTime(row.bucket.resetTime)})`
: ''}
</Text>
</>
)}
</Text>
</Box>
</>
);
})()}
</Box>
)}
</Box>
</Box>
))}
@@ -8,7 +8,7 @@ exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] =
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
"
`;
@@ -40,6 +40,6 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
"
`;
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `
"15%
"85%
"
`;
exports[`QuotaDisplay > should render red when usage < 5% 1`] = `
"/stats 4% usage remaining
exports[`QuotaDisplay > should render critical when used >= 95% 1`] = `
"96% used
"
`;
@@ -15,12 +15,12 @@ exports[`QuotaDisplay > should render terse limit reached message 1`] = `
"
`;
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
"/stats 15% usage remaining, resets in 1h
exports[`QuotaDisplay > should render warning when used >= 80% 1`] = `
"85% used
"
`;
exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `
"/stats 15% usage remaining
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
"85% used (Limit resets in 1 hour at 1:29 PM PST)
"
`;
@@ -162,16 +162,16 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
auto Usage
65% usage remaining
Model Usage: auto
35% used
│ 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 -
│ Model Reqs Model usage
│ ────────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90% used
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30% used
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -194,9 +194,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 Model usage
│ ────────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% used (Limit resets in 2 hours at 6:00 AM
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -219,9 +219,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 Model usage
│ ────────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% used (Limit resets in 1 hour 30 minutes
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -60,6 +60,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
originalRequestName,
progress,
progressTotal,
progressPercent,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
@@ -98,6 +99,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
status={status}
description={description}
emphasis={emphasis}
progressMessage={progressMessage}
progressPercent={progressPercent}
originalRequestName={originalRequestName}
/>
<FocusHint
@@ -192,6 +192,8 @@ type ToolInfoProps = {
description: string;
status: CoreToolCallStatus;
emphasis: TextEmphasis;
progressMessage?: string;
progressPercent?: number;
originalRequestName?: string;
};
@@ -200,6 +202,8 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
description,
status: coreStatus,
emphasis,
progressMessage: _progressMessage,
progressPercent: _progressPercent,
originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
@@ -325,7 +325,6 @@ describe('toolMapping', () => {
const result = mapToDisplay(toolCall);
expect(result.tools[0].originalRequestName).toBe('original_tool');
});
it('propagates isClientInitiated from tool request', () => {
const clientInitiatedTool: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
+14 -14
View File
@@ -117,6 +117,20 @@ export function useToolScheduler(
const handler = (event: ToolCallsUpdateMessage) => {
const isRoot = event.schedulerId === ROOT_SCHEDULER_ID;
// Update output timer for UI spinners (Side Effect)
const hasExecuting = event.toolCalls.some(
(tc) =>
tc.status === CoreToolCallStatus.Executing ||
((tc.status === CoreToolCallStatus.Success ||
tc.status === CoreToolCallStatus.Error) &&
'tailToolCallRequest' in tc &&
tc.tailToolCallRequest != null),
);
if (hasExecuting) {
setLastToolOutputTime(Date.now());
}
setToolCallsMap((prev) => {
const prevCalls = prev[event.schedulerId] ?? [];
const prevCallIds = new Set(prevCalls.map((tc) => tc.request.callId));
@@ -151,20 +165,6 @@ export function useToolScheduler(
[event.schedulerId]: adapted,
};
});
// Update output timer for UI spinners (Side Effect)
const hasExecuting = event.toolCalls.some(
(tc) =>
tc.status === CoreToolCallStatus.Executing ||
((tc.status === CoreToolCallStatus.Success ||
tc.status === CoreToolCallStatus.Error) &&
'tailToolCallRequest' in tc &&
tc.tailToolCallRequest != null),
);
if (hasExecuting) {
setLastToolOutputTime(Date.now());
}
};
messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
+1
View File
@@ -115,6 +115,7 @@ export interface IndividualToolCallDisplay {
originalRequestName?: string;
progress?: number;
progressTotal?: number;
progressPercent?: number;
}
export interface CompressionProps {
+19
View File
@@ -19,6 +19,9 @@ export const CACHE_EFFICIENCY_MEDIUM = 15;
export const QUOTA_THRESHOLD_HIGH = 20;
export const QUOTA_THRESHOLD_MEDIUM = 5;
export const QUOTA_USED_WARNING_THRESHOLD = 80;
export const QUOTA_USED_CRITICAL_THRESHOLD = 95;
// --- Color Logic ---
export const getStatusColor = (
value: number,
@@ -36,3 +39,19 @@ export const getStatusColor = (
}
return options.defaultColor ?? theme.status.error;
};
/**
* Gets the status color based on "used" percentage (where higher is worse).
*/
export const getUsedStatusColor = (
usedPercentage: number,
thresholds: { warning: number; critical: number },
) => {
if (usedPercentage >= thresholds.critical) {
return theme.status.error;
}
if (usedPercentage >= thresholds.warning) {
return theme.status.warning;
}
return undefined;
};
+32 -14
View File
@@ -98,26 +98,44 @@ export function stripReferenceContent(text: string): string {
return text.replace(pattern, '').trim();
}
export const formatResetTime = (resetTime: string): string => {
const diff = new Date(resetTime).getTime() - Date.now();
export const formatResetTime = (
resetTime: string | undefined,
terse = false,
): string => {
if (!resetTime) return '';
const resetDate = new Date(resetTime);
if (isNaN(resetDate.getTime())) return '';
const diff = resetDate.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')}`;
if (terse) {
const hoursStr = hours > 0 ? `${hours}hr` : '';
const minutesStr = minutes > 0 ? `${minutes}m` : '';
return hoursStr && minutesStr
? `${hoursStr} ${minutesStr}`
: hoursStr || minutesStr;
}
return `resets in ${fmt(minutes, 'minute')}`;
let duration = '';
if (hours > 0) {
duration = `${hours} hour${hours > 1 ? 's' : ''}`;
if (minutes > 0) {
duration += ` ${minutes} minute${minutes > 1 ? 's' : ''}`;
}
} else {
duration = `${minutes} minute${minutes > 1 ? 's' : ''}`;
}
const timeStr = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
}).format(resetDate);
return `${duration} at ${timeStr}`;
};