mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd5d18c210 | |||
| 4f9293e8e9 | |||
| 9b51ccf82a | |||
| c47bca8374 | |||
| 2c83cf9603 | |||
| 84432ddaa8 | |||
| 05d29d68aa | |||
| 99ddc299ad | |||
| cd4c8821cb | |||
| 8d0c2a3acb | |||
| 184a9abb59 | |||
| 4b2c117af3 | |||
| 511cbc54c9 | |||
| 692c34c834 | |||
| 3be1de915f | |||
| 16485afa96 | |||
| 375afc7199 | |||
| fd8d218911 | |||
| 1de0367faa | |||
| b6231f561b | |||
| 252dbeb39a | |||
| 39fb7b11a8 | |||
| eb3e540f3f |
+34
-32
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2103,6 +2103,52 @@ describe('loadCliConfig compressionThreshold', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig showContextWindowWarning', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should pass showContextWindowWarning from settings to config (true)', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
ui: {
|
||||
showContextWindowWarning: true,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getShowContextWindowWarning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass showContextWindowWarning from settings to config (false)', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
ui: {
|
||||
showContextWindowWarning: false,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getShowContextWindowWarning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should default to false if not in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getShowContextWindowWarning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig useRipgrep', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
@@ -955,6 +955,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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
await userPromptCall });
|
||||
});
|
||||
expect(spanMetadata.input).toBe('telemetry test query');
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -188,6 +188,7 @@ export interface ChatCompressionInfo {
|
||||
originalTokenCount: number;
|
||||
newTokenCount: number;
|
||||
compressionStatus: CompressionStatus;
|
||||
requestTokenCount?: number;
|
||||
}
|
||||
|
||||
export type ServerGeminiChatCompressedEvent = {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user