Compare commits

...

23 Commits

Author SHA1 Message Date
Yuna Seol bd5d18c210 chore: remove unnecessary empty ignore files and plan.md 2026-04-10 13:42:51 -04:00
Yuna Seol 4f9293e8e9 fix(ui): always show context window warning on terminal overflow
This removes the early return in the `handleContextWindowWillOverflowEvent` hook. If the context window truly overflows and the message is blocked, we must tell the user why, regardless of their proactive warning settings. This prevents a silent block where the CLI appears to just swallow the user's input.
2026-04-10 13:32:18 -04:00
Keith Guerin 9b51ccf82a fix(cli): resolve CI test failures and stabilize mocks 2026-03-24 23:15:54 -07:00
Keith Guerin c47bca8374 fix(cli): address CI build and lint failures by using stable geminiClient reference 2026-03-24 17:20:13 -07:00
Keith Guerin 2c83cf9603 fix(cli): address CI build and lint failures 2026-03-24 15:53:55 -07:00
Keith Guerin 84432ddaa8 fix(ci): address final test and build issues after rebase 2026-03-24 14:02:02 -07:00
Keith Guerin 05d29d68aa fix(ci): address final test and build issues after rebase 2026-03-23 23:13:30 -07:00
Keith Guerin 99ddc299ad feat(ui): refine context messaging with window terminology and large prompt override 2026-03-23 22:24:11 -07:00
Keith Guerin cd4c8821cb fix(ci): address lint, build and test failures after merge 2026-03-23 22:24:11 -07:00
Keith Guerin 8d0c2a3acb fix(cli): resolve CI build and lint failures by fixing TS types and formatting 2026-03-23 22:24:11 -07:00
Keith Guerin 184a9abb59 docs(settings): regenerate schema and docs, and fix lint issue 2026-03-23 22:24:11 -07:00
Keith Guerin 4b2c117af3 fix(cli): resolve merge conflicts and align with async test helpers 2026-03-23 22:24:11 -07:00
Keith Guerin 511cbc54c9 fix(cli): resolve merge conflicts and align with async test helpers 2026-03-23 22:23:26 -07:00
Keith Guerin 692c34c834 fix(cli): fix missing awaits in tests after merge 2026-03-23 22:22:02 -07:00
Keith Guerin 3be1de915f fix(cli): resolve compilation errors and update test mocks 2026-03-23 22:22:02 -07:00
Keith Guerin 16485afa96 feat(ui): finalize compression UX with threshold hint and large prompt override 2026-03-23 22:21:11 -07:00
Keith Guerin 375afc7199 feat(ui): restore threshold hint and thin arrow to compression message 2026-03-23 22:21:11 -07:00
Keith Guerin fd8d218911 docs(ui): refine documentation and test eslint-disable for context UI changes 2026-03-23 22:20:51 -07:00
Keith Guerin 1de0367faa docs(ui): update settings.md and settingsSchema descriptions for context UI changes 2026-03-23 22:20:15 -07:00
Keith Guerin b6231f561b docs(settings): rename context compression setting to use 'Messages' for clarity 2026-03-23 22:19:16 -07:00
Keith Guerin 252dbeb39a feat(ui): add showContextCompression setting and simplify message wording 2026-03-23 22:19:16 -07:00
Keith Guerin 39fb7b11a8 feat(ui): finalize context and compression messaging with symbolic arrow and refined wording 2026-03-23 22:17:47 -07:00
Keith Guerin eb3e540f3f 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.
2026-03-23 22:03:31 -07:00
22 changed files with 621 additions and 609 deletions
+34 -32
View File
@@ -46,38 +46,40 @@ they appear in the UI.
### UI
| UI Label | Setting | Description | Default |
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
| UI Label | Setting | Description | Default |
| ------------------------------------ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `true` |
| Show Context Window Warning | `ui.showContextWindowWarning` | Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached. | `false` |
| Show Context Compression Messages | `ui.showContextCompression` | Show a message in the chat history when it is compressed. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
### IDE
+10
View File
@@ -263,6 +263,16 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.hideContextSummary`** (boolean):
- **Description:** Hide the context summary (GEMINI.md, MCP servers) above the
input.
- **Default:** `true`
- **`ui.showContextWindowWarning`** (boolean):
- **Description:** Show a warning message when the context window limit is
nearly reached. If disabled, the CLI will attempt to automatically compress
the history when the limit is reached.
- **Default:** `false`
- **`ui.showContextCompression`** (boolean):
- **Description:** Show a message in the chat history when it is compressed.
- **Default:** `false`
- **`ui.footer.items`** (array):
@@ -48,7 +48,8 @@ describe('Interactive Mode', () => {
true,
);
await run.expectText('Chat history compressed', 5000);
await run.expectText('Context compressed', 5000);
await run.expectText('Adjust threshold', 5000);
});
// TODO: Context compression is broken and doesn't include the system
+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();
+2
View File
@@ -955,6 +955,8 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
showContextWindowWarning: settings.ui?.showContextWindowWarning,
showContextCompression: settings.ui?.showContextCompression,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
+21 -1
View File
@@ -575,11 +575,31 @@ 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. If disabled, the CLI will attempt to automatically compress the history when the limit is reached.',
showInDialog: true,
},
showContextCompression: {
type: 'boolean',
label: 'Show Context Compression Messages',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Show a message in the chat history when it is compressed.',
showInDialog: true,
},
footer: {
type: 'object',
label: 'Footer',
@@ -4,28 +4,42 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
CompressionStatus,
type ChatCompressionInfo,
type GeminiClient,
} from '@google/gemini-cli-core';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import * as Core from '@google/gemini-cli-core';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { compressCommand } from './compressCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = (await importOriginal()) as any;
return {
...actual,
tokenLimit: vi.fn(),
};
});
describe('compressCommand', () => {
let context: ReturnType<typeof createMockCommandContext>;
let mockTryCompressChat: ReturnType<typeof vi.fn>;
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
mockTryCompressChat = vi.fn();
vi.mocked(Core.tokenLimit).mockReturnValue(1000);
context = createMockCommandContext({
services: {
agentContext: {
config: {
getModel: () => 'test-model',
getContextWindowCompressionThreshold: () => 0.2,
},
geminiClient: {
tryCompressChat: mockTryCompressChat,
} as unknown as GeminiClient,
} as unknown as Core.GeminiClient,
},
},
});
@@ -36,9 +50,10 @@ describe('compressCommand', () => {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
compressionStatus: null,
isManual: true,
},
};
await compressCommand.action!(context, '');
@@ -54,9 +69,9 @@ describe('compressCommand', () => {
});
it('should set pending item, call tryCompressChat, and add result on success', async () => {
const compressedResult: ChatCompressionInfo = {
const compressedResult: Core.ChatCompressionInfo = {
originalTokenCount: 200,
compressionStatus: CompressionStatus.COMPRESSED,
compressionStatus: Core.CompressionStatus.COMPRESSED,
newTokenCount: 100,
};
mockTryCompressChat.mockResolvedValue(compressedResult);
@@ -68,8 +83,9 @@ describe('compressCommand', () => {
compression: {
isPending: true,
compressionStatus: null,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
isManual: true,
},
});
@@ -83,9 +99,11 @@ describe('compressCommand', () => {
type: MessageType.COMPRESSION,
compression: {
isPending: false,
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 200,
newTokenCount: 100,
compressionStatus: Core.CompressionStatus.COMPRESSED,
beforePercentage: 20,
afterPercentage: 10,
isManual: true,
thresholdPercentage: 20,
},
},
expect.any(Number),
+40 -11
View File
@@ -6,6 +6,7 @@
import { MessageType, type HistoryItemCompression } from '../types.js';
import { CommandKind, type SlashCommand } from './types.js';
import { tokenLimit, type CompressionStatus } from '@google/gemini-cli-core';
export const compressCommand: SlashCommand = {
name: 'compress',
@@ -14,7 +15,21 @@ export const compressCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const { ui } = context;
const { ui, services } = context;
const agentContext = services.agentContext;
if (!agentContext) {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Agent context not found.',
},
Date.now(),
);
return;
}
const config = agentContext.config;
if (ui.pendingItem) {
ui.addItem(
{
@@ -30,29 +45,43 @@ export const compressCommand: SlashCommand = {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
compressionStatus: null,
isManual: true,
},
};
try {
ui.setPendingItem(pendingMessage);
const promptId = `compress-${Date.now()}`;
const compressed =
await context.services.agentContext?.geminiClient?.tryCompressChat(
promptId,
true,
);
const compressed = await agentContext.geminiClient.tryCompressChat(
promptId,
true,
);
if (compressed) {
const limit = tokenLimit(config.getModel());
const threshold = config.getContextWindowCompressionThreshold();
const beforePercentage = Math.round(
(compressed.originalTokenCount / limit) * 100,
);
const afterPercentage = Math.round(
(compressed.newTokenCount / limit) * 100,
);
ui.addItem(
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
beforePercentage,
afterPercentage,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
compressionStatus: Number(
compressed.compressionStatus,
) as unknown as CompressionStatus,
isManual: true,
thresholdPercentage: Math.round(threshold * 100),
},
} 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();
});
@@ -118,34 +125,6 @@ describe('StatusDisplay', () => {
unmount();
});
it('renders HookStatusDisplay when hooks are active', async () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does NOT render HookStatusDisplay if notifications are disabled in settings', async () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const settings = createMockSettings({
hooksConfig: { notifications: false },
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
settings,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides ContextSummaryDisplay if configured in settings', async () => {
const settings = createMockSettings({
ui: { hideContextSummary: true },
@@ -163,9 +142,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)
"
`;
@@ -11,17 +11,22 @@ import {
} from './CompressionMessage.js';
import { CompressionStatus } from '@google/gemini-cli-core';
import { type CompressionProps } from '../../types.js';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
describe('<CompressionMessage />', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const createCompressionProps = (
overrides: Partial<CompressionProps> = {},
): CompressionDisplayProps => ({
compression: {
isPending: false,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
compressionStatus: CompressionStatus.COMPRESSED,
isManual: true,
...overrides,
},
});
@@ -29,9 +34,10 @@ describe('<CompressionMessage />', () => {
describe('pending state', () => {
it('renders pending message when compression is in progress', async () => {
const props = createCompressionProps({ isPending: true });
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Compressing chat history');
@@ -43,56 +49,28 @@ describe('<CompressionMessage />', () => {
it('renders success message when tokens are reduced', async () => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: 100,
newTokenCount: 50,
beforePercentage: 22,
afterPercentage: 6,
compressionStatus: CompressionStatus.COMPRESSED,
thresholdPercentage: 50,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Chat history compressed from 100 to 50 tokens.',
'Context compressed (22% → 6%). Adjust threshold (50%) in /settings.',
);
unmount();
});
it.each([
{ original: 50000, newTokens: 25000 }, // Large compression
{ original: 700000, newTokens: 350000 }, // Very large compression
])(
'renders success message for large successful compression (from $original to $newTokens)',
async ({ original, newTokens }) => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: original,
newTokenCount: newTokens,
compressionStatus: CompressionStatus.COMPRESSED,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).toContain(
`compressed from ${original} to ${newTokens} tokens`,
);
expect(output).not.toContain('Skipping compression');
expect(output).not.toContain('did not reduce size');
unmount();
},
);
});
describe('skipped compression (tokens increased or same)', () => {
it('renders skip message when compression would increase token count', async () => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: 50,
newTokenCount: 75,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
@@ -101,121 +79,12 @@ describe('<CompressionMessage />', () => {
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Compression was not beneficial for this history size.',
);
unmount();
});
it('renders skip message when token counts are equal', async () => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: 50,
newTokenCount: 50,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain(
'Compression was not beneficial for this history size.',
);
unmount();
});
});
describe('message content validation', () => {
it.each([
{
original: 200,
newTokens: 80,
expected: 'compressed from 200 to 80 tokens',
},
{
original: 500,
newTokens: 150,
expected: 'compressed from 500 to 150 tokens',
},
{
original: 1500,
newTokens: 400,
expected: 'compressed from 1500 to 400 tokens',
},
])(
'displays correct compression statistics (from $original to $newTokens)',
async ({ original, newTokens, expected }) => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: original,
newTokenCount: newTokens,
compressionStatus: CompressionStatus.COMPRESSED,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain(expected);
unmount();
},
);
it.each([
{ original: 50, newTokens: 60 }, // Increased
{ original: 100, newTokens: 100 }, // Same
{ original: 49999, newTokens: 50000 }, // Just under 50k threshold
])(
'shows skip message for small histories when new tokens >= original tokens ($original -> $newTokens)',
async ({ original, newTokens }) => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: original,
newTokenCount: newTokens,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain(
'Compression was not beneficial for this history size.',
);
expect(output).not.toContain('compressed from');
unmount();
},
);
it.each([
{ original: 50000, newTokens: 50100 }, // At 50k threshold
{ original: 700000, newTokens: 710000 }, // Large history case
{ original: 100000, newTokens: 100000 }, // Large history, same count
])(
'shows compression failure message for large histories when new tokens >= original tokens ($original -> $newTokens)',
async ({ original, newTokens }) => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: original,
newTokenCount: newTokens,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain('compression did not reduce size');
expect(output).not.toContain('compressed from');
expect(output).not.toContain('Compression was not beneficial');
unmount();
},
);
});
describe('failure states', () => {
@@ -229,9 +98,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();
});
@@ -22,11 +22,13 @@ export interface CompressionDisplayProps {
export function CompressionMessage({
compression,
}: CompressionDisplayProps): React.JSX.Element {
const { isPending, originalTokenCount, newTokenCount, compressionStatus } =
compression;
const originalTokens = originalTokenCount ?? 0;
const newTokens = newTokenCount ?? 0;
const {
isPending,
beforePercentage,
afterPercentage,
compressionStatus,
thresholdPercentage,
} = compression;
const getCompressionText = () => {
if (isPending) {
@@ -34,20 +36,19 @@ export function CompressionMessage({
}
switch (compressionStatus) {
case CompressionStatus.COMPRESSED:
return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`;
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
// For smaller histories (< 50k tokens), compression overhead likely exceeds benefits
if (originalTokens < 50000) {
return 'Compression was not beneficial for this history size.';
case CompressionStatus.COMPRESSED: {
let text = `Context compressed (${beforePercentage}% → ${afterPercentage}%).`;
if (thresholdPercentage != null) {
text += ` Adjust threshold (${thresholdPercentage}%) in /settings.`;
}
// 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 text;
}
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
return 'Compression was not beneficial for this history 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 +59,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>
+193 -169
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-explicit-any -- TODO: Refactor to remove any usage */
import {
describe,
it,
@@ -32,10 +32,7 @@ import type {
Config,
EditorType,
AnyToolInvocation,
AnyDeclarativeTool,
SpanMetadata,
CompletedToolCall,
ToolCallRequestInfo,
} from '@google/gemini-cli-core';
import {
CoreToolCallStatus,
@@ -52,19 +49,15 @@ import {
MCPDiscoveryState,
GeminiCliOperation,
getPlanModeExitMessage,
CompressionStatus,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type {
SlashCommandProcessorResult,
HistoryItemWithoutId,
HistoryItem,
} from '../types.js';
import type { SlashCommandProcessorResult } from '../types.js';
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
@@ -145,6 +138,7 @@ const mockRunInDevTraceSpan = vi.hoisted(() =>
};
return await fn({
metadata,
endSpan: vi.fn(),
});
}),
);
@@ -249,10 +243,8 @@ describe('useGeminiStream', () => {
let mockMarkToolsAsSubmitted: Mock;
let handleAtCommandSpy: MockInstance;
const emptyHistory: HistoryItem[] = [];
let capturedOnComplete:
| ((tools: CompletedToolCall[]) => Promise<void>)
| null = null;
const emptyHistory: any[] = [];
let capturedOnComplete: any = null;
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
const mockOnAuthError = vi.fn();
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
@@ -332,6 +324,9 @@ describe('useGeminiStream', () => {
})),
getIdeMode: vi.fn(() => false),
getEnableHooks: vi.fn(() => false),
getShowContextWindowWarning: vi.fn(() => false),
getShowContextCompression: vi.fn(() => false),
getContextWindowCompressionThreshold: vi.fn(() => 0.2),
} as unknown as Config;
beforeEach(() => {
@@ -411,17 +406,13 @@ describe('useGeminiStream', () => {
lastToolCalls,
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
(
updater:
| TrackedToolCall[]
| ((prev: TrackedToolCall[]) => TrackedToolCall[]),
) => {
(updater: any) => {
lastToolCalls =
typeof updater === 'function' ? updater(lastToolCalls) : updater;
rerender({ ...initialProps, toolCalls: lastToolCalls });
},
(signal: AbortSignal) => {
mockCancelAllToolCalls(signal);
(...args: any[]) => {
mockCancelAllToolCalls(...args);
lastToolCalls = lastToolCalls.map((tc) => {
if (
tc.status === CoreToolCallStatus.AwaitingApproval ||
@@ -888,7 +879,7 @@ describe('useGeminiStream', () => {
const fn = spanArgs[1];
const metadata = { attributes: {} };
await act(async () => {
await fn({ metadata });
await fn({ metadata, endSpan: vi.fn() });
});
expect(metadata).toMatchObject({
input: sentParts,
@@ -982,7 +973,7 @@ describe('useGeminiStream', () => {
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
@@ -1054,7 +1045,7 @@ describe('useGeminiStream', () => {
});
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
@@ -1081,10 +1072,9 @@ describe('useGeminiStream', () => {
} as unknown as TrackedCompletedToolCall,
];
const lowVerbositySettings = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockLoadedSettings,
...(mockLoadedSettings as any),
merged: {
...mockLoadedSettings.merged,
...(mockLoadedSettings.merged as any),
ui: { errorVerbosity: 'low' },
},
} as LoadedSettings;
@@ -1935,120 +1925,6 @@ describe('useGeminiStream', () => {
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
});
});
it('should record client-initiated tool calls in GeminiChat history', async () => {
const { result, client: mockGeminiClient } = await renderTestHook();
mockHandleSlashCommand.mockResolvedValue({
type: 'schedule_tool',
toolName: 'activate_skill',
toolArgs: { name: 'test-skill' },
});
await act(async () => {
await result.current.submitQuery('/test-skill');
});
// Simulate tool completion
const completedTool = {
request: {
callId: 'test-call-id',
name: 'activate_skill',
args: { name: 'test-skill' },
isClientInitiated: true,
},
status: CoreToolCallStatus.Success,
invocation: {
getDescription: () => 'Activating skill test-skill',
},
tool: {
isOutputMarkdown: true,
},
response: {
responseParts: [
{
functionResponse: {
name: 'activate_skill',
response: { content: 'skill instructions' },
},
},
],
},
} as unknown as TrackedCompletedToolCall;
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([completedTool]);
}
});
// Verify that the tool call and response were added to GeminiChat history
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
role: 'model',
parts: [
{
functionCall: {
name: 'activate_skill',
args: { name: 'test-skill' },
},
},
],
});
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: completedTool.response.responseParts,
});
});
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
const { result, client: mockGeminiClient } = await renderTestHook();
mockHandleSlashCommand.mockResolvedValue({
type: 'schedule_tool',
toolName: 'save_memory',
toolArgs: { fact: 'test fact' },
});
await act(async () => {
await result.current.submitQuery('/memory add "test fact"');
});
// Simulate tool completion
const completedTool = {
request: {
callId: 'test-call-id',
name: 'save_memory',
args: { fact: 'test fact' },
isClientInitiated: true,
},
status: CoreToolCallStatus.Success,
invocation: {
getDescription: () => 'Saving memory',
},
tool: {
isOutputMarkdown: true,
},
response: {
responseParts: [
{
functionResponse: {
name: 'save_memory',
response: { success: true },
},
},
],
},
} as unknown as TrackedCompletedToolCall;
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([completedTool]);
}
});
// Verify that addHistory was NOT called
expect(mockGeminiClient.addHistory).not.toHaveBeenCalled();
});
});
describe('Memory Refresh on save_memory', () => {
@@ -2076,7 +1952,7 @@ describe('useGeminiStream', () => {
displayName: 'save_memory',
description: 'Saves memory',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
@@ -2150,8 +2026,7 @@ describe('useGeminiStream', () => {
);
const testConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
...(mockConfig as any),
getContentGenerator: vi.fn(),
getContentGeneratorConfig: vi.fn(() => ({
authType: mockAuthType,
@@ -2316,7 +2191,7 @@ describe('useGeminiStream', () => {
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2357,7 +2232,7 @@ describe('useGeminiStream', () => {
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2474,22 +2349,32 @@ describe('useGeminiStream', () => {
it.each([
{
name: 'without suggestion when remaining tokens are > 75% of limit',
name: 'add a message when remaining tokens overflow, regardless of showContextWindowWarning setting',
requestTokens: 20,
remainingTokens: 80,
shouldShow: false, // The setting itself is false
expectedMessage:
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
'Context 20% full. Message may exceed window. Reduce size or /compress.',
},
{
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 30% full. Message may exceed window. Reduce size or /compress.',
},
])(
'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 +2394,12 @@ describe('useGeminiStream', () => {
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith({
type: 'info',
text: expectedMessage,
});
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expectedMessage,
}),
);
});
},
);
@@ -2566,8 +2453,12 @@ describe('useGeminiStream', () => {
});
});
it('should add informational messages when ChatCompressed event is received', async () => {
it('should add informational messages when ChatCompressed event is received and showContextCompression is true', async () => {
vi.mocked(tokenLimit).mockReturnValue(10000);
vi.mocked(
mockConfig.getContextWindowCompressionThreshold,
).mockReturnValue(0.2);
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(true);
// Setup mock to return a stream with ChatCompressed event
mockSendMessageStream.mockReturnValue(
(async function* () {
@@ -2576,7 +2467,22 @@ describe('useGeminiStream', () => {
value: {
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: 'compressed',
compressionStatus: CompressionStatus.COMPRESSED,
},
};
yield {
type: ServerGeminiEventType.Content,
value: 'Response after compression',
};
yield {
type: ServerGeminiEventType.Finished,
value: {
finishReason: 'STOP',
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
},
},
};
})(),
@@ -2593,10 +2499,129 @@ 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: 'compression',
compression: {
isPending: false,
beforePercentage: 10,
afterPercentage: 5,
compressionStatus: CompressionStatus.COMPRESSED,
isManual: false,
thresholdPercentage: 20,
},
}),
expect.any(Number),
);
});
});
it('should NOT add informational messages when ChatCompressed event is received and showContextCompression is false', async () => {
vi.mocked(tokenLimit).mockReturnValue(10000);
vi.mocked(
mockConfig.getContextWindowCompressionThreshold,
).mockReturnValue(0.2);
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(false);
// Setup mock to return a stream with ChatCompressed event
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ChatCompressed,
value: {
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: CompressionStatus.COMPRESSED,
},
};
yield {
type: ServerGeminiEventType.Content,
value: 'Response after compression',
};
yield {
type: ServerGeminiEventType.Finished,
value: {
finishReason: 'STOP',
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
},
},
};
})(),
);
const { result } = await renderHookWithDefaults();
// Submit a query
await act(async () => {
await result.current.submitQuery('Test compression');
});
// Check that NO compression message was added
await waitFor(() => {
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({
type: 'compression',
}),
);
});
});
it('should add informational messages when ChatCompressed event is received with a large prompt even if showContextCompression is false', async () => {
vi.mocked(tokenLimit).mockReturnValue(10000);
vi.mocked(
mockConfig.getContextWindowCompressionThreshold,
).mockReturnValue(0.2); // 20%
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(false);
// Setup mock to return a stream with ChatCompressed event and a large requestTokenCount (25%)
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ChatCompressed,
value: {
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: CompressionStatus.COMPRESSED,
requestTokenCount: 2500, // 25% > 20%
},
};
yield {
type: ServerGeminiEventType.Content,
value: 'Response after compression',
};
yield {
type: ServerGeminiEventType.Finished,
value: {
finishReason: 'STOP',
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
},
},
};
})(),
);
const { result } = await renderHookWithDefaults();
// Submit a query
await act(async () => {
await result.current.submitQuery('Test large prompt compression');
});
// Check that compression message WAS added despite the setting
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'compression',
compression: expect.objectContaining({
beforePercentage: 10,
afterPercentage: 5,
compressionStatus: CompressionStatus.COMPRESSED,
isManual: false,
thresholdPercentage: 20,
}),
}),
expect.any(Number),
);
@@ -2702,14 +2727,14 @@ describe('useGeminiStream', () => {
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
const addItemOrder: string[] = [];
let capturedOnComplete: (tools: CompletedToolCall[]) => Promise<void>;
let capturedOnComplete: any;
const mockScheduleToolCalls = vi.fn(async (requests) => {
addItemOrder.push('scheduleToolCalls_START');
// Simulate tools completing and triggering onComplete immediately.
// This mimics the behavior that caused the regression where tool results
// were added to history during the await scheduleToolCalls(...) block.
const tools = requests.map((r: ToolCallRequestInfo) => ({
const tools = requests.map((r: any) => ({
request: r,
status: CoreToolCallStatus.Success,
tool: { displayName: r.name, name: r.name },
@@ -2724,7 +2749,7 @@ describe('useGeminiStream', () => {
addItemOrder.push('scheduleToolCalls_END');
});
mockAddItem.mockImplementation((item: HistoryItemWithoutId) => {
mockAddItem.mockImplementation((item: any) => {
addItemOrder.push(`addItem:${item.type}`);
});
@@ -2954,10 +2979,9 @@ describe('useGeminiStream', () => {
describe('Thought Reset', () => {
it('should keep full thinking entries in history when mode is full', async () => {
const fullThinkingSettings: LoadedSettings = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockLoadedSettings,
...(mockLoadedSettings as any),
merged: {
...mockLoadedSettings.merged,
...(mockLoadedSettings.merged as any),
ui: { inlineThinkingMode: 'full' },
},
} as unknown as LoadedSettings;
@@ -4036,7 +4060,7 @@ describe('useGeminiStream', () => {
const spanMetadata = {} as SpanMetadata;
await act(async () => {
await userPromptCall![1]({ metadata: spanMetadata });
await userPromptCall![1]({ metadata: spanMetadata, endSpan: vi.fn() });
});
expect(spanMetadata.input).toBe('telemetry test query');
});
+85 -84
View File
@@ -38,22 +38,20 @@ import {
GeminiCliOperation,
getPlanModeExitMessage,
isBackgroundExecutionData,
type CompressionStatus,
Kind,
ACTIVATE_SKILL_TOOL_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
EditorType,
GeminiClient,
ServerGeminiChatCompressedEvent,
ServerGeminiContentEvent as ContentEvent,
ServerGeminiFinishedEvent,
ServerGeminiStreamEvent as GeminiEvent,
ThoughtSummary,
ToolCallRequestInfo,
ToolCallResponseInfo,
GeminiErrorEventValue,
RetryAttemptPayload,
type Config,
type EditorType,
type GeminiClient,
type ServerGeminiChatCompressedEvent,
type ServerGeminiContentEvent as ContentEvent,
type ServerGeminiFinishedEvent,
type ServerGeminiStreamEvent as GeminiEvent,
type ThoughtSummary,
type ToolCallRequestInfo,
type ToolCallResponseInfo,
type GeminiErrorEventValue,
type RetryAttemptPayload,
} from '@google/gemini-cli-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import type {
@@ -61,7 +59,6 @@ import type {
HistoryItemThinking,
HistoryItemWithoutId,
HistoryItemToolGroup,
HistoryItemInfo,
IndividualToolCallDisplay,
SlashCommandProcessorResult,
HistoryItemModel,
@@ -257,6 +254,8 @@ export const useGeminiStream = (
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const handleCompletedToolsRef =
useRef<(completedTools: TrackedToolCall[]) => Promise<void>>(undefined);
const { startNewPrompt, getPromptCount } = useSessionStats();
const storage = config.storage;
const logger = useLogger(storage);
@@ -302,22 +301,23 @@ export const useGeminiStream = (
}),
);
}
// Clear the live-updating display now that the final state is in history.
setToolCallsForDisplay([]);
// Record tool calls with full metadata before sending responses.
try {
const currentModel =
config.getGeminiClient().getCurrentSequenceModel() ??
config.getModel();
config
.getGeminiClient()
.getChat()
.recordCompletedToolCalls(
currentModel,
completedToolCallsFromScheduler,
);
(typeof geminiClient.getCurrentSequenceModel === 'function'
? geminiClient.getCurrentSequenceModel()
: undefined) ?? config.getModel();
const chat =
typeof geminiClient.getChat === 'function'
? geminiClient.getChat()
: undefined;
chat?.recordCompletedToolCalls(
currentModel,
completedToolCallsFromScheduler,
);
await recordToolCallInteractions(
config,
@@ -330,7 +330,7 @@ export const useGeminiStream = (
}
// Handle tool response submission immediately when tools complete
await handleCompletedTools(
await handleCompletedToolsRef.current?.(
completedToolCallsFromScheduler as TrackedToolCall[],
);
}
@@ -549,9 +549,11 @@ export const useGeminiStream = (
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
return false;
}
// ToolGroupMessage now shows all non-canceled tools, so they are visible
// in pending and we need to draw the closing border for them.
return true;
return (
tc.status !== 'scheduled' &&
tc.status !== 'validating' &&
tc.status !== 'awaiting_approval'
);
});
if (
@@ -1145,21 +1147,43 @@ export const useGeminiStream = (
}
const limit = tokenLimit(config.getModel());
const originalPercentage = Math.round(
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
);
const newPercentage = Math.round(
((eventValue?.newTokenCount ?? 0) / limit) * 100,
);
const beforePercentage =
eventValue?.originalTokenCount != null
? Math.round((eventValue.originalTokenCount / limit) * 100)
: null;
const afterPercentage =
eventValue?.newTokenCount != null
? Math.round((eventValue.newTokenCount / limit) * 100)
: null;
const threshold = config.getContextWindowCompressionThreshold();
const isLargePrompt =
eventValue?.requestTokenCount != null &&
eventValue.requestTokenCount / limit > threshold;
if (!config.getShowContextCompression() && !isLargePrompt) {
return;
}
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,
beforePercentage,
afterPercentage,
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
compressionStatus: eventValue
? (Number(
eventValue.compressionStatus,
) as unknown as CompressionStatus)
: null,
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
isManual: false,
thresholdPercentage: Math.round(threshold * 100),
},
timestamp: new Date(userMessageTimestamp),
} as HistoryItemWithoutId,
userMessageTimestamp,
);
},
@@ -1182,16 +1206,11 @@ export const useGeminiStream = (
onCancelSubmit(true);
const limit = tokenLimit(config.getModel());
const usedPercentage = Math.round(
((limit - remainingTokenCount) / limit) * 100,
);
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.';
}
const text = `Context ${usedPercentage}% full. Message may exceed window. Reduce size or /compress.`;
addItem({
type: 'info',
@@ -1538,8 +1557,7 @@ export const useGeminiStream = (
setLoopDetectionConfirmationRequest(null);
if (result.userSelection === 'disable') {
config
.getGeminiClient()
geminiClient
.getLoopDetectionService()
.disableForSession();
addItem({
@@ -1657,7 +1675,7 @@ export const useGeminiStream = (
) {
let awaitingApprovalCalls = toolCalls.filter(
(call): call is TrackedWaitingToolCall =>
call.status === 'awaiting_approval' && !call.request.forcedAsk,
call.status === 'awaiting_approval',
);
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
@@ -1715,42 +1733,24 @@ export const useGeminiStream = (
},
);
// Check if all tools in the batch are in a terminal state
const allTerminal = completedToolCallsFromScheduler.every(
(tc) =>
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled',
);
if (!allTerminal) {
return;
}
// Finalize any client-initiated tools as soon as they are done.
const clientTools = completedAndReadyToSubmitTools.filter(
(t) => t.request.isClientInitiated,
);
if (clientTools.length > 0) {
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
if (geminiClient) {
for (const tool of clientTools) {
// Only manually record skill activations in the chat history.
// Other client-initiated tools (like save_memory) update the system
// prompt/context and don't strictly need to be in the history.
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
continue;
}
// Add both the call (model turn) and the result (user turn) to history.
// Client-initiated calls are essentially "synthetic" turns that let
// subsequent model calls understand what just happened in the UI.
await geminiClient.addHistory({
role: 'model',
parts: [
{
functionCall: {
name: tool.request.name,
args: tool.request.args,
},
},
],
});
await geminiClient.addHistory({
role: 'user',
parts: tool.response.responseParts,
});
}
}
}
// Identify new, successful save_memory calls that we haven't processed yet.
@@ -1906,6 +1906,7 @@ export const useGeminiStream = (
setIsResponding,
],
);
handleCompletedToolsRef.current = handleCompletedTools;
const pendingHistoryItems = useMemo(
() =>
+4 -2
View File
@@ -138,9 +138,11 @@ export interface IndividualToolCallDisplay {
export interface CompressionProps {
isPending: boolean;
originalTokenCount: number | null;
newTokenCount: number | null;
beforePercentage: number | null;
afterPercentage: number | null;
compressionStatus: CompressionStatus | null;
isManual: boolean;
thresholdPercentage?: number | null;
}
/**
+18
View File
@@ -673,6 +673,8 @@ export interface ConfigParameters {
agents?: AgentSettings;
}>;
enableConseca?: boolean;
showContextWindowWarning?: boolean;
showContextCompression?: boolean;
billing?: {
overageStrategy?: OverageStrategy;
};
@@ -708,6 +710,8 @@ 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 showContextCompression: boolean;
private readonly coreTools: string[] | undefined;
private readonly mainAgentTools: string[] | undefined;
@@ -1146,6 +1150,8 @@ 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;
this.showContextCompression = params.showContextCompression ?? false;
// Initialize Safety Infrastructure
const contextBuilder = new ContextBuilder(this);
@@ -2053,6 +2059,18 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpEnabled;
}
getShowContextWindowWarning(): boolean {
return this.showContextWindowWarning;
}
getShowContextCompression(): boolean {
return this.showContextCompression;
}
getContextWindowCompressionThreshold(): number {
return this.compressionThreshold ?? 0.5;
}
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
return this.mcpEnablementCallbacks;
}
+8 -1
View File
@@ -248,6 +248,9 @@ describe('Gemini Client (client.ts)', () => {
getEnableHooks: vi.fn().mockReturnValue(false),
getChatCompression: vi.fn().mockReturnValue(undefined),
getCompressionThreshold: vi.fn().mockReturnValue(undefined),
getShowContextWindowWarning: vi.fn().mockReturnValue(false),
getShowContextCompression: vi.fn().mockReturnValue(false),
getContextWindowCompressionThreshold: vi.fn().mockReturnValue(0.2),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
getShowModelInfoInChat: vi.fn().mockReturnValue(false),
getContinueOnFailedApiCall: vi.fn(),
@@ -1617,6 +1620,7 @@ ${JSON.stringify(
originalTokenCount: initialTokenCount,
newTokenCount: 400,
compressionStatus: CompressionStatus.COMPRESSED,
requestTokenCount: 50, // Added to match updated interface
};
});
@@ -1643,10 +1647,13 @@ ${JSON.stringify(
}),
);
// 2. Should contain compression event
// 2. Should contain compression event with requestTokenCount
expect(events).toContainEqual(
expect.objectContaining({
type: GeminiEventType.ChatCompressed,
value: expect.objectContaining({
requestTokenCount: expect.any(Number),
}),
}),
);
+48 -17
View File
@@ -608,17 +608,6 @@ export class GeminiClient {
// Check for context window overflow
const modelForLimitCheck = this._getActiveModelForCurrentTurn();
const compressed = await this.tryCompressChat(prompt_id, false);
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
yield { type: GeminiEventType.ChatCompressed, value: compressed };
}
const remainingTokenCount =
tokenLimit(modelForLimitCheck) - this.getChat().getLastPromptTokenCount();
await this.tryMaskToolOutputs(this.getHistory());
// Estimate tokens. For text-only requests, we estimate based on character length.
// For requests with non-text parts (like images, tools), we use the countTokens API.
const estimatedRequestTokenCount = await calculateRequestTokenCount(
@@ -627,12 +616,48 @@ export class GeminiClient {
modelForLimitCheck,
);
const compressed = await this.tryCompressChat(
prompt_id,
false,
estimatedRequestTokenCount,
);
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
yield { type: GeminiEventType.ChatCompressed, value: compressed };
}
let remainingTokenCount =
tokenLimit(modelForLimitCheck) - this.getChat().getLastPromptTokenCount();
await this.tryMaskToolOutputs(this.getHistory());
if (estimatedRequestTokenCount > remainingTokenCount) {
yield {
type: GeminiEventType.ContextWindowWillOverflow,
value: { estimatedRequestTokenCount, remainingTokenCount },
};
return turn;
if (!this.config.getShowContextWindowWarning()) {
const forcedCompressed = await this.tryCompressChat(
prompt_id,
true,
estimatedRequestTokenCount,
);
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
@@ -1158,6 +1183,7 @@ export class GeminiClient {
async tryCompressChat(
prompt_id: string,
force: boolean = false,
requestTokenCount?: number,
): Promise<ChatCompressionInfo> {
// If the model is 'auto', we will use a placeholder model to check.
// Compression occurs before we choose a model, so calling `count_tokens`
@@ -1173,6 +1199,11 @@ export class GeminiClient {
this.hasFailedCompressionAttempt,
);
const resultInfo = {
...info,
requestTokenCount,
};
if (
info.compressionStatus ===
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
@@ -1208,7 +1239,7 @@ export class GeminiClient {
}
}
return info;
return resultInfo;
}
/**
+1
View File
@@ -188,6 +188,7 @@ export interface ChatCompressionInfo {
originalTokenCount: number;
newTokenCount: number;
compressionStatus: CompressionStatus;
requestTokenCount?: number;
}
export type ServerGeminiChatCompressedEvent = {
+15 -1
View File
@@ -327,7 +327,21 @@
"hideContextSummary": {
"title": "Hide Context Summary",
"description": "Hide the context summary (GEMINI.md, MCP servers) above the input.",
"markdownDescription": "Hide the context summary (GEMINI.md, MCP servers) above the input.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
"markdownDescription": "Hide the context summary (GEMINI.md, MCP servers) above the input.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"showContextWindowWarning": {
"title": "Show Context Window Warning",
"description": "Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached.",
"markdownDescription": "Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"showContextCompression": {
"title": "Show Context Compression Messages",
"description": "Show a message in the chat history when it is compressed.",
"markdownDescription": "Show a message in the chat history when it is compressed.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},