diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a358ad8b07..973d88f5f8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -169,7 +169,7 @@ jobs:
npm run test:ci --workspace @google/gemini-cli
else
# Explicitly list non-cli packages to ensure they are sharded correctly
- npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present
+ npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false
npm run test:scripts
fi
diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts
index 9baccd3359..8d368bfb91 100644
--- a/packages/cli/src/config/policy.test.ts
+++ b/packages/cli/src/config/policy.test.ts
@@ -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 });
diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index b79b005d85..667168dbc5 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -235,7 +235,7 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).toContain('15%');
+ expect(lastFrame()).toContain('85%');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
diff --git a/packages/cli/src/ui/components/QuotaDisplay.test.tsx b/packages/cli/src/ui/components/QuotaDisplay.test.tsx
index 150eb7097c..5a8b8c5bf8 100644
--- a/packages/cli/src/ui/components/QuotaDisplay.test.tsx
+++ b/packages/cli/src/ui/components/QuotaDisplay.test.tsx
@@ -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(
,
@@ -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(
,
);
@@ -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(
,
);
@@ -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(
,
);
diff --git a/packages/cli/src/ui/components/QuotaDisplay.tsx b/packages/cli/src/ui/components/QuotaDisplay.tsx
index 881f4c77fd..96e11e18cf 100644
--- a/packages/cli/src/ui/components/QuotaDisplay.tsx
+++ b/packages/cli/src/ui/components/QuotaDisplay.tsx
@@ -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,36 @@ export const QuotaDisplay: React.FC = ({
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};
+ const resetMsg = resetTime
+ ? `, resets in ${formatResetTime(resetTime, 'terse')}`
+ : '';
+ text = terse ? 'Limit reached' : `Limit reached${resetMsg}`;
+ } else {
+ text = terse
+ ? `${usedPercentage.toFixed(0)}%`
+ : `${usedPercentage.toFixed(0)}% used${
+ resetTime
+ ? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})`
+ : ''
+ }`;
}
- 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};
};
diff --git a/packages/cli/src/ui/components/QuotaStatsInfo.tsx b/packages/cli/src/ui/components/QuotaStatsInfo.tsx
index 22325db147..f617e98b3a 100644
--- a/packages/cli/src/ui/components/QuotaStatsInfo.tsx
+++ b/packages/cli/src/ui/components/QuotaStatsInfo.tsx
@@ -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,26 @@ export const QuotaStatsInfo: React.FC = ({
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 (
{remaining === 0
- ? `Limit reached`
- : `${percentage.toFixed(0)}% usage remaining`}
- {resetTime && `, ${formatResetTime(resetTime)}`}
+ ? `Limit reached${
+ resetTime
+ ? `, resets in ${formatResetTime(resetTime, 'terse')}`
+ : ''
+ }`
+ : `${usedPercentage.toFixed(0)}% used${
+ resetTime
+ ? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})`
+ : ''
+ }`}
{showDetails && (
<>
diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx
index af7e1b884d..0a3c5eca21 100644
--- a/packages/cli/src/ui/components/StatsDisplay.test.tsx
+++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx
@@ -68,6 +68,14 @@ const createTestMetrics = (
});
describe('', () => {
+ beforeEach(() => {
+ vi.stubEnv('TZ', 'UTC');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
it('renders only the Performance section in its zero state', async () => {
const zeroMetrics = createTestMetrics();
@@ -465,9 +473,9 @@ describe('', () => {
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%');
+ expect(output).toContain('Usage resets');
expect(output).toMatchSnapshot();
vi.useRealTimers();
@@ -521,8 +529,8 @@ describe('', () => {
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%');
expect(output).toContain('Usage limit: 1,100');
expect(output).toMatchSnapshot();
@@ -571,8 +579,8 @@ describe('', () => {
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%');
+ expect(output).toContain('Usage resets');
expect(output).toMatchSnapshot();
vi.useRealTimers();
diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx
index 65169f6d74..f26c9a3ea5 100644
--- a/packages/cli/src/ui/components/StatsDisplay.tsx
+++ b/packages/cli/src/ui/components/StatsDisplay.tsx
@@ -5,7 +5,7 @@
*/
import type React from 'react';
-import { Box, Text } from 'ink';
+import { Box, Text, useStdout } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import { theme } from '../semantic-colors.js';
import { formatDuration, formatResetTime } from '../utils/formatters.js';
@@ -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 {
@@ -155,6 +158,8 @@ const ModelUsageTable: React.FC<{
useGemini3_1,
useCustomToolModel,
}) => {
+ const { stdout } = useStdout();
+ const terminalWidth = stdout?.columns ?? 84;
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
if (rows.length === 0) {
@@ -163,12 +168,47 @@ const ModelUsageTable: React.FC<{
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
- const nameWidth = 25;
- const requestsWidth = 7;
+ const nameWidth = 23;
+ const requestsWidth = 5;
const uncachedWidth = 15;
const cachedWidth = 14;
const outputTokensWidth = 15;
- const usageLimitWidth = showQuotaColumn ? 28 : 0;
+ const percentageWidth = showQuotaColumn ? 6 : 0;
+ const resetWidth = 22;
+
+ // Total width of other columns (including parent box paddingX={2})
+ const fixedWidth = nameWidth + requestsWidth + percentageWidth + resetWidth;
+ const outerPadding = 4;
+ const availableForUsage = terminalWidth - outerPadding - fixedWidth;
+
+ const usageLimitWidth = showQuotaColumn
+ ? Math.max(10, Math.min(24, availableForUsage))
+ : 0;
+ const progressBarWidth = Math.max(2, usageLimitWidth - 4);
+
+ const renderProgressBar = (
+ usedFraction: number,
+ color: string,
+ 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 = Math.max(0, totalSteps - filledSteps);
+ return (
+
+
+ {'▬'.repeat(filledSteps)}
+ {'▬'.repeat(emptySteps)}
+
+
+ );
+ };
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
green: CACHE_EFFICIENCY_HIGH,
@@ -179,25 +219,13 @@ const ModelUsageTable: React.FC<{
nameWidth +
requestsWidth +
(showQuotaColumn
- ? usageLimitWidth
+ ? usageLimitWidth + percentageWidth + resetWidth
: uncachedWidth + cachedWidth + outputTokensWidth);
const isAuto = currentModel && isAutoModel(currentModel);
- const modelUsageTitle = isAuto
- ? `${getDisplayString(currentModel)} Usage`
- : `Model Usage`;
return (
- {/* Header */}
-
-
-
- {modelUsageTitle}
-
-
-
-
{isAuto &&
showQuotaColumn &&
pooledRemaining !== undefined &&
@@ -216,7 +244,7 @@ const ModelUsageTable: React.FC<{
)}
-
+
Model
@@ -267,15 +295,31 @@ const ModelUsageTable: React.FC<{
>
)}
{showQuotaColumn && (
-
-
- Usage remaining
-
-
+ <>
+
+
+ Model usage
+
+
+
+
+
+ Usage resets
+
+
+ >
)}
@@ -290,84 +334,150 @@ const ModelUsageTable: React.FC<{
width={totalWidth}
>
- {rows.map((row) => (
-
-
- {
+ let effectiveUsedFraction = 0;
+ let usedPercentage = 0;
+ let statusColor = theme.ui.comment;
+ let percentageText = '';
+
+ if (row.bucket && row.bucket.remainingFraction != null) {
+ const actualUsedFraction = 1 - row.bucket.remainingFraction;
+ effectiveUsedFraction =
+ actualUsedFraction === 0 && row.isActive
+ ? 0.001
+ : actualUsedFraction;
+ usedPercentage = effectiveUsedFraction * 100;
+ statusColor =
+ getUsedStatusColor(usedPercentage, {
+ warning: QUOTA_USED_WARNING_THRESHOLD,
+ critical: QUOTA_USED_CRITICAL_THRESHOLD,
+ }) ?? (row.isActive ? theme.text.primary : theme.ui.comment);
+ percentageText =
+ usedPercentage > 0 && usedPercentage < 1
+ ? `${usedPercentage.toFixed(1)}%`
+ : `${usedPercentage.toFixed(0)}%`;
+ }
+
+ return (
+
+
+
+ {row.modelName}
+
+
+
- {row.modelName}
-
-
-
-
- {row.requests}
-
-
- {!showQuotaColumn && (
- <>
-
-
+
+ {!showQuotaColumn && (
+ <>
+
- {row.inputTokens}
-
-
-
- {row.cachedTokens}
-
-
-
+ {row.inputTokens}
+
+
+
- {row.outputTokens}
-
-
- >
- )}
-
- {row.bucket &&
- row.bucket.remainingFraction != null &&
- row.bucket.resetTime && (
-
- {(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
- {formatResetTime(row.bucket.resetTime)}
-
- )}
+ {row.cachedTokens}
+
+
+
+ {row.outputTokens}
+
+
+ >
+ )}
+ {showQuotaColumn && (
+ <>
+
+ {row.bucket && row.bucket.remainingFraction != null && (
+
+ {renderProgressBar(
+ effectiveUsedFraction,
+ statusColor,
+ progressBarWidth,
+ )}
+
+ )}
+
+
+ {row.bucket && row.bucket.remainingFraction != null && (
+
+ {row.bucket.remainingFraction === 0 ? (
+
+ Limit
+
+ ) : (
+
+ {percentageText}
+
+ )}
+
+ )}
+
+
+
+ {row.bucket?.resetTime &&
+ formatResetTime(row.bucket.resetTime, 'column')
+ ? formatResetTime(row.bucket.resetTime, 'column')
+ : ''}
+
+
+ >
+ )}
-
- ))}
+ );
+ })}
{cacheEfficiency > 0 && !showQuotaColumn && (
diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
index 2d7b413787..f2892488c3 100644
--- a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
@@ -8,7 +8,7 @@ exports[` > displays "Limit reached" message when remaining is 0 1`] =
exports[` > 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 configuration filtering (golden snapshots) > render
exports[` > 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%
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
index b631f4e8ad..822777ad67 100644
--- a/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
@@ -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 1h)
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
index ab8f60e9f5..e6d61e64e5 100644
--- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
@@ -17,10 +17,9 @@ exports[` > renders the summary display with a title 1`
│ » API Time: 50.2s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ Model Usage │
-│ Model Reqs Input Tokens Cache Reads Output Tokens │
-│ ──────────────────────────────────────────────────────────────────────────── │
-│ gemini-2.5-pro 10 500 500 2,000 │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ──────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 10 500 500 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
index cc31c301ba..8f876cc44b 100644
--- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
@@ -117,10 +117,9 @@ exports[` > Conditional Rendering Tests > hides Efficiency secti
│ » API Time: 100ms (100.0%) │
│ » 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 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -162,16 +161,15 @@ exports[` > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ auto Usage │
-│ 65% usage remaining │
+│ 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 Usage resets │
+│ ──────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90% │
+│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30% │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -193,10 +191,9 @@ exports[` > Quota Display > renders quota information for unused
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ Model Usage │
-│ Model Reqs Usage remaining │
-│ ──────────────────────────────────────────────────────────── │
-│ gemini-2.5-flash - 50.0% resets in 2h │
+│ Model Reqs Model usage Usage resets │
+│ ──────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% 2:00 PM (2h) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -218,10 +215,9 @@ exports[` > Quota Display > renders quota information when quota
│ » API Time: 100ms (100.0%) │
│ » 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 Usage resets │
+│ ──────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% 1:30 PM (1h 30m) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -283,11 +279,10 @@ exports[` > renders a table with two models correctly 1`] = `
│ » API Time: 19.5s (100.0%) │
│ » 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. │
│ │
@@ -312,10 +307,9 @@ exports[` > renders all sections when all data is present 1`] =
│ » API Time: 100ms (44.8%) │
│ » 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. │
│ │
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 6b9184b0b4..7c2277d4be 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -98,6 +98,7 @@ export const ToolMessage: React.FC = ({
status={status}
description={description}
emphasis={emphasis}
+ progressMessage={progressMessage}
originalRequestName={originalRequestName}
/>
= ({
description,
status: coreStatus,
emphasis,
+ progressMessage: _progressMessage,
originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts
index 3b4e942357..e949a8575c 100644
--- a/packages/cli/src/ui/hooks/toolMapping.test.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.test.ts
@@ -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,
diff --git a/packages/cli/src/ui/hooks/useToolScheduler.ts b/packages/cli/src/ui/hooks/useToolScheduler.ts
index 496143e590..88e75464fb 100644
--- a/packages/cli/src/ui/hooks/useToolScheduler.ts
+++ b/packages/cli/src/ui/hooks/useToolScheduler.ts
@@ -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);
diff --git a/packages/cli/src/ui/utils/displayUtils.ts b/packages/cli/src/ui/utils/displayUtils.ts
index e311aa4974..6da169788e 100644
--- a/packages/cli/src/ui/utils/displayUtils.ts
+++ b/packages/cli/src/ui/utils/displayUtils.ts
@@ -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;
+};
diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts
index bafc04b555..d9094365fe 100644
--- a/packages/cli/src/ui/utils/formatters.test.ts
+++ b/packages/cli/src/ui/utils/formatters.test.ts
@@ -10,9 +10,45 @@ import {
formatBytes,
formatTimeAgo,
stripReferenceContent,
+ formatResetTime,
} from './formatters.js';
describe('formatters', () => {
+ describe('formatResetTime', () => {
+ const NOW = new Date('2025-01-01T12:00:00Z');
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should format full time correctly', () => {
+ const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
+ const result = formatResetTime(resetTime);
+ expect(result).toMatch(/1 hour 30 minutes at \d{1,2}:\d{2} [AP]M/);
+ });
+
+ it('should format terse time correctly', () => {
+ const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
+ expect(formatResetTime(resetTime, 'terse')).toBe('1h 30m');
+ });
+
+ it('should format column time correctly', () => {
+ const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
+ const result = formatResetTime(resetTime, 'column');
+ expect(result).toMatch(/\d{1,2}:\d{2} [AP]M \(1h 30m\)/);
+ });
+
+ it('should handle zero or negative diff by returning empty string', () => {
+ const resetTime = new Date(NOW.getTime() - 1000).toISOString();
+ expect(formatResetTime(resetTime)).toBe('');
+ });
+ });
+
describe('formatBytes', () => {
it('should format bytes into KB', () => {
expect(formatBytes(12345)).toBe('12.1 KB');
diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts
index 3b4335bac9..5a3f926dbe 100644
--- a/packages/cli/src/ui/utils/formatters.ts
+++ b/packages/cli/src/ui/utils/formatters.ts
@@ -98,26 +98,58 @@ 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,
+ format: 'terse' | 'column' | 'full' = 'full',
+): 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);
+ const isTerse = format === 'terse';
+ const isColumn = format === 'column';
- 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 (isTerse || isColumn) {
+ const hoursStr = hours > 0 ? `${hours}h` : '';
+ const minutesStr = minutes > 0 ? `${minutes}m` : '';
+ const duration =
+ hoursStr && minutesStr
+ ? `${hoursStr} ${minutesStr}`
+ : hoursStr || minutesStr;
+
+ if (isColumn) {
+ const timeStr = new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ minute: 'numeric',
+ }).format(resetDate);
+ return duration ? `${timeStr} (${duration})` : timeStr;
+ }
+
+ return duration;
}
- 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}`;
};
diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts
index 7f8418fbae..baf475701c 100644
--- a/packages/core/src/policy/policy-engine.test.ts
+++ b/packages/core/src/policy/policy-engine.test.ts
@@ -3107,7 +3107,6 @@ describe('PolicyEngine', () => {
expect(checkers[0].checker.name).toBe('c2');
});
});
-
describe('Tool Annotations', () => {
it('should match tools by semantic annotations', async () => {
engine = new PolicyEngine({
@@ -3171,7 +3170,6 @@ describe('PolicyEngine', () => {
).toBe(PolicyDecision.ALLOW);
});
});
-
describe('hook checkers', () => {
it('should add and retrieve hook checkers in priority order', () => {
engine.addHookChecker({
diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts
index 0f7b58c39a..8612a838ca 100644
--- a/packages/core/src/tools/mcp-client.test.ts
+++ b/packages/core/src/tools/mcp-client.test.ts
@@ -2053,7 +2053,6 @@ describe('mcp-client', () => {
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('ext-value');
expect(callArgs.env!['RESOLVED_VAR']).toBe('ext-value');
});
-
it('should expand environment variables in mcpServerConfig.env and not redact them', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')