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

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

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