diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml index 4198945159..366564d56e 100644 --- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml @@ -23,6 +23,10 @@ jobs: steps: - name: 'Generate GitHub App Token' id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} uses: 'actions/create-github-app-token@v2' with: app-id: '${{ secrets.APP_ID }}' @@ -33,7 +37,7 @@ jobs: env: DRY_RUN: '${{ inputs.dry_run }}' with: - github-token: '${{ steps.generate_token.outputs.token }}' + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: | const dryRun = process.env.DRY_RUN === 'true'; const thirtyDaysAgo = new Date(); diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 758976b85b..537e9d1aee 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -464,8 +464,9 @@ on GitHub. page in their default browser directly from the CLI using the `/extension` explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846) by [@JayadityaGit](https://github.com/JayadityaGit)). -- **Configurable compression:** Users can modify the compression threshold in - `/settings`. The default has been made more proactive +- **Configurable compression:** Users can modify the context compression + threshold in `/settings` (decimal with percentage display). The default has + been made more proactive ([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by [@scidomino](https://github.com/scidomino)). - **API key authentication:** Users can now securely enter and store their diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 51f0078206..a8511d9c42 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -25,9 +25,10 @@ implementation. It allows you to: - [Customizing Planning with Skills](#customizing-planning-with-skills) - [Customizing Policies](#customizing-policies) - [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode) - - [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode) + - [Example: Enable custom subagents in Plan Mode](#example-enable-custom-subagents-in-plan-mode) - [Custom Plan Directory and Policies](#custom-plan-directory-and-policies) - [Automatic Model Routing](#automatic-model-routing) +- [Cleanup](#cleanup) ## Enabling Plan Mode @@ -133,6 +134,7 @@ These are the only allowed tools: - **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`] - **Search:** [`grep_search`], [`google_web_search`] +- **Research Subagents:** [`codebase_investigator`], [`cli_help`] - **Interaction:** [`ask_user`] - **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`, `postgres_read_schema`) are allowed. @@ -203,16 +205,17 @@ priority = 100 modes = ["plan"] ``` -#### Example: Enable research subagents in Plan Mode +#### Example: Enable custom subagents in Plan Mode -You can enable experimental research [subagents] like `codebase_investigator` to -help gather architecture details during the planning phase. +Built-in research [subagents] like [`codebase_investigator`] and [`cli_help`] +are enabled by default in Plan Mode. You can enable additional [custom +subagents] by adding a rule to your policy. `~/.gemini/policies/research-subagents.toml` ```toml [[rule]] -toolName = "codebase_investigator" +toolName = "my_custom_subagent" decision = "allow" priority = 100 modes = ["plan"] @@ -290,6 +293,24 @@ performance. You can disable this automatic switching in your settings: } ``` +## Cleanup + +By default, Gemini CLI automatically cleans up old session data, including all +associated plan files and task trackers. + +- **Default behavior:** Sessions (and their plans) are retained for **30 days**. +- **Configuration:** You can customize this behavior via the `/settings` command + (search for **Session Retention**) or in your `settings.json` file. See + [session retention] for more details. + +Manual deletion also removes all associated artifacts: + +- **Command Line:** Use `gemini --delete-session `. +- **Session Browser:** Press `/resume`, navigate to a session, and press `x`. + +If you use a [custom plans directory](#custom-plan-directory-and-policies), +those files are not automatically deleted and must be managed manually. + [`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder [`read_file`]: /docs/tools/file-system.md#2-read_file-readfile [`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext @@ -300,7 +321,10 @@ performance. You can disable this automatic switching in your settings: [MCP tools]: /docs/tools/mcp-server.md [`save_memory`]: /docs/tools/memory.md [`activate_skill`]: /docs/cli/skills.md +[`codebase_investigator`]: /docs/core/subagents.md#codebase_investigator +[`cli_help`]: /docs/core/subagents.md#cli_help [subagents]: /docs/core/subagents.md +[custom subagents]: /docs/core/subagents.md#creating-custom-subagents [policy engine]: /docs/reference/policy-engine.md [`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode [`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode @@ -311,3 +335,4 @@ performance. You can disable this automatic switching in your settings: [auto model]: /docs/reference/configuration.md#model-settings [model routing]: /docs/cli/telemetry.md#model-routing [preferred external editor]: /docs/reference/configuration.md#general +[session retention]: /docs/cli/session-management.md#session-retention diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md index a1453148ae..442069bdac 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -121,27 +121,36 @@ session lengths. ### Session retention -To prevent your history from growing indefinitely, enable automatic cleanup -policies in your settings. +By default, Gemini CLI automatically cleans up old session data to prevent your +history from growing indefinitely. When a session is deleted, Gemini CLI also +removes all associated data, including implementation plans, task trackers, tool +outputs, and activity logs. + +The default policy is to **retain sessions for 30 days**. + +#### Configuration + +You can customize these policies using the `/settings` command or by manually +editing your `settings.json` file: ```json { "general": { "sessionRetention": { "enabled": true, - "maxAge": "30d", // Keep sessions for 30 days - "maxCount": 50 // Keep the 50 most recent sessions + "maxAge": "30d", + "maxCount": 50 } } } ``` - **`enabled`**: (boolean) Master switch for session cleanup. Defaults to - `false`. + `true`. - **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d", - "4w"). Sessions older than this are deleted. + "4w"). Sessions older than this are deleted. Defaults to `"30d"`. - **`maxCount`**: (number) Maximum number of sessions to retain. The oldest - sessions exceeding this count are deleted. + sessions exceeding this count are deleted. Defaults to undefined (unlimited). - **`minRetention`**: (string) Minimum retention period (safety limit). Defaults to `"1d"`. Sessions newer than this period are never deleted by automatic cleanup. diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 571d90aaf6..37508fc04e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -60,7 +60,7 @@ they appear in the UI. | Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path 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 remaining percentage. | `true` | +| 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` | @@ -89,13 +89,13 @@ they appear in the UI. ### Model -| UI Label | Setting | Description | Default | -| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | -| Model | `model.name` | The Gemini model to use for conversations. | `undefined` | -| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | -| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | -| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | -| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | +| UI Label | Setting | Description | Default | +| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | +| Model | `model.name` | The Gemini model to use for conversations. | `undefined` | +| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | +| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | +| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | +| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | ### Context diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 2c2b730126..46d43225b2 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -122,7 +122,10 @@ The manifest file defines the extension's behavior and configuration. } }, "contextFileName": "GEMINI.md", - "excludeTools": ["run_shell_command"] + "excludeTools": ["run_shell_command"], + "plan": { + "directory": ".gemini/plans" + } } ``` @@ -157,6 +160,11 @@ The manifest file defines the extension's behavior and configuration. `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. +- `plan`: Planning features configuration. + - `directory`: The directory where planning artifacts are stored. This serves + as a fallback if the user hasn't specified a plan directory in their + settings. If not specified by either the extension or the user, the default + is `~/.gemini/tmp///plans/`. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 524b00e00f..49954da8c6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -263,7 +263,7 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`ui.footer.hideContextPercentage`** (boolean): - - **Description:** Hides the context window remaining percentage. + - **Description:** Hides the context window usage percentage. - **Default:** `true` - **`ui.hideFooter`** (boolean): diff --git a/eslint.config.js b/eslint.config.js index 5cb8b7fcfa..d305f75f87 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,6 +25,18 @@ const __dirname = path.dirname(__filename); const projectRoot = __dirname; const currentYear = new Date().getFullYear(); +const commonRestrictedSyntaxRules = [ + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, +]; + export default tseslint.config( { // Global ignores @@ -120,18 +132,7 @@ export default tseslint.config( 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, - ], + 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], 'no-unsafe-finally': 'error', 'no-unused-expressions': 'off', // Disable base rule '@typescript-eslint/no-unused-expressions': [ @@ -171,6 +172,28 @@ export default tseslint.config( ], }, }, + { + // API Response Optionality enforcement for Code Assist + files: ['packages/core/src/code_assist/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + ...commonRestrictedSyntaxRules, + { + selector: + 'TSInterfaceDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', + message: + 'All fields in API response interfaces (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', + }, + { + selector: + 'TSTypeAliasDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', + message: + 'All fields in API response types (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', + }, + ], + }, + }, { // Rules that only apply to product code files: ['packages/*/src/**/*.{ts,tsx}'], diff --git a/evals/ask_user.eval.ts b/evals/ask_user.eval.ts new file mode 100644 index 0000000000..c67f995168 --- /dev/null +++ b/evals/ask_user.eval.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +describe('ask_user', () => { + evalTest('USUALLY_PASSES', { + name: 'Agent uses AskUser tool to present multiple choice options', + prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`, + assert: async (rig) => { + const wasToolCalled = await rig.waitForToolCall('ask_user'); + expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'Agent uses AskUser tool to clarify ambiguous requirements', + files: { + 'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }), + }, + prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`, + assert: async (rig) => { + const wasToolCalled = await rig.waitForToolCall('ask_user'); + expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'Agent uses AskUser tool before performing significant ambiguous rework', + files: { + 'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";', + 'packages/core/src/util.ts': '// util\nexport function help() {}', + 'packages/core/package.json': JSON.stringify({ + name: '@google/gemini-cli-core', + }), + 'README.md': '# Gemini CLI', + }, + prompt: `Refactor the entire core package to be better.`, + assert: async (rig) => { + const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode'); + expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe( + true, + ); + + const wasAskUserCalled = await rig.waitForToolCall('ask_user'); + expect( + wasAskUserCalled, + 'Expected ask_user tool to be called to clarify the significant rework', + ).toBe(true); + }, + }); + + // --- Regression Tests for Recent Fixes --- + + // Regression test for issue #20177: Ensure the agent does not use `ask_user` to + // confirm shell commands. Fixed via prompt refinements and tool definition + // updates to clarify that shell command confirmation is handled by the UI. + // See fix: https://github.com/google-gemini/gemini-cli/pull/20504 + evalTest('USUALLY_PASSES', { + name: 'Agent does NOT use AskUser to confirm shell commands', + files: { + 'package.json': JSON.stringify({ + scripts: { build: 'echo building' }, + }), + }, + prompt: `Run 'npm run build' in the current directory.`, + assert: async (rig) => { + await rig.waitForTelemetryReady(); + + const toolLogs = rig.readToolLogs(); + const wasShellCalled = toolLogs.some( + (log) => log.toolRequest.name === 'run_shell_command', + ); + const wasAskUserCalled = toolLogs.some( + (log) => log.toolRequest.name === 'ask_user', + ); + + expect( + wasShellCalled, + 'Expected run_shell_command tool to be called', + ).toBe(true); + expect( + wasAskUserCalled, + 'ask_user should not be called to confirm shell commands', + ).toBe(false); + }, + }); +}); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 919ad86c51..b22b7412cc 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -19,6 +19,8 @@ import { debugLogger, ApprovalMode, type MCPServerConfig, + type GeminiCLIExtension, + Storage, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import { @@ -3524,4 +3526,101 @@ describe('loadCliConfig mcpEnabled', () => { expect(config.getAllowedMcpServers()).toEqual(['serverA']); expect(config.getBlockedMcpServers()).toEqual(['serverB']); }); + + describe('extension plan settings', () => { + beforeEach(() => { + vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue( + '/mock/home/user/.gemini/tmp/test-project', + ); + }); + + it('should use plan directory from active extension when user has not specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from active extension when user has specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + general: { + plan: { directory: 'user-plans-dir' }, + }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('user-plans-dir'); + expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from inactive extension', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: false, + plan: { directory: 'ext-plans-dir-inactive' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).not.toContain( + 'ext-plans-dir-inactive', + ); + }); + + it('should use default path if neither user nor extension settings provide a plan directory', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + // No extensions providing plan directory + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + + const config = await loadCliConfig(settings, 'test-session', argv); + // Should return the default managed temp directory path + expect(config.storage.getPlansDir()).toBe( + path.join( + '/mock', + 'home', + 'user', + '.gemini', + 'tmp', + 'test-project', + 'test-session', + 'plans', + ), + ); + }); + }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bbc8b1681e..b478d67478 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -511,6 +511,10 @@ export async function loadCliConfig( }); await extensionManager.loadExtensions(); + const extensionPlanSettings = extensionManager + .getExtensions() + .find((ext) => ext.isActive && ext.plan?.directory)?.plan; + const experimentalJitContext = settings.experimental?.jitContext ?? false; let memoryContent: string | HierarchicalMemory = ''; @@ -827,7 +831,9 @@ export async function loadCliConfig( enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, directWebFetch: settings.experimental?.directWebFetch, - planSettings: settings.general?.plan, + planSettings: settings.general?.plan?.directory + ? settings.general.plan + : (extensionPlanSettings ?? settings.general?.plan), enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 11279b2957..bcfd44b19b 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -903,6 +903,7 @@ Would you like to attempt to install via "git clone" instead?`, themes: config.themes, rules, checkers, + plan: config.plan, }; } catch (e) { debugLogger.error( diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 815cf23ece..04a7b885ca 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -33,6 +33,15 @@ export interface ExtensionConfig { * These themes will be registered when the extension is activated. */ themes?: CustomTheme[]; + /** + * Planning features configuration contributed by this extension. + */ + plan?: { + /** + * The directory where planning artifacts are stored. + */ + directory?: string; + }; } export interface ExtensionUpdateInfo { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 38b71e433f..660866c0e3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -117,6 +117,10 @@ export interface SettingDefinition { * For map-like objects without explicit `properties`, describes the shape of the values. */ additionalProperties?: SettingCollectionDefinition; + /** + * Optional unit to display after the value (e.g. '%'). + */ + unit?: string; /** * Optional reference identifier for generators that emit a `$ref`. */ @@ -595,7 +599,7 @@ const SETTINGS_SCHEMA = { category: 'UI', requiresRestart: false, default: true, - description: 'Hides the context window remaining percentage.', + description: 'Hides the context window usage percentage.', showInDialog: true, }, }, @@ -913,13 +917,14 @@ const SETTINGS_SCHEMA = { }, compressionThreshold: { type: 'number', - label: 'Compression Threshold', + label: 'Context Compression Threshold', category: 'Model', requiresRestart: true, default: 0.5 as number, description: 'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).', showInDialog: true, + unit: '%', }, disableLoopDetection: { type: 'boolean', diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index ae272d6145..bcd5fd62b5 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { describe, it, expect, vi } from 'vitest'; @@ -17,18 +17,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -vi.mock('../../config/settings.js', () => ({ - DEFAULT_MODEL_CONFIGS: {}, - LoadedSettings: class { - constructor() { - // this.merged = {}; - } - }, -})); - describe('ContextUsageDisplay', () => { - it('renders correct percentage left', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders correct percentage used', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('50% context left'); + expect(output).toContain('50% context used'); unmount(); }); - it('renders short label when terminal width is small', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders correctly when usage is 0%', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('0% context used'); + unmount(); + }); + + it('renders abbreviated label when terminal width is small', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { width: 80 }, ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('80%'); - expect(output).not.toContain('context left'); + expect(output).toContain('20%'); + expect(output).not.toContain('context used'); unmount(); }); - it('renders 0% when full', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders 80% correctly', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('80% context used'); + unmount(); + }); + + it('renders 100% when full', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('0% context left'); + expect(output).toContain('100% context used'); unmount(); }); }); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index 1c1d24cc2d..66cb8ed234 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -7,6 +7,11 @@ import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { getContextUsagePercentage } from '../utils/contextUsage.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { + MIN_TERMINAL_WIDTH_FOR_FULL_LABEL, + DEFAULT_COMPRESSION_THRESHOLD, +} from '../constants.js'; export const ContextUsageDisplay = ({ promptTokenCount, @@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({ terminalWidth, }: { promptTokenCount: number; - model: string; + model: string | undefined; terminalWidth: number; }) => { + const settings = useSettings(); const percentage = getContextUsagePercentage(promptTokenCount, model); - const percentageLeft = ((1 - percentage) * 100).toFixed(0); + const percentageUsed = (percentage * 100).toFixed(0); - const label = terminalWidth < 100 ? '%' : '% context left'; + const threshold = + settings.merged.model?.compressionThreshold ?? + DEFAULT_COMPRESSION_THRESHOLD; + + let textColor = theme.text.secondary; + if (percentage >= 1.0) { + textColor = theme.status.error; + } else if (percentage >= threshold) { + textColor = theme.status.warning; + } + + const label = + terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used'; return ( - - {percentageLeft} + + {percentageUsed} {label} ); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index 6e6a4ce48c..65d54e50d6 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -76,7 +76,7 @@ describe('DetailedMessagesDisplay', () => { unmount(); }); - it('hides the F12 hint in low error verbosity mode', async () => { + it('shows the F12 hint even in low error verbosity mode', async () => { const messages: ConsoleMessageItem[] = [ { type: 'error', content: 'Error message', count: 1 }, ]; @@ -95,7 +95,7 @@ describe('DetailedMessagesDisplay', () => { }, ); await waitUntilReady(); - expect(lastFrame()).not.toContain('(F12 to close)'); + expect(lastFrame()).toContain('(F12 to close)'); unmount(); }); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index 097ebe1378..ff88afa888 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -13,8 +13,6 @@ import { ScrollableList, type ScrollableListRef, } from './shared/ScrollableList.js'; -import { useConfig } from '../contexts/ConfigContext.js'; -import { useSettings } from '../contexts/SettingsContext.js'; interface DetailedMessagesDisplayProps { messages: ConsoleMessageItem[]; @@ -29,10 +27,6 @@ export const DetailedMessagesDisplay: React.FC< DetailedMessagesDisplayProps > = ({ messages, maxHeight, width, hasFocus }) => { const scrollableListRef = useRef>(null); - const config = useConfig(); - const settings = useSettings(); - const showHotkeyHint = - settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode(); const borderAndPadding = 3; @@ -71,10 +65,7 @@ export const DetailedMessagesDisplay: React.FC< > - Debug Console{' '} - {showHotkeyHint && ( - (F12 to close) - )} + Debug Console (F12 to close) diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 9c253fec92..7187240249 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -15,6 +15,19 @@ import { } from '@google/gemini-cli-core'; import type { SessionStatsState } from '../contexts/SessionContext.js'; +let mockIsDevelopment = false; + +vi.mock('../../utils/installationInfo.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + get isDevelopment() { + return mockIsDevelopment; + }, + }; +}); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); @@ -161,7 +174,7 @@ describe('