From eb3e540f3f11270e7c29264bdef25d02cfc78dc5 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Tue, 17 Mar 2026 23:00:00 -0700 Subject: [PATCH] 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. --- conductor/plan.md | 99 +++++++++++++++++++ packages/cli/src/config/config.test.ts | 46 +++++++++ packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 12 ++- .../src/ui/commands/compressCommand.test.ts | 3 + .../cli/src/ui/commands/compressCommand.ts | 2 + .../cli/src/ui/components/Composer.test.tsx | 37 +------ packages/cli/src/ui/components/Composer.tsx | 24 +---- .../src/ui/components/StatusDisplay.test.tsx | 28 ++++-- .../__snapshots__/StatusDisplay.test.tsx.snap | 7 +- .../messages/CompressionMessage.test.tsx | 22 +++-- .../messages/CompressionMessage.tsx | 38 +++---- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 52 +++++++--- packages/cli/src/ui/hooks/useGeminiStream.ts | 44 ++++----- packages/cli/src/ui/types.ts | 1 + packages/core/src/config/config.ts | 7 ++ packages/core/src/core/client.ts | 29 ++++-- 17 files changed, 305 insertions(+), 147 deletions(-) create mode 100644 conductor/plan.md 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