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
@@ -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>