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>

View File

@@ -48,3 +48,9 @@ export const ACTIVE_SHELL_MAX_LINES = 15;
// Max lines to preserve in history for completed shell commands
export const COMPLETED_SHELL_MAX_LINES = 15;
/** Minimum terminal width required to show the full context used label */
export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
/** Default context usage fraction at which to trigger compression */
export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;

View File

@@ -30,7 +30,7 @@ export const INFORMATIVE_TIPS = [
'Choose a specific Gemini model for conversations (/settings)…',
'Limit the number of turns in your session history (/settings)…',
'Automatically summarize large tool outputs to save tokens (settings.json)…',
'Control when chat history gets compressed based on token usage (settings.json)…',
'Control when chat history gets compressed based on context compression threshold (settings.json)…',
'Define custom context file names, like CONTEXT.md (settings.json)…',
'Set max directories to scan for context files (/settings)…',
'Expand your workspace with additional directories (/directory)…',

View File

@@ -50,6 +50,7 @@ import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { theme } from '../semantic-colors.js';
// --- MOCKS ---
const mockSendMessageStream = vi
@@ -2300,14 +2301,14 @@ describe('useGeminiStream', () => {
requestTokens: 20,
remainingTokens: 80,
expectedMessage:
'Sending this message (20 tokens) might exceed the remaining context window limit (80 tokens).',
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
},
{
name: 'with suggestion when remaining tokens are < 75% of limit',
requestTokens: 30,
remainingTokens: 70,
expectedMessage:
'Sending this message (30 tokens) might exceed the remaining context window limit (70 tokens). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
'Sending this message (30 tokens) might exceed the context window limit (70 tokens left). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
},
])(
'should add message $name',
@@ -2388,6 +2389,43 @@ describe('useGeminiStream', () => {
});
});
it('should add informational messages when ChatCompressed event is received', async () => {
vi.mocked(tokenLimit).mockReturnValue(10000);
// Setup mock to return a stream with ChatCompressed event
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ChatCompressed,
value: {
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: 'compressed',
},
};
})(),
);
const { result } = renderHookWithDefaults();
// Submit a query
await act(async () => {
await result.current.submitQuery('Test compression');
});
// Check that the succinct info message was added
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Context compressed from 10% to 5%.',
secondaryText: 'Change threshold in /settings.',
color: theme.status.warning,
}),
expect.any(Number),
);
});
});
it.each([
{
reason: 'STOP',

View File

@@ -1065,16 +1065,27 @@ export const useGeminiStream = (
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
return addItem({
type: 'info',
text:
`IMPORTANT: This conversation exceeded the compress threshold. ` +
`A compressed context will be sent for future messages (compressed from: ` +
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`,
});
const limit = tokenLimit(config.getModel());
const originalPercentage = Math.round(
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
);
const newPercentage = Math.round(
((eventValue?.newTokenCount ?? 0) / limit) * 100,
);
addItem(
{
type: MessageType.INFO,
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
secondaryText: `Change threshold in /settings.`,
color: theme.status.warning,
marginBottom: 1,
} as HistoryItemInfo,
userMessageTimestamp,
);
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
);
const handleMaxSessionTurnsEvent = useCallback(
@@ -1094,12 +1105,12 @@ export const useGeminiStream = (
const limit = tokenLimit(config.getModel());
const isLessThan75Percent =
const isMoreThan25PercentUsed =
limit > 0 && remainingTokenCount < limit * 0.75;
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the remaining context window limit (${remainingTokenCount} tokens).`;
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
if (isLessThan75Percent) {
if (isMoreThan25PercentUsed) {
text +=
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
}

View File

@@ -151,6 +151,7 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
secondaryText?: string;
icon?: string;
color?: string;
marginBottom?: number;