feat(cli): invert context window display to show usage (#20071)

Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
Keith Guerin
2026-03-03 01:22:29 -08:00
committed by GitHub
parent 208291f391
commit 1e2afbb514
19 changed files with 235 additions and 68 deletions

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { describe, it, expect, vi } from 'vitest';
@@ -17,18 +17,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
vi.mock('../../config/settings.js', () => ({
DEFAULT_MODEL_CONFIGS: {},
LoadedSettings: class {
constructor() {
// this.merged = {};
}
},
}));
describe('ContextUsageDisplay', () => {
it('renders correct percentage left', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
it('renders correct percentage used', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={5000}
model="gemini-pro"
@@ -37,27 +28,56 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('50% context left');
expect(output).toContain('50% context used');
unmount();
});
it('renders short label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
it('renders correctly when usage is 0%', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={0}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context used');
unmount();
});
it('renders abbreviated label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={2000}
model="gemini-pro"
terminalWidth={80}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80%');
expect(output).not.toContain('context left');
expect(output).toContain('20%');
expect(output).not.toContain('context used');
unmount();
});
it('renders 0% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
it('renders 80% correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={8000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80% context used');
unmount();
});
it('renders 100% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={10000}
model="gemini-pro"
@@ -66,7 +86,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context left');
expect(output).toContain('100% context used');
unmount();
});
});

View File

@@ -7,6 +7,11 @@
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { getContextUsagePercentage } from '../utils/contextUsage.js';
import { useSettings } from '../contexts/SettingsContext.js';
import {
MIN_TERMINAL_WIDTH_FOR_FULL_LABEL,
DEFAULT_COMPRESSION_THRESHOLD,
} from '../constants.js';
export const ContextUsageDisplay = ({
promptTokenCount,
@@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({
terminalWidth,
}: {
promptTokenCount: number;
model: string;
model: string | undefined;
terminalWidth: number;
}) => {
const settings = useSettings();
const percentage = getContextUsagePercentage(promptTokenCount, model);
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
const percentageUsed = (percentage * 100).toFixed(0);
const label = terminalWidth < 100 ? '%' : '% context left';
const threshold =
settings.merged.model?.compressionThreshold ??
DEFAULT_COMPRESSION_THRESHOLD;
let textColor = theme.text.secondary;
if (percentage >= 1.0) {
textColor = theme.status.error;
} else if (percentage >= threshold) {
textColor = theme.status.warning;
}
const label =
terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used';
return (
<Text color={theme.text.secondary}>
{percentageLeft}
<Text color={textColor}>
{percentageUsed}
{label}
</Text>
);

View File

@@ -174,7 +174,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
expect(lastFrame()).toMatch(/\d+% context used/);
unmount();
});
@@ -229,7 +229,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Usage remaining');
expect(lastFrame()).not.toContain('used');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -262,7 +262,7 @@ describe('<Footer />', () => {
unmount();
});
it('displays the model name and abbreviated context percentage', async () => {
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
@@ -280,6 +280,7 @@ describe('<Footer />', () => {
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
expect(lastFrame()).not.toContain('context used');
unmount();
});
@@ -477,7 +478,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% context left/);
expect(lastFrame()).not.toMatch(/\d+% context used/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
@@ -497,7 +498,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
expect(lastFrame()).toMatch(/\d+% context used/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {

View File

@@ -99,6 +99,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'info' && (
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}

View File

@@ -89,11 +89,12 @@ const renderStatusDisplay = async (
};
describe('StatusDisplay', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
});
afterEach(() => {
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -112,7 +113,7 @@ describe('StatusDisplay', () => {
});
it('renders system md indicator if env var is set', async () => {
process.env['GEMINI_SYSTEM_MD'] = 'true';
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();

View File

@@ -11,12 +11,12 @@ exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
" ...s/to/make/it/long no sandbox /model gemini-pro 100%
" ...s/to/make/it/long no sandbox /model gemini-pro 0%
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 0% context used
"
`;

View File

@@ -11,6 +11,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
secondaryText?: string;
icon?: string;
color?: string;
marginBottom?: number;
@@ -18,6 +19,7 @@ interface InfoMessageProps {
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
secondaryText,
icon,
color,
marginBottom,
@@ -35,6 +37,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
{text.split('\n').map((line, index) => (
<Text wrap="wrap" key={index}>
<RenderInline text={line} defaultColor={color} />
{index === text.split('\n').length - 1 && secondaryText && (
<Text color={theme.text.secondary}> {secondaryText}</Text>
)}
</Text>
))}
</Box>