feat(ui): redesign context and compression UI for a more seamless experience

- Added ui.showContextWindowWarning setting (default false).
- Implemented forced auto-compression on context overflow when warning is disabled.
- Redesigned compression messages to be subtle (gray, no icon, left border).
- Removed automatic context usage percentage from the minimal/focus UI.
- Changed ui.hideContextSummary default to true.
- Updated and verified all relevant tests.

Note: Includes a behavioral change where the CLI now attempts to force-compress
history when the context window is full rather than blocking by default.
This commit is contained in:
Keith Guerin
2026-03-17 23:00:00 -07:00
parent 37c8de3c06
commit eb3e540f3f
17 changed files with 305 additions and 147 deletions
+46
View File
@@ -2103,6 +2103,52 @@ describe('loadCliConfig compressionThreshold', () => {
});
});
describe('loadCliConfig showContextWindowWarning', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should pass showContextWindowWarning from settings to config (true)', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
ui: {
showContextWindowWarning: true,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getShowContextWindowWarning()).toBe(true);
});
it('should pass showContextWindowWarning from settings to config (false)', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
ui: {
showContextWindowWarning: false,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getShowContextWindowWarning()).toBe(false);
});
it('should default to false if not in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getShowContextWindowWarning()).toBe(false);
});
});
describe('loadCliConfig useRipgrep', () => {
beforeEach(() => {
vi.resetAllMocks();
+1
View File
@@ -955,6 +955,7 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
showContextWindowWarning: settings.ui?.showContextWindowWarning,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
+11 -1
View File
@@ -575,11 +575,21 @@ const SETTINGS_SCHEMA = {
label: 'Hide Context Summary',
category: 'UI',
requiresRestart: false,
default: false,
default: true,
description:
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
showInDialog: true,
},
showContextWindowWarning: {
type: 'boolean',
label: 'Show Context Window Warning',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Show a warning message when the context window limit is nearly reached.',
showInDialog: true,
},
footer: {
type: 'object',
label: 'Footer',
@@ -39,6 +39,7 @@ describe('compressCommand', () => {
originalTokenCount: null,
newTokenCount: null,
compressionStatus: null,
model: 'test-model',
},
};
await compressCommand.action!(context, '');
@@ -70,6 +71,7 @@ describe('compressCommand', () => {
compressionStatus: null,
originalTokenCount: null,
newTokenCount: null,
model: 'test-model',
},
});
@@ -86,6 +88,7 @@ describe('compressCommand', () => {
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 200,
newTokenCount: 100,
model: 'test-model',
},
},
expect.any(Number),
@@ -33,6 +33,7 @@ export const compressCommand: SlashCommand = {
originalTokenCount: null,
newTokenCount: null,
compressionStatus: null,
model: context.services.config?.getModel(),
},
};
@@ -53,6 +54,7 @@ export const compressCommand: SlashCommand = {
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
model: context.services.config?.getModel(),
},
} as HistoryItemCompression,
Date.now(),
@@ -17,11 +17,7 @@ import {
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
import {
ApprovalMode,
tokenLimit,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { ApprovalMode, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
@@ -733,37 +729,6 @@ describe('Composer', () => {
expect(output).toContain('Press Esc again to rewind.');
expect(output).not.toContain('ContextSummaryDisplay');
});
it('shows context usage bleed-through when over 60%', async () => {
const model = 'gemini-2.5-pro';
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
currentModel: model,
sessionStats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metrics: {} as any,
lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7),
promptCount: 0,
},
});
const settings = createMockSettings({
ui: {
footer: { hideContextPercentage: false },
},
});
const { lastFrame } = await renderComposer(uiState, settings);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode
expect(lastFrame()).toContain('StatusDisplay');
expect(lastFrame()).toContain('70% used');
});
});
describe('Error Details Display', () => {
+2 -22
View File
@@ -20,13 +20,11 @@ import { useVimMode } from '../contexts/VimModeContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { isContextUsageHigh } from '../utils/contextUsage.js';
import { theme } from '../semantic-colors.js';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { StatusDisplay } from './StatusDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
@@ -257,11 +255,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
const miniMode_ShowTip = showTipLine;
const miniMode_ShowContext = isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
uiState.currentModel,
settings.merged.model?.compressionThreshold,
);
// Composite Mini Mode Triggers
const showRow1_MiniMode =
@@ -270,7 +263,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
miniMode_ShowShortcuts ||
miniMode_ShowTip;
const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
const showRow2_MiniMode = miniMode_ShowApprovalMode;
// Final Display Rules (Stable Footer Architecture)
const showRow1 = showUiDetails || showRow1_MiniMode;
@@ -488,22 +481,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
alignItems="center"
marginLeft={isNarrow ? 1 : 0}
>
{(showUiDetails || miniMode_ShowContext) && (
{showUiDetails && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{miniMode_ShowContext && !showUiDetails && (
<Box marginLeft={1}>
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={uiState.terminalWidth}
/>
</Box>
)}
</Box>
</Box>
)}
@@ -72,7 +72,9 @@ const createMockConfig = (overrides = {}) => ({
const renderStatusDisplay = async (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
uiState: UIState = createMockUIState(),
settings = createMockSettings(),
settings = createMockSettings({
ui: { hideContextSummary: true },
}),
config = createMockConfig(),
) => {
const result = await render(
@@ -97,16 +99,21 @@ describe('StatusDisplay', () => {
vi.restoreAllMocks();
});
it('renders nothing by default if context summary is hidden via props', async () => {
const { lastFrame, unmount } = await renderStatusDisplay({
hideContextSummary: true,
});
it('renders nothing by default', async () => {
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders ContextSummaryDisplay by default', async () => {
const { lastFrame, unmount } = await renderStatusDisplay();
it('renders ContextSummaryDisplay when hideContextSummary is false', async () => {
const settings = createMockSettings({
ui: { hideContextSummary: false },
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
undefined,
settings,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -135,6 +142,7 @@ describe('StatusDisplay', () => {
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const settings = createMockSettings({
ui: { hideContextSummary: true },
hooksConfig: { notifications: false },
});
const { lastFrame, unmount } = await renderStatusDisplay(
@@ -142,7 +150,7 @@ describe('StatusDisplay', () => {
uiState,
settings,
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -163,9 +171,13 @@ describe('StatusDisplay', () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
});
const settings = createMockSettings({
ui: { hideContextSummary: false },
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
settings,
);
expect(lastFrame()).toContain('Shells: 3');
unmount();
@@ -1,11 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `
"Mock Context Summary Display (Skills: 2, Shells: 0)
"
`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `
exports[`StatusDisplay > renders ContextSummaryDisplay when hideContextSummary is false 1`] = `
"Mock Context Summary Display (Skills: 2, Shells: 0)
"
`;
@@ -52,9 +52,9 @@ describe('<CompressionMessage />', () => {
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Chat history compressed from 100 to 50 tokens.',
'Context compressed from 100 tokens to 50 tokens. Change threshold in /settings.',
);
unmount();
});
@@ -76,9 +76,9 @@ describe('<CompressionMessage />', () => {
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
`compressed from ${original} to ${newTokens} tokens`,
`Context compressed from ${original.toLocaleString()} tokens to ${newTokens.toLocaleString()} tokens. Change threshold in /settings.`,
);
expect(output).not.toContain('Skipping compression');
expect(output).not.toContain('did not reduce size');
@@ -101,7 +101,7 @@ describe('<CompressionMessage />', () => {
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Compression was not beneficial for this history size.',
);
@@ -133,17 +133,19 @@ describe('<CompressionMessage />', () => {
{
original: 200,
newTokens: 80,
expected: 'compressed from 200 to 80 tokens',
expected:
'Context compressed from 200 tokens to 80 tokens. Change threshold in /settings.',
},
{
original: 500,
newTokens: 150,
expected: 'compressed from 500 to 150 tokens',
expected:
'Context compressed from 500 tokens to 150 tokens. Change threshold in /settings.',
},
{
original: 1500,
newTokens: 400,
expected: 'compressed from 1500 to 400 tokens',
expected: `Context compressed from ${(1500).toLocaleString()} tokens to 400 tokens. Change threshold in /settings.`,
},
])(
'displays correct compression statistics (from $original to $newTokens)',
@@ -229,9 +231,9 @@ describe('<CompressionMessage />', () => {
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Chat history compression failed: the model returned an empty summary.',
'Chat history compression failed: empty summary.',
);
unmount();
});
@@ -9,7 +9,7 @@ import type { CompressionProps } from '../../types.js';
import { CliSpinner } from '../CliSpinner.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { CompressionStatus } from '@google/gemini-cli-core';
import { CompressionStatus, tokenLimit } from '@google/gemini-cli-core';
export interface CompressionDisplayProps {
compression: CompressionProps;
@@ -22,8 +22,13 @@ export interface CompressionDisplayProps {
export function CompressionMessage({
compression,
}: CompressionDisplayProps): React.JSX.Element {
const { isPending, originalTokenCount, newTokenCount, compressionStatus } =
compression;
const {
isPending,
originalTokenCount,
newTokenCount,
compressionStatus,
model,
} = compression;
const originalTokens = originalTokenCount ?? 0;
const newTokens = newTokenCount ?? 0;
@@ -33,9 +38,15 @@ export function CompressionMessage({
return 'Compressing chat history';
}
const limit = model ? tokenLimit(model) : 0;
const formatPercent = (tokens: number) =>
limit > 0
? `${Math.round((tokens / limit) * 100)}% (${tokens.toLocaleString()} tokens)`
: `${tokens.toLocaleString()} tokens`;
switch (compressionStatus) {
case CompressionStatus.COMPRESSED:
return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`;
return `Context compressed from ${formatPercent(originalTokens)} to ${formatPercent(newTokens)}. Change threshold in /settings.`;
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
// For smaller histories (< 50k tokens), compression overhead likely exceeds benefits
if (originalTokens < 50000) {
@@ -43,11 +54,11 @@ export function CompressionMessage({
}
// For larger histories where compression should work but didn't,
// this suggests an issue with the compression process itself
return 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.';
return 'Chat history compression did not reduce size.';
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
return 'Could not compress chat history due to a token counting error.';
case CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY:
return 'Chat history compression failed: the model returned an empty summary.';
return 'Chat history compression failed: empty summary.';
case CompressionStatus.NOOP:
return 'Nothing to compress.';
default:
@@ -58,20 +69,13 @@ export function CompressionMessage({
const text = getCompressionText();
return (
<Box flexDirection="row">
<Box marginRight={1}>
{isPending ? (
<CliSpinner type="dots" />
) : (
<Text color={theme.text.accent}></Text>
)}
</Box>
<Box flexDirection="row" paddingLeft={1} marginBottom={1}>
<Box marginRight={1}>{isPending && <CliSpinner type="dots" />}</Box>
<Box>
<Text
color={
compression.isPending ? theme.text.accent : theme.status.success
}
color={theme.text.secondary}
aria-label={SCREEN_READER_MODEL_PREFIX}
italic
>
{text}
</Text>
@@ -64,7 +64,6 @@ 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
@@ -332,6 +331,7 @@ describe('useGeminiStream', () => {
})),
getIdeMode: vi.fn(() => false),
getEnableHooks: vi.fn(() => false),
getShowContextWindowWarning: vi.fn(() => false),
} as unknown as Config;
beforeEach(() => {
@@ -2474,22 +2474,30 @@ describe('useGeminiStream', () => {
it.each([
{
name: 'without suggestion when remaining tokens are > 75% of limit',
name: 'NOT add a message when showContextWindowWarning is false',
requestTokens: 20,
remainingTokens: 80,
expectedMessage:
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
shouldShow: false,
},
{
name: 'with suggestion when remaining tokens are < 75% of limit',
name: 'add a message when showContextWindowWarning is true',
requestTokens: 30,
remainingTokens: 70,
shouldShow: true,
expectedMessage:
'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.',
'Context window is 30% full. Message size (30 tokens) might exceed the limit.\nPlease try reducing the size of your message or use the /compress command to compress the chat history.',
},
])(
'should add message $name',
async ({ requestTokens, remainingTokens, expectedMessage }) => {
'should $name',
async ({
requestTokens,
remainingTokens,
shouldShow,
expectedMessage,
}) => {
vi.mocked(mockConfig.getShowContextWindowWarning).mockReturnValue(
shouldShow,
);
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
@@ -2509,10 +2517,18 @@ describe('useGeminiStream', () => {
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith({
type: 'info',
text: expectedMessage,
});
if (shouldShow) {
expect(mockAddItem).toHaveBeenCalledWith({
type: 'info',
text: expectedMessage,
});
} else {
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
}),
);
}
});
},
);
@@ -2593,10 +2609,14 @@ describe('useGeminiStream', () => {
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,
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: 'compressed',
model: 'gemini-2.5-pro',
},
}),
expect.any(Number),
);
+19 -25
View File
@@ -61,7 +61,6 @@ import type {
HistoryItemThinking,
HistoryItemWithoutId,
HistoryItemToolGroup,
HistoryItemInfo,
IndividualToolCallDisplay,
SlashCommandProcessorResult,
HistoryItemModel,
@@ -1144,22 +1143,18 @@ export const useGeminiStream = (
setPendingHistoryItem(null);
}
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,
type: 'compression',
compression: {
isPending: false,
originalTokenCount: eventValue?.originalTokenCount ?? null,
newTokenCount: eventValue?.newTokenCount ?? null,
compressionStatus: eventValue?.compressionStatus ?? null,
model: config.getModel(),
},
timestamp: new Date(userMessageTimestamp),
} as HistoryItemWithoutId,
userMessageTimestamp,
);
},
@@ -1181,18 +1176,17 @@ export const useGeminiStream = (
(estimatedRequestTokenCount: number, remainingTokenCount: number) => {
onCancelSubmit(true);
const limit = tokenLimit(config.getModel());
const isMoreThan25PercentUsed =
limit > 0 && remainingTokenCount < limit * 0.75;
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
if (isMoreThan25PercentUsed) {
text +=
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
if (!config.getShowContextWindowWarning()) {
return;
}
const limit = tokenLimit(config.getModel());
const usedPercentage = Math.round(
((limit - remainingTokenCount) / limit) * 100,
);
const text = `Context window is ${usedPercentage}% full. Message size (${estimatedRequestTokenCount.toLocaleString()} tokens) might exceed the limit.\nPlease try reducing the size of your message or use the /compress command to compress the chat history.`;
addItem({
type: 'info',
text,
+1
View File
@@ -141,6 +141,7 @@ export interface CompressionProps {
originalTokenCount: number | null;
newTokenCount: number | null;
compressionStatus: CompressionStatus | null;
model?: string;
}
/**