mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 21:07:00 -07:00
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:
@@ -0,0 +1,99 @@
|
||||
# Implementation Plan: Hide Context & Compression UI Redesign
|
||||
|
||||
## Background & Motivation
|
||||
|
||||
The current UI for context window management (warnings and compression messages)
|
||||
is too prominent. The context overflow warning is a yellow block of text, the
|
||||
auto-compression message is yellow, and the manual compression message is green
|
||||
with an icon. Context usage percentage also conditionally "bleeds through" in
|
||||
the minimal UI when usage is high.
|
||||
|
||||
The goal is to make context management more seamless: hide the context overflow
|
||||
warning by default in favor of forced auto-compression, make compression
|
||||
messages visually subtle, prevent context percentage from appearing
|
||||
dynamically/automatically (e.g., in the minimal UI), while retaining the ability
|
||||
for users to explicitly enable the context percentage display in their footer if
|
||||
they choose.
|
||||
|
||||
## Scope & Impact
|
||||
|
||||
- Add a new setting `ui.showContextWindowWarning` (default `false`).
|
||||
- Modify `core/client.ts` to force compression on overflow if the warning is
|
||||
disabled. **(Risk: Behavioral change for users who rely on the manual overflow
|
||||
blocker).**
|
||||
- Redesign `CompressionMessage.tsx` to be subtle (gray, no icon, left border).
|
||||
- Update `useGeminiStream.ts` to use the new `CompressionMessage` for
|
||||
auto-compression, and update the overflow warning text to be shorter and
|
||||
percentage-based.
|
||||
- Remove dynamic/automatic context percentage appearances (specifically the
|
||||
bleed-through in `Composer.tsx`), but preserve `ContextUsageDisplay.tsx` for
|
||||
opt-in use in the footer.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### 1. Remove Dynamic Context Percentage Displays
|
||||
|
||||
- **Update `Composer.tsx`**: Remove `showMinimalContextBleedThrough` logic and
|
||||
the `<ContextUsageDisplay />` component from the minimal bleed-through row.
|
||||
The context percentage should never appear automatically based on high usage.
|
||||
- **Retain Footer Option**: Keep `ContextUsageDisplay.tsx`,
|
||||
`hideContextPercentage` in `settingsSchema.ts`, and the `context-used` footer
|
||||
item in `footerItems.ts`. It remains off by default but available for users
|
||||
who explicitly want it in their footer.
|
||||
|
||||
### 2. Configuration Updates
|
||||
|
||||
- **Update `settingsSchema.ts`**: Add
|
||||
`showContextWindowWarning: { type: 'boolean', default: false, ... }` under the
|
||||
`ui` category.
|
||||
- **Update `Config` Interface**: Add `getShowContextWindowWarning(): boolean` to
|
||||
`packages/core/src/config/config.ts` and its implementations.
|
||||
|
||||
### 3. Core Client Update (Auto-Compress on Overflow)
|
||||
|
||||
- **Update `packages/core/src/core/client.ts`**: Locate the token limit check
|
||||
`if (estimatedRequestTokenCount > remainingTokenCount)`. Before yielding the
|
||||
`ContextWindowWillOverflow` event, check
|
||||
`this.config.getShowContextWindowWarning()`. If `false`:
|
||||
1. Call `await this.tryCompressChat(prompt_id, true)` to force a compression
|
||||
attempt.
|
||||
2. If compression succeeds (`CompressionStatus.COMPRESSED`), yield the
|
||||
`ChatCompressed` event and recalculate `remainingTokenCount`.
|
||||
3. If `estimatedRequestTokenCount` now fits within the new
|
||||
`remainingTokenCount`, bypass the overflow yield and continue processing
|
||||
the request.
|
||||
4. If it still overflows after forced compression, yield the
|
||||
`ContextWindowWillOverflow` event so the user is informed that the limit is
|
||||
absolutely reached.
|
||||
|
||||
### 4. Update Overflow Warning Text
|
||||
|
||||
- **Update `useGeminiStream.ts`**: Modify `handleContextWindowWillOverflowEvent`
|
||||
to use a shorter, percentage-based text. _Example:_ "Context window is 100%
|
||||
full. Message size might exceed the limit."
|
||||
|
||||
### 5. Redesign Compression Message
|
||||
|
||||
- **Update `CompressionMessage.tsx`**:
|
||||
- Change colors from `theme.status.success` and `theme.text.accent` to
|
||||
`theme.text.secondary` (subtle gray).
|
||||
- Remove the `✦` icon.
|
||||
- Wrap the text in a `<Box>` with a left border to match
|
||||
`ThinkingMessage.tsx`:
|
||||
`borderStyle="single" borderLeft={true} borderRight={false} borderTop={false} borderBottom={false} borderColor={theme.text.secondary}`
|
||||
|
||||
### 6. Unify Auto-Compression Message
|
||||
|
||||
- **Update `useGeminiStream.ts`**: Modify `handleChatCompressionEvent` so that
|
||||
instead of constructing a yellow `MessageType.INFO` string, it dispatches an
|
||||
item of `type: MessageType.COMPRESSION` (passing the `eventValue` as
|
||||
`compression` props). This ensures automatic compression is rendered by our
|
||||
newly subtle `CompressionMessage.tsx`.
|
||||
|
||||
## PR Notes
|
||||
|
||||
We will explicitly document the behavioral change in the PR description: By
|
||||
default, the CLI will now forcefully attempt to summarize chat history when it
|
||||
overflows, rather than immediately blocking the user with a warning.
|
||||
Additionally, context percentage no longer automatically appears when usage is
|
||||
high; it is strictly an opt-in footer setting.
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -141,6 +141,7 @@ export interface CompressionProps {
|
||||
originalTokenCount: number | null;
|
||||
newTokenCount: number | null;
|
||||
compressionStatus: CompressionStatus | null;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -673,6 +673,7 @@ export interface ConfigParameters {
|
||||
agents?: AgentSettings;
|
||||
}>;
|
||||
enableConseca?: boolean;
|
||||
showContextWindowWarning?: boolean;
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
@@ -708,6 +709,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly question: string | undefined;
|
||||
private readonly worktreeSettings: WorktreeSettings | undefined;
|
||||
readonly enableConseca: boolean;
|
||||
private readonly showContextWindowWarning: boolean;
|
||||
|
||||
private readonly coreTools: string[] | undefined;
|
||||
private readonly mainAgentTools: string[] | undefined;
|
||||
@@ -1146,6 +1148,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.fileExclusions = new FileExclusions(this);
|
||||
this.eventEmitter = params.eventEmitter;
|
||||
this.enableConseca = params.enableConseca ?? false;
|
||||
this.showContextWindowWarning = params.showContextWindowWarning ?? false;
|
||||
|
||||
// Initialize Safety Infrastructure
|
||||
const contextBuilder = new ContextBuilder(this);
|
||||
@@ -2053,6 +2056,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.mcpEnabled;
|
||||
}
|
||||
|
||||
getShowContextWindowWarning(): boolean {
|
||||
return this.showContextWindowWarning;
|
||||
}
|
||||
|
||||
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
||||
return this.mcpEnablementCallbacks;
|
||||
}
|
||||
|
||||
@@ -614,7 +614,7 @@ export class GeminiClient {
|
||||
yield { type: GeminiEventType.ChatCompressed, value: compressed };
|
||||
}
|
||||
|
||||
const remainingTokenCount =
|
||||
let remainingTokenCount =
|
||||
tokenLimit(modelForLimitCheck) - this.getChat().getLastPromptTokenCount();
|
||||
|
||||
await this.tryMaskToolOutputs(this.getHistory());
|
||||
@@ -628,11 +628,28 @@ export class GeminiClient {
|
||||
);
|
||||
|
||||
if (estimatedRequestTokenCount > remainingTokenCount) {
|
||||
yield {
|
||||
type: GeminiEventType.ContextWindowWillOverflow,
|
||||
value: { estimatedRequestTokenCount, remainingTokenCount },
|
||||
};
|
||||
return turn;
|
||||
if (!this.config.getShowContextWindowWarning()) {
|
||||
const forcedCompressed = await this.tryCompressChat(prompt_id, true);
|
||||
if (
|
||||
forcedCompressed.compressionStatus === CompressionStatus.COMPRESSED
|
||||
) {
|
||||
yield {
|
||||
type: GeminiEventType.ChatCompressed,
|
||||
value: forcedCompressed,
|
||||
};
|
||||
remainingTokenCount =
|
||||
tokenLimit(modelForLimitCheck) -
|
||||
this.getChat().getLastPromptTokenCount();
|
||||
}
|
||||
}
|
||||
|
||||
if (estimatedRequestTokenCount > remainingTokenCount) {
|
||||
yield {
|
||||
type: GeminiEventType.ContextWindowWillOverflow,
|
||||
value: { estimatedRequestTokenCount, remainingTokenCount },
|
||||
};
|
||||
return turn;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent context updates from being sent while a tool call is
|
||||
|
||||
Reference in New Issue
Block a user