diff --git a/conductor/plan.md b/conductor/plan.md
new file mode 100644
index 0000000000..1ea91aa312
--- /dev/null
+++ b/conductor/plan.md
@@ -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 `` 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 `` 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.
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index f312ddde4f..600713cb3f 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -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();
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index fa6d16fc72..0557412ff3 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -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,
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index b886dfccf3..8930980f00 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -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',
diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts
index fd60b54354..ecf3d85ccd 100644
--- a/packages/cli/src/ui/commands/compressCommand.test.ts
+++ b/packages/cli/src/ui/commands/compressCommand.test.ts
@@ -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),
diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts
index 6d53667010..29a813e4a7 100644
--- a/packages/cli/src/ui/commands/compressCommand.ts
+++ b/packages/cli/src/ui/commands/compressCommand.ts
@@ -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(),
diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx
index 1cbb29a06c..d8099fc9d5 100644
--- a/packages/cli/src/ui/components/Composer.test.tsx
+++ b/packages/cli/src/ui/components/Composer.test.tsx
@@ -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', () => {
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 042f50776d..890e8b1eb2 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -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 && (
)}
- {miniMode_ShowContext && !showUiDetails && (
-
-
-
- )}
)}
diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx
index 82b439e65f..e05c0bd0ba 100644
--- a/packages/cli/src/ui/components/StatusDisplay.test.tsx
+++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx
@@ -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();
diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
index 2e6b4b75ad..25ca65cf74 100644
--- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
@@ -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)
"
`;
diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
index ac645d312c..064191e018 100644
--- a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
@@ -52,9 +52,9 @@ describe('', () => {
);
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('', () => {
);
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('', () => {
);
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('', () => {
{
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('', () => {
);
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();
});
diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.tsx
index d5f10cc12c..cc800caadf 100644
--- a/packages/cli/src/ui/components/messages/CompressionMessage.tsx
+++ b/packages/cli/src/ui/components/messages/CompressionMessage.tsx
@@ -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 (
-
-
- {isPending ? (
-
- ) : (
- ✦
- )}
-
+
+ {isPending && }
{text}
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 7858ad6ede..1d0711c466 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -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),
);
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 54006d2ab2..c7a83cd3ae 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -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,
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 3760575a6f..1fc928e9f4 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -141,6 +141,7 @@ export interface CompressionProps {
originalTokenCount: number | null;
newTokenCount: number | null;
compressionStatus: CompressionStatus | null;
+ model?: string;
}
/**
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 0740a5c16b..a8a5e42bf3 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -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;
}
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index f357a0decb..6f36fafc22 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -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