Compare commits

...

27 Commits

Author SHA1 Message Date
gemini-cli-robot 393e9a171f chore(release): v0.33.0-preview.2 2026-03-05 22:10:39 +00:00
gemini-cli-robot 59702f913d fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch version v0.33.0-preview.1 and create version 0.33.0-preview.2 (#21300)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-03-05 12:28:20 -08:00
gemini-cli-robot b3439e1458 chore(release): v0.33.0-preview.1 2026-03-04 04:12:41 +00:00
gemini-cli-robot b21b289e26 fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch version v0.33.0-preview.0 and create version 0.33.0-preview.1 (#21047)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-03 18:48:23 -08:00
gemini-cli-robot 56a63a35e2 chore(release): v0.33.0-preview.0 2026-03-03 23:20:33 +00:00
Shreya Keshive 34f0c1538b feat(acp): add set models interface (#20991) 2026-03-03 22:29:42 +00:00
Sehoon Shon c70c95ead3 remove hardcoded tiername when missing tier (#21022) 2026-03-03 22:16:37 +00:00
Dev Randalpura f3bbe6e77a fix(core): send shell output to model on cancel (#20501) 2026-03-03 22:10:16 +00:00
Abhi 28e79831ac fix(core): sanitize and length-check MCP tool qualified names (#20987) 2026-03-03 21:38:52 +00:00
Sam Roberts fdf5b18cfb Format the quota/limit style guide. (#21017) 2026-03-03 21:37:05 +00:00
ruomeng b5f3eb2c9c feat(plan): add copy subcommand to plan (#20491) (#20988) 2026-03-03 21:36:51 +00:00
Ale Aadithya 2a84090dd5 Docs/add hooks reference (#20961)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-03 21:29:15 +00:00
Sri Pasumarthi 27d7aeb1ed feat: Implement slash command handling in ACP for /memory,/init,/extensions and /restore (#20528) 2026-03-03 21:29:14 +00:00
Tommaso Sciortino d6c560498b fix(cli): pin clipboardy to ~5.2.x (#21009) 2026-03-03 21:01:29 +00:00
Sam Roberts 4500da339b Update docs-writer skill with new resource (#20917) 2026-03-03 20:49:18 +00:00
Ishaan Gupta 4be08a2261 refactor common settings logic for skills,agents (#17490)
Co-authored-by: ved015 <vedant.04.mahajan@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-03 19:25:17 +00:00
Adib234 fe332bbef7 feat(evals): add behavioral evals for ask_user tool (#20620) 2026-03-03 17:51:15 +00:00
kartik 50af050623 fix: pre-load @file references from external editor prompts (#20963)
Signed-off-by: Kartik Angiras <angiraskartik@gmail.com>
2026-03-03 17:41:30 +00:00
Tommaso Sciortino e5207eb67f fix(cli): wait for background auto-update before relaunching (#20904) 2026-03-03 17:18:29 +00:00
Adam Weidman f15bcaf499 feat(agents): directly indicate auth required state (#20986) 2026-03-03 17:10:12 +00:00
Adib234 5f2f60bed6 feat(plan): enable built-in research subagents in plan mode (#20972) 2026-03-03 16:52:27 +00:00
Mahima Shanware c332d1e636 feat(extensions): add support for plan directory in extension manifest (#20354)
Co-authored-by: Mahima Shanware <mshanware@google.com>
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-03-03 16:50:18 +00:00
Sehoon Shon aa158e18d3 fix(core): enforce optionality for API response fields in code_assist (#20714) 2026-03-03 16:17:34 +00:00
Jerop Kipruto fca29b0bd8 fix(plan): clean up session directories and plans on deletion (#20914) 2026-03-03 14:11:25 +00:00
Keith Guerin 1e2afbb514 feat(cli): invert context window display to show usage (#20071)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-03-03 09:22:29 +00:00
Bryan Morgan 208291f391 fix(ci): handle empty APP_ID in stale PR closer (#20919) 2026-03-03 00:14:36 -05:00
Jacob Richman 8303edbb54 Code review fixes as a pr (#20612) 2026-03-03 04:32:50 +00:00
101 changed files with 4281 additions and 730 deletions
+4
View File
@@ -45,6 +45,10 @@ Write precisely to ensure your instructions are unambiguous.
specific verbs.
- **Examples:** Use meaningful names in examples; avoid placeholders like
"foo" or "bar."
- **Quota and limit terminology:** For any content involving resource capacity
or using the word "quota" or "limit", strictly adhere to the guidelines in
the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for the
administrative bucket and "limit" for the numerical ceiling.
### Formatting and syntax
Apply consistent formatting to make documentation visually organized and
@@ -0,0 +1,61 @@
# Style Guide: Quota vs. Limit
This guide defines the usage of "quota," "limit," and related terms in
user-facing interfaces.
## TL;DR
- **`quota`**: The administrative "bucket." Use for settings, billing, and
requesting increases. (e.g., "Adjust your storage **quota**.")
- **`limit`**: The real-time numerical "ceiling." Use for error messages when a
user is blocked. (e.g., "You've reached your request **limit**.")
- **When blocked, combine them:** Explain the **limit** that was hit and the
**quota** that is the remedy. (e.g., "You've reached the request **limit** for
your developer **quota**.")
- **Related terms:** Use `usage` for consumption tracking, `restriction` for
fixed rules, and `reset` for when a limit refreshes.
---
## Detailed Guidelines
### Definitions
- **Quota is the "what":** It identifies the category of resource being managed
(e.g., storage quota, GPU quota, request/prompt quota).
- **Limit is the "how much":** It defines the numerical boundary.
Use **quota** when referring to the administrative concept or the request for
more. Use **limit** when discussing the specific point of exhaustion.
### When to use "quota"
Use this term for **account management, billing, and settings.** It describes
the entitlement the user has purchased or been assigned.
**Examples:**
- **Navigation label:** Quota and usage
- **Contextual help:** Your **usage quota** is managed by your organization. To
request an increase, contact your administrator.
### When to use "limit"
Use this term for **real-time feedback, notifications, and error messages.** It
identifies the specific wall the user just hit.
**Examples:**
- **Error message:** Youve reached the 50-request-per-minute **limit**.
- **Inline warning:** Input exceeds the 32k token **limit**.
### How to use both together
When a user is blocked, combine both terms to explain the **event** (limit) and
the **remedy** (quota).
**Example:**
- **Heading:** Daily usage limit reached
- **Body:** You've reached the maximum daily capacity for your developer quota.
To continue working today, upgrade your quota.
@@ -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();
+3 -2
View File
@@ -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
+35 -5
View File
@@ -21,13 +21,15 @@ implementation. It allows you to:
- [Entering Plan Mode](#entering-plan-mode)
- [Planning Workflow](#planning-workflow)
- [Exiting Plan Mode](#exiting-plan-mode)
- [Commands](#commands)
- [Tool Restrictions](#tool-restrictions)
- [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
@@ -125,6 +127,10 @@ To exit Plan Mode, you can:
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
finalized plan for your approval.
### Commands
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
## Tool Restrictions
Plan Mode enforces strict safety policies to prevent accidental changes.
@@ -133,6 +139,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 +210,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 +298,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 <index|id>`.
- **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 +326,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 +340,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
+16 -7
View File
@@ -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.
+8 -8
View File
@@ -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
+9 -1
View File
@@ -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/<project>/<session-id>/plans/`.
When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
+3
View File
@@ -270,6 +270,9 @@ Slash commands provide meta-level control over the CLI itself.
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
- **Sub-commands:**
- **`copy`**:
- **Description:** Copy the currently approved plan to your clipboard.
### `/policies`
+1 -1
View File
@@ -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):
+8 -1
View File
@@ -94,7 +94,14 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Hooks", "slug": "docs/hooks" },
{
"label": "Hooks",
"collapsed": true,
"items": [
{ "label": "Overview", "slug": "docs/hooks" },
{ "label": "Reference", "slug": "docs/hooks/reference" }
]
},
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
+35 -12
View File
@@ -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}'],
+92
View File
@@ -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);
},
});
});
+244 -71
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"workspaces": [
"packages/*"
],
@@ -5464,13 +5464,6 @@
"node": ">=8"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT",
"peer": true
},
"node_modules/array-includes": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
@@ -6305,16 +6298,36 @@
"node": ">= 12"
}
},
"node_modules/clipboardy": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.0.0.tgz",
"integrity": "sha512-MQfKHaD09eP80Pev4qBxZLbxJK/ONnqfSYAPlCmPh+7BDboYtO/3BmB6HGzxDIT0SlTRc2tzS8lQqfcdLtZ0Kg==",
"node_modules/clipboard-image": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/clipboard-image/-/clipboard-image-0.1.0.tgz",
"integrity": "sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg==",
"license": "MIT",
"dependencies": {
"execa": "^9.6.0",
"run-jxa": "^3.0.0"
},
"bin": {
"clipboard-image": "cli.js"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clipboardy": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.2.1.tgz",
"integrity": "sha512-RWp4E/ivQAzgF4QSWA9sjeW+Bjo+U2SvebkDhNIfO7y65eGdXPUxMTdIKYsn+bxM3ItPHGm3e68Bv3fgQ3mARw==",
"license": "MIT",
"dependencies": {
"clipboard-image": "^0.1.0",
"execa": "^9.6.1",
"is-wayland": "^0.1.0",
"is-wsl": "^3.1.0",
"is64bit": "^2.0.0"
"is64bit": "^2.0.0",
"powershell-utils": "^0.2.0"
},
"engines": {
"node": ">=20"
@@ -6570,7 +6583,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -6740,6 +6752,33 @@
"node": ">= 8"
}
},
"node_modules/crypto-random-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
"integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
"license": "MIT",
"dependencies": {
"type-fest": "^1.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/crypto-random-string/node_modules/type-fest": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -8430,9 +8469,9 @@
}
},
"node_modules/execa": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
@@ -8550,36 +8589,15 @@
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8839,7 +8857,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8848,18 +8865,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT",
"peer": true
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
"license": "MIT"
},
"node_modules/find-up": {
"version": "5.0.0",
@@ -11735,6 +11741,21 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/macos-version": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/macos-version/-/macos-version-6.0.0.tgz",
"integrity": "sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -11870,6 +11891,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -13420,6 +13447,18 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/powershell-utils": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
"integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14302,6 +14341,107 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/run-jxa/-/run-jxa-3.0.0.tgz",
"integrity": "sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA==",
"license": "MIT",
"dependencies": {
"execa": "^5.1.1",
"macos-version": "^6.0.0",
"subsume": "^4.0.0",
"type-fest": "^2.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa/node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/run-jxa/node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa/node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/run-jxa/node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/run-jxa/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/run-jxa/node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/run-jxa/node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -15238,6 +15378,34 @@
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"license": "MIT"
},
"node_modules/subsume": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/subsume/-/subsume-4.0.0.tgz",
"integrity": "sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^5.0.0",
"unique-string": "^3.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/subsume/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/superagent": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz",
@@ -16220,6 +16388,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unique-string": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
"integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
"license": "MIT",
"dependencies": {
"crypto-random-string": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universal-user-agent": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
@@ -16286,16 +16469,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -17130,7 +17303,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -17188,7 +17361,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17200,7 +17373,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"clipboardy": "~5.2.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
@@ -17271,7 +17444,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -17536,7 +17709,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17551,7 +17724,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17568,7 +17741,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17585,7 +17758,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-preview.2"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-preview.2"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -38,7 +38,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"clipboardy": "~5.2.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
+99
View File
@@ -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',
),
);
});
});
});
+7 -1
View File
@@ -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,
@@ -886,6 +886,7 @@ Would you like to attempt to install via "git clone" instead?`,
themes: config.themes,
rules,
checkers,
plan: config.plan,
};
} catch (e) {
debugLogger.error(
+9
View File
@@ -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 {
+7 -2
View File
@@ -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',
-130
View File
@@ -2544,136 +2544,6 @@ describe('AppContainer State Management', () => {
});
});
describe('Expansion Persistence', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
const setupExpansionPersistenceTest = async (
HighPriorityChild?: React.FC,
) => {
const getTree = () => (
<SettingsContext.Provider value={mockSettings}>
<KeypressProvider config={mockConfig}>
<OverflowProvider>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
{HighPriorityChild && <HighPriorityChild />}
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
const renderResult = render(getTree());
stdin = renderResult.stdin;
await act(async () => {
vi.advanceTimersByTime(100);
});
rerender = () => renderResult.rerender(getTree());
unmount = () => renderResult.unmount();
};
const writeStdin = async (sequence: string) => {
await act(async () => {
stdin.write(sequence);
// Advance timers to allow escape sequence parsing and broadcasting
vi.advanceTimersByTime(100);
});
rerender();
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should reset expansion when a key is NOT handled by anyone', async () => {
await setupExpansionPersistenceTest();
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// Press a random key that no one handles (hits Low priority fallback)
await writeStdin('x');
// Should be reset to true (collapsed)
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should toggle expansion when Ctrl+O is pressed', async () => {
await setupExpansionPersistenceTest();
// Initial state is collapsed
expect(capturedUIState.constrainHeight).toBe(true);
// Press Ctrl+O to expand (Ctrl+O is sequence \x0f)
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(false);
// Press Ctrl+O again to collapse
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => {
const NavigationHandler = () => {
// use real useKeypress
useKeypress(
(key: Key) => {
if (key.name === 'up' || key.name === 'down') {
return true; // Handle navigation
}
return false;
},
{ isActive: true, priority: true }, // High priority
);
return null;
};
await setupExpansionPersistenceTest(NavigationHandler);
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// 1. Simulate Up arrow (handled by high priority child)
// CSI A is Up arrow
await writeStdin('\u001b[A');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 2. Simulate Down arrow (handled by high priority child)
// CSI B is Down arrow
await writeStdin('\u001b[B');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 3. Sanity check: press an unhandled key
await writeStdin('x');
// Should finally collapse
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
});
describe('Shortcuts Help Visibility', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockedUseKeypress: Mock;
+1 -4
View File
@@ -1873,10 +1873,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, {
isActive: true,
priority: KeypressPriority.Low,
});
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(
() => {
@@ -14,7 +14,9 @@ import {
coreEvents,
processSingleFileContent,
type ProcessedFileReadResult,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { copyToClipboard } from '../utils/commandUtils.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -25,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitFeedback: vi.fn(),
},
processSingleFileContent: vi.fn(),
readFileWithEncoding: vi.fn(),
partToString: vi.fn((val) => val),
};
});
@@ -35,9 +38,14 @@ vi.mock('node:path', async (importOriginal) => {
...actual,
default: { ...actual },
join: vi.fn((...args) => args.join('/')),
basename: vi.fn((p) => p.split('/').pop()),
};
});
vi.mock('../utils/commandUtils.js', () => ({
copyToClipboard: vi.fn(),
}));
describe('planCommand', () => {
let mockContext: CommandContext;
@@ -115,4 +123,46 @@ describe('planCommand', () => {
text: '# Approved Plan Content',
});
});
describe('copy subcommand', () => {
it('should copy the approved plan to clipboard', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(mockPlanPath);
vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content');
const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');
await copySubCommand.action(mockContext, '');
expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath);
expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Plan copied to clipboard (approved-plan.md).',
);
});
it('should warn if no approved plan is found', async () => {
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(undefined);
const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');
await copySubCommand.action(mockContext, '');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
'No approved plan found to copy.',
);
});
});
});
+43 -2
View File
@@ -4,22 +4,54 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
import {
type CommandContext,
CommandKind,
type SlashCommand,
} from './types.js';
import {
ApprovalMode,
coreEvents,
debugLogger,
processSingleFileContent,
partToString,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import * as path from 'node:path';
import { copyToClipboard } from '../utils/commandUtils.js';
async function copyAction(context: CommandContext) {
const config = context.services.config;
if (!config) {
debugLogger.debug('Plan copy command: config is not available in context');
return;
}
const planPath = config.getApprovedPlanPath();
if (!planPath) {
coreEvents.emitFeedback('warning', 'No approved plan found to copy.');
return;
}
try {
const content = await readFileWithEncoding(planPath);
await copyToClipboard(content);
coreEvents.emitFeedback(
'info',
`Plan copied to clipboard (${path.basename(planPath)}).`,
);
} catch (error) {
coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error);
}
}
export const planCommand: SlashCommand = {
name: 'plan',
description: 'Switch to Plan Mode and view current plan',
kind: CommandKind.BUILT_IN,
autoExecute: true,
autoExecute: false,
action: async (context) => {
const config = context.services.config;
if (!config) {
@@ -62,4 +94,13 @@ export const planCommand: SlashCommand = {
);
}
},
subCommands: [
{
name: 'copy',
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: copyAction,
},
],
};
@@ -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(
<ContextUsageDisplay
promptTokenCount={5000}
model="gemini-pro"
@@ -37,27 +28,56 @@ describe('ContextUsageDisplay', () => {
);
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(
<ContextUsageDisplay
promptTokenCount={0}
model="gemini-pro"
terminalWidth={120}
/>,
);
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(
<ContextUsageDisplay
promptTokenCount={2000}
model="gemini-pro"
terminalWidth={80}
/>,
{ 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(
<ContextUsageDisplay
promptTokenCount={8000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80% context used');
unmount();
});
it('renders 100% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={10000}
model="gemini-pro"
@@ -66,7 +86,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context left');
expect(output).toContain('100% context used');
unmount();
});
});
@@ -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 (
<Text color={theme.text.secondary}>
{percentageLeft}
<Text color={textColor}>
{percentageUsed}
{label}
</Text>
);
@@ -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();
});
@@ -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<ScrollableListRef<ConsoleMessageItem>>(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<
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console{' '}
{showHotkeyHint && (
<Text color={theme.text.secondary}>(F12 to close)</Text>
)}
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
</Text>
</Box>
<Box height={maxHeight} width={width - borderAndPadding}>
+50 -6
View File
@@ -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<typeof import('../../utils/installationInfo.js')>();
return {
...original,
get isDevelopment() {
return mockIsDevelopment;
},
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -161,7 +174,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
expect(lastFrame()).toMatch(/\d+% context used/);
unmount();
});
@@ -216,7 +229,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Usage remaining');
expect(lastFrame()).not.toContain('used');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -249,7 +262,7 @@ describe('<Footer />', () => {
unmount();
});
it('displays the model name and abbreviated context percentage', async () => {
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
@@ -267,6 +280,7 @@ describe('<Footer />', () => {
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
expect(lastFrame()).not.toContain('context used');
unmount();
});
@@ -464,7 +478,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% context left/);
expect(lastFrame()).not.toMatch(/\d+% context used/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
@@ -484,7 +498,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
expect(lastFrame()).toMatch(/\d+% context used/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
@@ -509,7 +523,15 @@ describe('<Footer />', () => {
});
describe('error summary visibility', () => {
it('hides error summary in low verbosity mode', async () => {
beforeEach(() => {
mockIsDevelopment = false;
});
afterEach(() => {
mockIsDevelopment = false;
});
it('hides error summary in low verbosity mode out of dev mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
@@ -530,6 +552,28 @@ describe('<Footer />', () => {
unmount();
});
it('shows error summary in low verbosity mode in dev mode', async () => {
mockIsDevelopment = true;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
+3 -1
View File
@@ -62,7 +62,9 @@ export const Footer: React.FC = () => {
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
!showErrorDetails &&
errorCount > 0 &&
(isFullErrorVerbosity || debugMode || isDevelopment);
const hideCWD = settings.merged.ui.footer.hideCWD;
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
@@ -99,6 +99,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'info' && (
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}
@@ -89,11 +89,12 @@ const renderStatusDisplay = async (
};
describe('StatusDisplay', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
});
afterEach(() => {
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -112,7 +113,7 @@ describe('StatusDisplay', () => {
});
it('renders system md indicator if env var is set', async () => {
process.env['GEMINI_SYSTEM_MD'] = 'true';
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -47,6 +47,7 @@ describe('<UserIdentity />', () => {
const output = lastFrame();
expect(output).toContain('test@example.com');
expect(output).toContain('/auth');
expect(output).not.toContain('/upgrade');
unmount();
});
@@ -74,6 +75,7 @@ describe('<UserIdentity />', () => {
const output = lastFrame();
expect(output).toContain('Logged in with Google');
expect(output).toContain('/auth');
expect(output).not.toContain('/upgrade');
unmount();
});
@@ -130,6 +132,26 @@ describe('<UserIdentity />', () => {
const output = lastFrame();
expect(output).toContain(`Authenticated with ${AuthType.USE_GEMINI}`);
expect(output).toContain('/auth');
expect(output).not.toContain('/upgrade');
unmount();
});
it('should render specific tier name when provided', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
model: 'gemini-pro',
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Enterprise Tier');
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Enterprise Tier');
expect(output).toContain('/upgrade');
unmount();
});
});
@@ -53,12 +53,14 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
</Box>
{/* Tier Name /upgrade */}
<Box>
<Text color={theme.text.primary} wrap="truncate-end">
{tierName ?? 'Gemini Code Assist for individuals'}
</Text>
<Text color={theme.text.secondary}> /upgrade</Text>
</Box>
{tierName && (
<Box>
<Text color={theme.text.primary} wrap="truncate-end">
{tierName}
</Text>
<Text color={theme.text.secondary}> /upgrade</Text>
</Box>
)}
</Box>
);
};
@@ -11,12 +11,12 @@ exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
" ...s/to/make/it/long no sandbox /model gemini-pro 100%
" ...s/to/make/it/long no sandbox /model gemini-pro 0%
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 0% context used
"
`;
@@ -11,6 +11,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
secondaryText?: string;
icon?: string;
color?: string;
marginBottom?: number;
@@ -18,6 +19,7 @@ interface InfoMessageProps {
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
secondaryText,
icon,
color,
marginBottom,
@@ -35,6 +37,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
{text.split('\n').map((line, index) => (
<Text wrap="wrap" key={index}>
<RenderInline text={line} defaultColor={color} />
{index === text.split('\n').length - 1 && secondaryText && (
<Text color={theme.text.secondary}> {secondaryText}</Text>
)}
</Text>
))}
</Box>
+6
View File
@@ -48,3 +48,9 @@ export const ACTIVE_SHELL_MAX_LINES = 15;
// Max lines to preserve in history for completed shell commands
export const COMPLETED_SHELL_MAX_LINES = 15;
/** Minimum terminal width required to show the full context used label */
export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
/** Default context usage fraction at which to trigger compression */
export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
+1 -1
View File
@@ -30,7 +30,7 @@ export const INFORMATIVE_TIPS = [
'Choose a specific Gemini model for conversations (/settings)…',
'Limit the number of turns in your session history (/settings)…',
'Automatically summarize large tool outputs to save tokens (settings.json)…',
'Control when chat history gets compressed based on token usage (settings.json)…',
'Control when chat history gets compressed based on context compression threshold (settings.json)…',
'Define custom context file names, like CONTEXT.md (settings.json)…',
'Set max directories to scan for context files (/settings)…',
'Expand your workspace with additional directories (/directory)…',
@@ -50,6 +50,7 @@ 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
@@ -2300,14 +2301,14 @@ describe('useGeminiStream', () => {
requestTokens: 20,
remainingTokens: 80,
expectedMessage:
'Sending this message (20 tokens) might exceed the remaining context window limit (80 tokens).',
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
},
{
name: 'with suggestion when remaining tokens are < 75% of limit',
requestTokens: 30,
remainingTokens: 70,
expectedMessage:
'Sending this message (30 tokens) might exceed the remaining context window limit (70 tokens). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
'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.',
},
])(
'should add message $name',
@@ -2388,6 +2389,43 @@ describe('useGeminiStream', () => {
});
});
it('should add informational messages when ChatCompressed event is received', async () => {
vi.mocked(tokenLimit).mockReturnValue(10000);
// Setup mock to return a stream with ChatCompressed event
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ChatCompressed,
value: {
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: 'compressed',
},
};
})(),
);
const { result } = renderHookWithDefaults();
// Submit a query
await act(async () => {
await result.current.submitQuery('Test compression');
});
// Check that the succinct info message was added
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,
}),
expect.any(Number),
);
});
});
it.each([
{
reason: 'STOP',
+25 -14
View File
@@ -108,9 +108,9 @@ enum StreamProcessingStatus {
}
const SUPPRESSED_TOOL_ERRORS_NOTE =
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for details.';
const LOW_VERBOSITY_FAILURE_NOTE =
'This request failed. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) {
@@ -1065,16 +1065,27 @@ export const useGeminiStream = (
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
return addItem({
type: 'info',
text:
`IMPORTANT: This conversation exceeded the compress threshold. ` +
`A compressed context will be sent for future messages (compressed from: ` +
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`,
});
const limit = tokenLimit(config.getModel());
const originalPercentage = Math.round(
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
);
const newPercentage = Math.round(
((eventValue?.newTokenCount ?? 0) / limit) * 100,
);
addItem(
{
type: MessageType.INFO,
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
secondaryText: `Change threshold in /settings.`,
color: theme.status.warning,
marginBottom: 1,
} as HistoryItemInfo,
userMessageTimestamp,
);
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
);
const handleMaxSessionTurnsEvent = useCallback(
@@ -1094,12 +1105,12 @@ export const useGeminiStream = (
const limit = tokenLimit(config.getModel());
const isLessThan75Percent =
const isMoreThan25PercentUsed =
limit > 0 && remainingTokenCount < limit * 0.75;
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the remaining context window limit (${remainingTokenCount} tokens).`;
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
if (isLessThan75Percent) {
if (isMoreThan25PercentUsed) {
text +=
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
}
@@ -49,7 +49,7 @@ describe('useLoadingIndicator', () => {
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
errorVerbosity?: 'low' | 'full';
errorVerbosity: 'low' | 'full';
}) {
hookResult = useLoadingIndicator({
streamingState,
@@ -22,7 +22,7 @@ export interface UseLoadingIndicatorProps {
retryStatus: RetryAttemptPayload | null;
loadingPhrasesMode?: LoadingPhrasesMode;
customWittyPhrases?: string[];
errorVerbosity?: 'low' | 'full';
errorVerbosity: 'low' | 'full';
}
export const useLoadingIndicator = ({
@@ -31,7 +31,7 @@ export const useLoadingIndicator = ({
retryStatus,
loadingPhrasesMode,
customWittyPhrases,
errorVerbosity = 'full',
errorVerbosity,
}: UseLoadingIndicatorProps) => {
const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding;
+1
View File
@@ -151,6 +151,7 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
secondaryText?: string;
icon?: string;
color?: string;
marginBottom?: number;
+25 -7
View File
@@ -163,7 +163,6 @@ describe('commandUtils', () => {
it('should return true when query starts with @', () => {
expect(isAtCommand('@file')).toBe(true);
expect(isAtCommand('@path/to/file')).toBe(true);
expect(isAtCommand('@')).toBe(true);
});
it('should return true when query contains @ preceded by whitespace', () => {
@@ -172,17 +171,36 @@ describe('commandUtils', () => {
expect(isAtCommand(' @file')).toBe(true);
});
it('should return false when query does not start with @ and has no spaced @', () => {
it('should return true when @ is preceded by non-whitespace (external editor scenario)', () => {
// When a user composes a prompt in an external editor, @-references may
// appear after punctuation characters such as ':' or '(' without a space.
// The processor must still recognise these as @-commands so that the
// referenced files are pre-loaded before the query is sent to the model.
expect(isAtCommand('check:@file.py')).toBe(true);
expect(isAtCommand('analyze(@file.py)')).toBe(true);
expect(isAtCommand('hello@file')).toBe(true);
expect(isAtCommand('text@path/to/file')).toBe(true);
expect(isAtCommand('user@host')).toBe(true);
});
it('should return false when query does not contain any @<path> pattern', () => {
expect(isAtCommand('file')).toBe(false);
expect(isAtCommand('hello')).toBe(false);
expect(isAtCommand('')).toBe(false);
expect(isAtCommand('email@domain.com')).toBe(false);
expect(isAtCommand('user@host')).toBe(false);
// A bare '@' with no following path characters is not an @-command.
expect(isAtCommand('@')).toBe(false);
});
it('should return false when @ is not preceded by whitespace', () => {
expect(isAtCommand('hello@file')).toBe(false);
expect(isAtCommand('text@path')).toBe(false);
it('should return false when @ is escaped with a backslash', () => {
expect(isAtCommand('\\@file')).toBe(false);
});
it('should return true for multi-line external editor prompts with @-references', () => {
expect(isAtCommand('Please review:\n@src/main.py\nand fix bugs.')).toBe(
true,
);
// @file after a colon on the same line.
expect(isAtCommand('Files:@src/a.py,@src/b.py')).toBe(true);
});
});
+15 -4
View File
@@ -10,18 +10,29 @@ import type { SlashCommand } from '../commands/types.js';
import fs from 'node:fs';
import type { Writable } from 'node:stream';
import type { Settings } from '../../config/settingsSchema.js';
import { AT_COMMAND_PATH_REGEX_SOURCE } from '../hooks/atCommandProcessor.js';
// Pre-compiled regex for detecting @<path> patterns consistent with parseAllAtCommands.
// Uses the same AT_COMMAND_PATH_REGEX_SOURCE so that isAtCommand is true whenever
// parseAllAtCommands would find at least one atPath part.
const AT_COMMAND_DETECT_REGEX = new RegExp(
`(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}`,
);
/**
* Checks if a query string potentially represents an '@' command.
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
* and followed by a non-whitespace character.
* Returns true if the query contains any '@<path>' pattern that would be
* recognised by the @ command processor, regardless of what character
* precedes the '@' sign. This ensures that prompts written in an external
* editor (where '@' may follow punctuation like ':' or '(') are correctly
* identified and their referenced files pre-loaded before the query is sent
* to the model.
*
* @param query The input query string.
* @returns True if the query looks like an '@' command, false otherwise.
*/
export const isAtCommand = (query: string): boolean =>
// Check if starts with @ OR has a space, then @
query.startsWith('@') || /\s@/.test(query);
AT_COMMAND_DETECT_REGEX.test(query);
/**
* Checks if a query string potentially represents an '/' command.
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
SettingScope,
type LoadedSettings,
type LoadableSettingScope,
} from '../config/settings.js';
import { enableAgent, disableAgent } from './agentSettings.js';
function createMockLoadedSettings(opts: {
userSettings?: Record<string, unknown>;
workspaceSettings?: Record<string, unknown>;
userPath?: string;
workspacePath?: string;
}): LoadedSettings {
const scopes: Record<
string,
{
settings: Record<string, unknown>;
originalSettings: Record<string, unknown>;
path: string;
}
> = {
[SettingScope.User]: {
settings: opts.userSettings ?? {},
originalSettings: opts.userSettings ?? {},
path: opts.userPath ?? '/home/user/.gemini/settings.json',
},
[SettingScope.Workspace]: {
settings: opts.workspaceSettings ?? {},
originalSettings: opts.workspaceSettings ?? {},
path: opts.workspacePath ?? '/project/.gemini/settings.json',
},
};
return {
forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
setValue: vi.fn(),
} as unknown as LoadedSettings;
}
describe('agentSettings', () => {
describe('agentStrategy (via enableAgent / disableAgent)', () => {
describe('enableAgent', () => {
it('should return no-op when the agent is already enabled in both scopes', () => {
const settings = createMockLoadedSettings({
userSettings: {
agents: { overrides: { 'my-agent': { enabled: true } } },
},
workspaceSettings: {
agents: { overrides: { 'my-agent': { enabled: true } } },
},
});
const result = enableAgent(settings, 'my-agent');
expect(result.status).toBe('no-op');
expect(result.action).toBe('enable');
expect(result.agentName).toBe('my-agent');
expect(result.modifiedScopes).toHaveLength(0);
expect(settings.setValue).not.toHaveBeenCalled();
});
it('should enable the agent when not present in any scope', () => {
const settings = createMockLoadedSettings({
userSettings: {},
workspaceSettings: {},
});
const result = enableAgent(settings, 'my-agent');
expect(result.status).toBe('success');
expect(result.action).toBe('enable');
expect(result.agentName).toBe('my-agent');
expect(result.modifiedScopes).toHaveLength(2);
expect(settings.setValue).toHaveBeenCalledTimes(2);
});
it('should enable the agent only in the scope where it is not enabled', () => {
const settings = createMockLoadedSettings({
userSettings: {
agents: { overrides: { 'my-agent': { enabled: true } } },
},
workspaceSettings: {
agents: { overrides: { 'my-agent': { enabled: false } } },
},
});
const result = enableAgent(settings, 'my-agent');
expect(result.status).toBe('success');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
});
describe('disableAgent', () => {
it('should return no-op when agent is already explicitly disabled', () => {
const settings = createMockLoadedSettings({
userSettings: {
agents: { overrides: { 'my-agent': { enabled: false } } },
},
});
const result = disableAgent(settings, 'my-agent', SettingScope.User);
expect(result.status).toBe('no-op');
expect(result.action).toBe('disable');
expect(result.agentName).toBe('my-agent');
expect(settings.setValue).not.toHaveBeenCalled();
});
it('should disable the agent when it is currently enabled', () => {
const settings = createMockLoadedSettings({
userSettings: {
agents: { overrides: { 'my-agent': { enabled: true } } },
},
});
const result = disableAgent(settings, 'my-agent', SettingScope.User);
expect(result.status).toBe('success');
expect(result.action).toBe('disable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
it('should return error for an invalid scope', () => {
const settings = createMockLoadedSettings({});
const result = disableAgent(settings, 'my-agent', SettingScope.Session);
expect(result.status).toBe('error');
expect(result.error).toContain('Invalid settings scope');
});
});
});
});
+40 -107
View File
@@ -4,30 +4,41 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { SettingScope, LoadedSettings } from '../config/settings.js';
import {
SettingScope,
isLoadableSettingScope,
type LoadedSettings,
} from '../config/settings.js';
import type { ModifiedScope } from './skillSettings.js';
type FeatureActionResult,
type FeatureToggleStrategy,
enableFeature,
disableFeature,
} from './featureToggleUtils.js';
export type AgentActionStatus = 'success' | 'no-op' | 'error';
/**
* Metadata representing the result of an agent settings operation.
*/
export interface AgentActionResult {
status: AgentActionStatus;
export interface AgentActionResult
extends Omit<FeatureActionResult, 'featureName'> {
agentName: string;
action: 'enable' | 'disable';
/** Scopes where the agent's state was actually changed. */
modifiedScopes: ModifiedScope[];
/** Scopes where the agent was already in the desired state. */
alreadyInStateScopes: ModifiedScope[];
/** Error message if status is 'error'. */
error?: string;
}
const agentStrategy: FeatureToggleStrategy = {
needsEnabling: (settings, scope, agentName) => {
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
return agentOverrides?.[agentName]?.enabled !== true;
},
enable: (settings, scope, agentName) => {
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
},
isExplicitlyDisabled: (settings, scope, agentName) => {
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
return agentOverrides?.[agentName]?.enabled === false;
},
disable: (settings, scope, agentName) => {
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
},
};
/**
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
* It sets `agents.overrides.<agentName>.enabled` to `true`.
@@ -36,50 +47,14 @@ export function enableAgent(
settings: LoadedSettings,
agentName: string,
): AgentActionResult {
const writableScopes = [SettingScope.Workspace, SettingScope.User];
const foundInDisabledScopes: ModifiedScope[] = [];
const alreadyEnabledScopes: ModifiedScope[] = [];
for (const scope of writableScopes) {
if (isLoadableSettingScope(scope)) {
const scopePath = settings.forScope(scope).path;
const agentOverrides =
settings.forScope(scope).settings.agents?.overrides;
const isEnabled = agentOverrides?.[agentName]?.enabled === true;
if (!isEnabled) {
foundInDisabledScopes.push({ scope, path: scopePath });
} else {
alreadyEnabledScopes.push({ scope, path: scopePath });
}
}
}
if (foundInDisabledScopes.length === 0) {
return {
status: 'no-op',
agentName,
action: 'enable',
modifiedScopes: [],
alreadyInStateScopes: alreadyEnabledScopes,
};
}
const modifiedScopes: ModifiedScope[] = [];
for (const { scope, path } of foundInDisabledScopes) {
if (isLoadableSettingScope(scope)) {
// Explicitly enable it.
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
modifiedScopes.push({ scope, path });
}
}
return {
status: 'success',
const { featureName, ...rest } = enableFeature(
settings,
agentName,
action: 'enable',
modifiedScopes,
alreadyInStateScopes: alreadyEnabledScopes,
agentStrategy,
);
return {
...rest,
agentName: featureName,
};
}
@@ -91,56 +66,14 @@ export function disableAgent(
agentName: string,
scope: SettingScope,
): AgentActionResult {
if (!isLoadableSettingScope(scope)) {
return {
status: 'error',
agentName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [],
error: `Invalid settings scope: ${scope}`,
};
}
const scopePath = settings.forScope(scope).path;
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
const isEnabled = agentOverrides?.[agentName]?.enabled !== false;
if (!isEnabled) {
return {
status: 'no-op',
agentName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [{ scope, path: scopePath }],
};
}
// Check if it's already disabled in the other writable scope
const otherScope =
scope === SettingScope.Workspace
? SettingScope.User
: SettingScope.Workspace;
const alreadyDisabledInOther: ModifiedScope[] = [];
if (isLoadableSettingScope(otherScope)) {
const otherOverrides =
settings.forScope(otherScope).settings.agents?.overrides;
if (otherOverrides?.[agentName]?.enabled === false) {
alreadyDisabledInOther.push({
scope: otherScope,
path: settings.forScope(otherScope).path,
});
}
}
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
return {
status: 'success',
const { featureName, ...rest } = disableFeature(
settings,
agentName,
action: 'disable',
modifiedScopes: [{ scope, path: scopePath }],
alreadyInStateScopes: alreadyDisabledInOther,
scope,
agentStrategy,
);
return {
...rest,
agentName: featureName,
};
}
@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
enableFeature,
disableFeature,
type FeatureToggleStrategy,
} from './featureToggleUtils.js';
import {
SettingScope,
type LoadedSettings,
type LoadableSettingScope,
} from '../config/settings.js';
function createMockLoadedSettings(opts: {
userSettings?: Record<string, unknown>;
workspaceSettings?: Record<string, unknown>;
userPath?: string;
workspacePath?: string;
}): LoadedSettings {
const scopes: Record<
string,
{ settings: Record<string, unknown>; path: string }
> = {
[SettingScope.User]: {
settings: opts.userSettings ?? {},
path: opts.userPath ?? '/home/user/.gemini/settings.json',
},
[SettingScope.Workspace]: {
settings: opts.workspaceSettings ?? {},
path: opts.workspacePath ?? '/project/.gemini/settings.json',
},
};
const mockSettings = {
forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
setValue: vi.fn(),
} as unknown as LoadedSettings;
return mockSettings;
}
function createMockStrategy(overrides?: {
needsEnabling?: (
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
) => boolean;
isExplicitlyDisabled?: (
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
) => boolean;
}): FeatureToggleStrategy {
return {
needsEnabling: vi.fn(overrides?.needsEnabling ?? (() => false)),
enable: vi.fn(),
isExplicitlyDisabled: vi.fn(
overrides?.isExplicitlyDisabled ?? (() => false),
),
disable: vi.fn(),
};
}
describe('featureToggleUtils', () => {
describe('enableFeature', () => {
it('should return no-op when the feature is already enabled in all scopes', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
needsEnabling: () => false,
});
const result = enableFeature(settings, 'my-feature', strategy);
expect(result.status).toBe('no-op');
expect(result.action).toBe('enable');
expect(result.featureName).toBe('my-feature');
expect(result.modifiedScopes).toHaveLength(0);
expect(result.alreadyInStateScopes).toHaveLength(2);
expect(strategy.enable).not.toHaveBeenCalled();
});
it('should enable the feature when disabled in one scope', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
needsEnabling: (_s, scope) => scope === SettingScope.Workspace,
});
const result = enableFeature(settings, 'my-feature', strategy);
expect(result.status).toBe('success');
expect(result.action).toBe('enable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
expect(strategy.enable).toHaveBeenCalledTimes(1);
});
it('should enable the feature when disabled in both scopes', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
needsEnabling: () => true,
});
const result = enableFeature(settings, 'my-feature', strategy);
expect(result.status).toBe('success');
expect(result.action).toBe('enable');
expect(result.modifiedScopes).toHaveLength(2);
expect(result.alreadyInStateScopes).toHaveLength(0);
expect(strategy.enable).toHaveBeenCalledTimes(2);
});
it('should include correct scope paths in the result', () => {
const settings = createMockLoadedSettings({
userPath: '/custom/user/path',
workspacePath: '/custom/workspace/path',
});
const strategy = createMockStrategy({
needsEnabling: () => true,
});
const result = enableFeature(settings, 'my-feature', strategy);
const paths = result.modifiedScopes.map((s) => s.path);
expect(paths).toContain('/custom/workspace/path');
expect(paths).toContain('/custom/user/path');
});
});
describe('disableFeature', () => {
it('should return no-op when the feature is already disabled in the target scope', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
isExplicitlyDisabled: () => true,
});
const result = disableFeature(
settings,
'my-feature',
SettingScope.User,
strategy,
);
expect(result.status).toBe('no-op');
expect(result.action).toBe('disable');
expect(result.featureName).toBe('my-feature');
expect(result.modifiedScopes).toHaveLength(0);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(strategy.disable).not.toHaveBeenCalled();
});
it('should disable the feature when it is enabled', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
isExplicitlyDisabled: () => false,
});
const result = disableFeature(
settings,
'my-feature',
SettingScope.User,
strategy,
);
expect(result.status).toBe('success');
expect(result.action).toBe('disable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
expect(strategy.disable).toHaveBeenCalledOnce();
});
it('should return error for an invalid scope', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy();
const result = disableFeature(
settings,
'my-feature',
SettingScope.Session,
strategy,
);
expect(result.status).toBe('error');
expect(result.action).toBe('disable');
expect(result.error).toContain('Invalid settings scope');
expect(strategy.disable).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
SettingScope,
isLoadableSettingScope,
type LoadableSettingScope,
type LoadedSettings,
} from '../config/settings.js';
export interface ModifiedScope {
scope: SettingScope;
path: string;
}
export type FeatureActionStatus = 'success' | 'no-op' | 'error';
export interface FeatureActionResult {
status: FeatureActionStatus;
featureName: string;
action: 'enable' | 'disable';
/** Scopes where the feature's state was actually changed. */
modifiedScopes: ModifiedScope[];
/** Scopes where the feature was already in the desired state. */
alreadyInStateScopes: ModifiedScope[];
/** Error message if status is 'error'. */
error?: string;
}
/**
* Strategy pattern to handle differences between feature types (e.g. skills vs agents).
*/
export interface FeatureToggleStrategy {
/**
* Checks if the feature needs to be enabled in the given scope.
* For skills (blacklist): returns true if in disabled list.
* For agents (whitelist): returns true if NOT explicitly enabled (false or undefined).
*/
needsEnabling(
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
): boolean;
/**
* Applies the enable change to the settings object.
*/
enable(
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
): void;
/**
* Checks if the feature is explicitly disabled in the given scope.
* For skills (blacklist): returns true if in disabled list.
* For agents (whitelist): returns true if explicitly set to false.
*/
isExplicitlyDisabled(
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
): boolean;
/**
* Applies the disable change to the settings object.
*/
disable(
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
): void;
}
/**
* Enables a feature by ensuring it is enabled in all writable scopes.
*/
export function enableFeature(
settings: LoadedSettings,
featureName: string,
strategy: FeatureToggleStrategy,
): FeatureActionResult {
const writableScopes = [SettingScope.Workspace, SettingScope.User];
const foundInDisabledScopes: ModifiedScope[] = [];
const alreadyEnabledScopes: ModifiedScope[] = [];
for (const scope of writableScopes) {
if (isLoadableSettingScope(scope)) {
const scopePath = settings.forScope(scope).path;
if (strategy.needsEnabling(settings, scope, featureName)) {
foundInDisabledScopes.push({ scope, path: scopePath });
} else {
alreadyEnabledScopes.push({ scope, path: scopePath });
}
}
}
if (foundInDisabledScopes.length === 0) {
return {
status: 'no-op',
featureName,
action: 'enable',
modifiedScopes: [],
alreadyInStateScopes: alreadyEnabledScopes,
};
}
const modifiedScopes: ModifiedScope[] = [];
for (const { scope, path } of foundInDisabledScopes) {
if (isLoadableSettingScope(scope)) {
strategy.enable(settings, scope, featureName);
modifiedScopes.push({ scope, path });
}
}
return {
status: 'success',
featureName,
action: 'enable',
modifiedScopes,
alreadyInStateScopes: alreadyEnabledScopes,
};
}
/**
* Disables a feature in the specified scope.
*/
export function disableFeature(
settings: LoadedSettings,
featureName: string,
scope: SettingScope,
strategy: FeatureToggleStrategy,
): FeatureActionResult {
if (!isLoadableSettingScope(scope)) {
return {
status: 'error',
featureName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [],
error: `Invalid settings scope: ${scope}`,
};
}
const scopePath = settings.forScope(scope).path;
if (strategy.isExplicitlyDisabled(settings, scope, featureName)) {
return {
status: 'no-op',
featureName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [{ scope, path: scopePath }],
};
}
// Check if it's already disabled in the other writable scope
const otherScope =
scope === SettingScope.Workspace
? SettingScope.User
: SettingScope.Workspace;
const alreadyDisabledInOther: ModifiedScope[] = [];
if (isLoadableSettingScope(otherScope)) {
if (strategy.isExplicitlyDisabled(settings, otherScope, featureName)) {
alreadyDisabledInOther.push({
scope: otherScope,
path: settings.forScope(otherScope).path,
});
}
}
strategy.disable(settings, scope, featureName);
return {
status: 'success',
featureName,
action: 'disable',
modifiedScopes: [{ scope, path: scopePath }],
alreadyInStateScopes: alreadyDisabledInOther,
};
}
@@ -12,7 +12,13 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js';
import type { LoadedSettings } from '../config/settings.js';
import EventEmitter from 'node:events';
import type { ChildProcess } from 'node:child_process';
import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
import {
handleAutoUpdate,
setUpdateHandler,
isUpdateInProgress,
waitForUpdateCompletion,
_setUpdateStateForTesting,
} from './handleAutoUpdate.js';
import { MessageType } from '../ui/types.js';
vi.mock('./installationInfo.js', async () => {
@@ -79,6 +85,7 @@ describe('handleAutoUpdate', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
_setUpdateStateForTesting(false);
});
it('should do nothing if update info is null', () => {
@@ -88,6 +95,80 @@ describe('handleAutoUpdate', () => {
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should track update progress state', async () => {
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @google/gemini-cli@latest',
updateMessage: 'This is an additional message.',
isGlobal: false,
packageManager: PackageManager.NPM,
});
expect(isUpdateInProgress()).toBe(false);
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(isUpdateInProgress()).toBe(true);
mockChildProcess.emit('close', 0);
expect(isUpdateInProgress()).toBe(false);
});
it('should track update progress state on error', async () => {
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @google/gemini-cli@latest',
updateMessage: 'This is an additional message.',
isGlobal: false,
packageManager: PackageManager.NPM,
});
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(isUpdateInProgress()).toBe(true);
mockChildProcess.emit('error', new Error('fail'));
expect(isUpdateInProgress()).toBe(false);
});
it('should resolve waitForUpdateCompletion when update succeeds', async () => {
_setUpdateStateForTesting(true);
const waitPromise = waitForUpdateCompletion();
updateEventEmitter.emit('update-success', {});
await expect(waitPromise).resolves.toBeUndefined();
});
it('should resolve waitForUpdateCompletion when update fails', async () => {
_setUpdateStateForTesting(true);
const waitPromise = waitForUpdateCompletion();
updateEventEmitter.emit('update-failed', {});
await expect(waitPromise).resolves.toBeUndefined();
});
it('should resolve waitForUpdateCompletion immediately if not in progress', async () => {
_setUpdateStateForTesting(false);
const waitPromise = waitForUpdateCompletion();
await expect(waitPromise).resolves.toBeUndefined();
});
it('should timeout waitForUpdateCompletion', async () => {
vi.useFakeTimers();
_setUpdateStateForTesting(true);
const waitPromise = waitForUpdateCompletion(1000);
vi.advanceTimersByTime(1001);
await expect(waitPromise).resolves.toBeUndefined();
vi.useRealTimers();
});
it('should do nothing if update prompts are disabled', () => {
mockSettings.merged.general.enableAutoUpdateNotification = false;
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
@@ -12,6 +12,54 @@ import type { HistoryItem } from '../ui/types.js';
import { MessageType } from '../ui/types.js';
import { spawnWrapper } from './spawnWrapper.js';
import type { spawn } from 'node:child_process';
import { debugLogger } from '@google/gemini-cli-core';
let _updateInProgress = false;
/** @internal */
export function _setUpdateStateForTesting(value: boolean) {
_updateInProgress = value;
}
export function isUpdateInProgress() {
return _updateInProgress;
}
/**
* Returns a promise that resolves when the update process completes or times out.
*/
export async function waitForUpdateCompletion(
timeoutMs = 30000,
): Promise<void> {
if (!_updateInProgress) {
return;
}
debugLogger.log(
'\nGemini CLI is waiting for a background update to complete before restarting...',
);
return new Promise((resolve) => {
// Re-check the condition inside the promise executor to avoid a race condition.
// If the update finished between the initial check and now, resolve immediately.
if (!_updateInProgress) {
resolve();
return;
}
const timer = setTimeout(cleanup, timeoutMs);
function cleanup() {
clearTimeout(timer);
updateEventEmitter.off('update-success', cleanup);
updateEventEmitter.off('update-failed', cleanup);
resolve();
}
updateEventEmitter.once('update-success', cleanup);
updateEventEmitter.once('update-failed', cleanup);
});
}
export function handleAutoUpdate(
info: UpdateObject | null,
@@ -62,6 +110,11 @@ export function handleAutoUpdate(
) {
return;
}
if (_updateInProgress) {
return;
}
const isNightly = info.update.latest.includes('nightly');
const updateCommand = installationInfo.updateCommand.replace(
@@ -73,10 +126,14 @@ export function handleAutoUpdate(
shell: true,
detached: true,
});
_updateInProgress = true;
// Un-reference the child process to allow the parent to exit independently.
updateProcess.unref();
updateProcess.on('close', (code) => {
_updateInProgress = false;
if (code === 0) {
updateEventEmitter.emit('update-success', {
message:
@@ -90,6 +147,7 @@ export function handleAutoUpdate(
});
updateProcess.on('error', (err) => {
_updateInProgress = false;
updateEventEmitter.emit('update-failed', {
message: `Automatic update failed. Please try updating manually. (error: ${err.message})`,
});
+9 -1
View File
@@ -7,6 +7,11 @@
import { vi } from 'vitest';
import { RELAUNCH_EXIT_CODE, relaunchApp } from './processUtils.js';
import * as cleanup from './cleanup.js';
import * as handleAutoUpdate from './handleAutoUpdate.js';
vi.mock('./handleAutoUpdate.js', () => ({
waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined),
}));
describe('processUtils', () => {
const processExit = vi
@@ -14,8 +19,11 @@ describe('processUtils', () => {
.mockReturnValue(undefined as never);
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
it('should run cleanup and exit with the relaunch code', async () => {
afterEach(() => vi.clearAllMocks());
it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
await relaunchApp();
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
expect(runExitCleanup).toHaveBeenCalledTimes(1);
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});
+2
View File
@@ -5,6 +5,7 @@
*/
import { runExitCleanup } from './cleanup.js';
import { waitForUpdateCompletion } from './handleAutoUpdate.js';
/**
* Exit code used to signal that the CLI should be relaunched.
@@ -15,6 +16,7 @@ export const RELAUNCH_EXIT_CODE = 199;
* Exits the process with a special code to signal that the parent process should relaunch it.
*/
export async function relaunchApp(): Promise<void> {
await waitForUpdateCompletion();
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}
@@ -919,6 +919,32 @@ describe('Session Cleanup', () => {
),
);
});
it('should delete the session-specific directory', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '1d', // Very short retention to trigger deletion of all but current
},
},
};
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.unlink.mockResolvedValue(undefined);
mockFs.rm.mockResolvedValue(undefined);
await cleanupExpiredSessions(config, settings);
// Verify that fs.rm was called with the session directory for the deleted session that has sessionInfo
// recent456 should be deleted and its directory removed
expect(mockFs.rm).toHaveBeenCalledWith(
path.join('/tmp/test-project', 'recent456'),
expect.objectContaining({ recursive: true, force: true }),
);
});
});
describe('parseRetentionPeriod format validation', () => {
+11
View File
@@ -115,6 +115,17 @@ export async function cleanupExpiredSessions(
} catch {
/* ignore if doesn't exist */
}
// ALSO cleanup the session-specific directory (contains plans, tasks, etc.)
const sessionDir = path.join(
config.storage.getProjectTempDir(),
sessionId,
);
try {
await fs.rm(sessionDir, { recursive: true, force: true });
} catch {
/* ignore if doesn't exist */
}
}
if (config.getDebugMode()) {
@@ -735,5 +735,59 @@ describe('SettingsUtils', () => {
expect(result).toBe('false');
});
});
describe('getDisplayValue with units', () => {
it('should format percentage correctly when unit is %', () => {
vi.mocked(getSettingsSchema).mockReturnValue({
model: {
properties: {
compressionThreshold: {
type: 'number',
label: 'Context Compression Threshold',
category: 'Model',
requiresRestart: true,
default: 0.5,
unit: '%',
},
},
},
} as unknown as SettingsSchemaType);
const settings = makeMockSettings({
model: { compressionThreshold: 0.8 },
});
const result = getDisplayValue(
'model.compressionThreshold',
settings,
makeMockSettings({}),
);
expect(result).toBe('0.8 (80%)*');
});
it('should append unit for non-% units', () => {
vi.mocked(getSettingsSchema).mockReturnValue({
ui: {
properties: {
pollingInterval: {
type: 'number',
label: 'Polling Interval',
category: 'UI',
requiresRestart: false,
default: 60,
unit: 's',
},
},
},
} as unknown as SettingsSchemaType);
const settings = makeMockSettings({ ui: { pollingInterval: 30 } });
const result = getDisplayValue(
'ui.pollingInterval',
settings,
makeMockSettings({}),
);
expect(result).toBe('30s*');
});
});
});
});
+6 -1
View File
@@ -84,7 +84,7 @@ export function getDefaultValue(key: string): SettingsValue {
/**
* Get the effective default value for a setting, checking experiment values when available.
* For settings like compressionThreshold, this will return the experiment value if set,
* For settings like Context Compression Threshold, this will return the experiment value if set,
* otherwise falls back to the schema default.
*/
export function getEffectiveDefaultValue(
@@ -289,6 +289,11 @@ export function getDisplayValue(
valueString = option?.label ?? `${value}`;
}
if (definition?.unit === '%' && typeof value === 'number') {
valueString = `${value} (${Math.round(value * 100)}%)`;
} else if (definition?.unit) {
valueString = `${valueString}${definition.unit}`;
}
if (existsInScope) {
return `${valueString}*`;
}
@@ -0,0 +1,196 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
SettingScope,
type LoadedSettings,
type LoadableSettingScope,
} from '../config/settings.js';
import { enableSkill, disableSkill } from './skillSettings.js';
function createMockLoadedSettings(opts: {
userSettings?: Record<string, unknown>;
workspaceSettings?: Record<string, unknown>;
userPath?: string;
workspacePath?: string;
}): LoadedSettings {
const scopes: Record<
string,
{
settings: Record<string, unknown>;
originalSettings: Record<string, unknown>;
path: string;
}
> = {
[SettingScope.User]: {
settings: opts.userSettings ?? {},
originalSettings: opts.userSettings ?? {},
path: opts.userPath ?? '/home/user/.gemini/settings.json',
},
[SettingScope.Workspace]: {
settings: opts.workspaceSettings ?? {},
originalSettings: opts.workspaceSettings ?? {},
path: opts.workspacePath ?? '/project/.gemini/settings.json',
},
};
return {
forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
setValue: vi.fn(),
} as unknown as LoadedSettings;
}
describe('skillSettings', () => {
describe('skillStrategy (via enableSkill / disableSkill)', () => {
describe('enableSkill', () => {
it('should return no-op when the skill is not in any disabled list', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: [] } },
workspaceSettings: { skills: { disabled: [] } },
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('no-op');
expect(result.action).toBe('enable');
expect(result.skillName).toBe('my-skill');
expect(result.modifiedScopes).toHaveLength(0);
expect(settings.setValue).not.toHaveBeenCalled();
});
it('should return no-op when skills.disabled is undefined', () => {
const settings = createMockLoadedSettings({
userSettings: {},
workspaceSettings: {},
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('no-op');
expect(result.action).toBe('enable');
expect(result.modifiedScopes).toHaveLength(0);
});
it('should enable the skill when it is in the disabled list of one scope', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill'] } },
workspaceSettings: { skills: { disabled: [] } },
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('success');
expect(result.action).toBe('enable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(result.alreadyInStateScopes[0].scope).toBe(
SettingScope.Workspace,
);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
it('should enable the skill when it is in the disabled list of both scopes', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill', 'other-skill'] } },
workspaceSettings: { skills: { disabled: ['my-skill'] } },
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('success');
expect(result.modifiedScopes).toHaveLength(2);
expect(result.alreadyInStateScopes).toHaveLength(0);
expect(settings.setValue).toHaveBeenCalledTimes(2);
});
it('should not affect other skills in the disabled list', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill', 'keep-disabled'] } },
workspaceSettings: { skills: { disabled: [] } },
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('success');
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
});
describe('disableSkill', () => {
it('should return no-op when the skill is already in the disabled list', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill'] } },
});
const result = disableSkill(settings, 'my-skill', SettingScope.User);
expect(result.status).toBe('no-op');
expect(result.action).toBe('disable');
expect(result.skillName).toBe('my-skill');
expect(result.modifiedScopes).toHaveLength(0);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(settings.setValue).not.toHaveBeenCalled();
});
it('should disable the skill when it is not in the disabled list', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: [] } },
});
const result = disableSkill(settings, 'my-skill', SettingScope.User);
expect(result.status).toBe('success');
expect(result.action).toBe('disable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
it('should disable the skill when skills.disabled is undefined', () => {
const settings = createMockLoadedSettings({
userSettings: {},
});
const result = disableSkill(settings, 'my-skill', SettingScope.User);
expect(result.status).toBe('success');
expect(result.action).toBe('disable');
expect(result.modifiedScopes).toHaveLength(1);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
it('should return error for an invalid scope', () => {
const settings = createMockLoadedSettings({});
const result = disableSkill(settings, 'my-skill', SettingScope.Session);
expect(result.status).toBe('error');
expect(result.error).toContain('Invalid settings scope');
});
it('should disable in workspace and report user as already disabled', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill'] } },
workspaceSettings: { skills: { disabled: [] } },
});
const result = disableSkill(
settings,
'my-skill',
SettingScope.Workspace,
);
expect(result.status).toBe('success');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
});
});
});
});
+57 -113
View File
@@ -4,34 +4,58 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
SettingScope,
isLoadableSettingScope,
type LoadedSettings,
} from '../config/settings.js';
import type { SettingScope, LoadedSettings } from '../config/settings.js';
export interface ModifiedScope {
scope: SettingScope;
path: string;
}
import {
type FeatureActionResult,
type FeatureToggleStrategy,
enableFeature,
disableFeature,
} from './featureToggleUtils.js';
export type { ModifiedScope } from './featureToggleUtils.js';
export type SkillActionStatus = 'success' | 'no-op' | 'error';
/**
* Metadata representing the result of a skill settings operation.
*/
export interface SkillActionResult {
status: SkillActionStatus;
export interface SkillActionResult
extends Omit<FeatureActionResult, 'featureName'> {
skillName: string;
action: 'enable' | 'disable';
/** Scopes where the skill's state was actually changed. */
modifiedScopes: ModifiedScope[];
/** Scopes where the skill was already in the desired state. */
alreadyInStateScopes: ModifiedScope[];
/** Error message if status is 'error'. */
error?: string;
}
const skillStrategy: FeatureToggleStrategy = {
needsEnabling: (settings, scope, skillName) => {
const scopeDisabled = settings.forScope(scope).settings.skills?.disabled;
return !!scopeDisabled?.includes(skillName);
},
enable: (settings, scope, skillName) => {
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
const newDisabled = currentScopeDisabled.filter(
(name) => name !== skillName,
);
settings.setValue(scope, 'skills.disabled', newDisabled);
},
isExplicitlyDisabled: (settings, scope, skillName) => {
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
return currentScopeDisabled.includes(skillName);
},
disable: (settings, scope, skillName) => {
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
// The generic utility checks isExplicitlyDisabled before calling this,
// but just to be safe and idempotent, we check or we assume the utility did its job.
// The utility does check isExplicitlyDisabled first.
// So we can blindly add it, but since we are modifying an array, pushing is fine.
// However, if we assume purely that we must disable it:
const newDisabled = [...currentScopeDisabled, skillName];
settings.setValue(scope, 'skills.disabled', newDisabled);
},
};
/**
* Enables a skill by removing it from all writable disabled lists (User and Workspace).
*/
@@ -39,51 +63,14 @@ export function enableSkill(
settings: LoadedSettings,
skillName: string,
): SkillActionResult {
const writableScopes = [SettingScope.Workspace, SettingScope.User];
const foundInDisabledScopes: ModifiedScope[] = [];
const alreadyEnabledScopes: ModifiedScope[] = [];
for (const scope of writableScopes) {
if (isLoadableSettingScope(scope)) {
const scopePath = settings.forScope(scope).path;
const scopeDisabled = settings.forScope(scope).settings.skills?.disabled;
if (scopeDisabled?.includes(skillName)) {
foundInDisabledScopes.push({ scope, path: scopePath });
} else {
alreadyEnabledScopes.push({ scope, path: scopePath });
}
}
}
if (foundInDisabledScopes.length === 0) {
return {
status: 'no-op',
skillName,
action: 'enable',
modifiedScopes: [],
alreadyInStateScopes: alreadyEnabledScopes,
};
}
const modifiedScopes: ModifiedScope[] = [];
for (const { scope, path } of foundInDisabledScopes) {
if (isLoadableSettingScope(scope)) {
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
const newDisabled = currentScopeDisabled.filter(
(name) => name !== skillName,
);
settings.setValue(scope, 'skills.disabled', newDisabled);
modifiedScopes.push({ scope, path });
}
}
return {
status: 'success',
const { featureName, ...rest } = enableFeature(
settings,
skillName,
action: 'enable',
modifiedScopes,
alreadyInStateScopes: alreadyEnabledScopes,
skillStrategy,
);
return {
...rest,
skillName: featureName,
};
}
@@ -95,57 +82,14 @@ export function disableSkill(
skillName: string,
scope: SettingScope,
): SkillActionResult {
if (!isLoadableSettingScope(scope)) {
return {
status: 'error',
skillName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [],
error: `Invalid settings scope: ${scope}`,
};
}
const scopePath = settings.forScope(scope).path;
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
if (currentScopeDisabled.includes(skillName)) {
return {
status: 'no-op',
skillName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [{ scope, path: scopePath }],
};
}
// Check if it's already disabled in the other writable scope
const otherScope =
scope === SettingScope.Workspace
? SettingScope.User
: SettingScope.Workspace;
const alreadyDisabledInOther: ModifiedScope[] = [];
if (isLoadableSettingScope(otherScope)) {
const otherScopeDisabled =
settings.forScope(otherScope).settings.skills?.disabled;
if (otherScopeDisabled?.includes(skillName)) {
alreadyDisabledInOther.push({
scope: otherScope,
path: settings.forScope(otherScope).path,
});
}
}
const newDisabled = [...currentScopeDisabled, skillName];
settings.setValue(scope, 'skills.disabled', newDisabled);
return {
status: 'success',
const { featureName, ...rest } = disableFeature(
settings,
skillName,
action: 'disable',
modifiedScopes: [{ scope, path: scopePath }],
alreadyInStateScopes: alreadyDisabledInOther,
scope,
skillStrategy,
);
return {
...rest,
skillName: featureName,
};
}
@@ -93,6 +93,10 @@ describe('GeminiAgent Session Resume', () => {
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
@@ -203,6 +207,10 @@ describe('GeminiAgent Session Resume', () => {
],
currentModeId: ApprovalMode.DEFAULT,
},
models: {
availableModels: expect.any(Array) as unknown,
currentModelId: 'gemini-pro',
},
});
// Verify resumeChat received the correct arguments
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandHandler } from './commandHandler.js';
import { describe, it, expect } from 'vitest';
describe('CommandHandler', () => {
it('parses commands correctly', () => {
const handler = new CommandHandler();
// @ts-expect-error - testing private method
const parse = (query: string) => handler.parseSlashCommand(query);
const memShow = parse('/memory show');
expect(memShow.commandToExecute?.name).toBe('memory show');
expect(memShow.args).toBe('');
const memAdd = parse('/memory add hello world');
expect(memAdd.commandToExecute?.name).toBe('memory add');
expect(memAdd.args).toBe('hello world');
const extList = parse('/extensions list');
expect(extList.commandToExecute?.name).toBe('extensions list');
const init = parse('/init');
expect(init.commandToExecute?.name).toBe('init');
});
});
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Command, CommandContext } from './commands/types.js';
import { CommandRegistry } from './commands/commandRegistry.js';
import { MemoryCommand } from './commands/memory.js';
import { ExtensionsCommand } from './commands/extensions.js';
import { InitCommand } from './commands/init.js';
import { RestoreCommand } from './commands/restore.js';
export class CommandHandler {
private registry: CommandRegistry;
constructor() {
this.registry = CommandHandler.createRegistry();
}
private static createRegistry(): CommandRegistry {
const registry = new CommandRegistry();
registry.register(new MemoryCommand());
registry.register(new ExtensionsCommand());
registry.register(new InitCommand());
registry.register(new RestoreCommand());
return registry;
}
getAvailableCommands(): Array<{ name: string; description: string }> {
return this.registry.getAllCommands().map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
}
/**
* Parses and executes a command string if it matches a registered command.
* Returns true if a command was handled, false otherwise.
*/
async handleCommand(
commandText: string,
context: CommandContext,
): Promise<boolean> {
const { commandToExecute, args } = this.parseSlashCommand(commandText);
if (commandToExecute) {
await this.runCommand(commandToExecute, args, context);
return true;
}
return false;
}
private async runCommand(
commandToExecute: Command,
args: string,
context: CommandContext,
): Promise<void> {
try {
const result = await commandToExecute.execute(
context,
args ? args.split(/\s+/) : [],
);
let messageContent = '';
if (typeof result.data === 'string') {
messageContent = result.data;
} else if (
typeof result.data === 'object' &&
result.data !== null &&
'content' in result.data
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
messageContent = (result.data as Record<string, any>)[
'content'
] as string;
} else {
messageContent = JSON.stringify(result.data, null, 2);
}
await context.sendMessage(messageContent);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
await context.sendMessage(`Error: ${errorMessage}`);
}
}
/**
* Parses a raw slash command string into its matching headless command and arguments.
* Mirrors `packages/cli/src/utils/commands.ts` logic.
*/
private parseSlashCommand(query: string): {
commandToExecute: Command | undefined;
args: string;
} {
const trimmed = query.trim();
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p);
let currentCommands = this.registry.getAllCommands();
let commandToExecute: Command | undefined;
let pathIndex = 0;
for (const part of commandPath) {
const foundCommand = currentCommands.find((cmd) => {
const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
return (
cmd.name === part ||
cmd.name === expectedName ||
cmd.aliases?.includes(part) ||
cmd.aliases?.includes(expectedName)
);
});
if (foundCommand) {
commandToExecute = foundCommand;
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
} else {
break;
}
}
const args = parts.slice(pathIndex).join(' ');
return { commandToExecute, args };
}
}
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import type { Command } from './types.js';
export class CommandRegistry {
private readonly commands = new Map<string, Command>();
register(command: Command) {
if (this.commands.has(command.name)) {
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
return;
}
this.commands.set(command.name, command);
for (const subCommand of command.subCommands ?? []) {
this.register(subCommand);
}
}
get(commandName: string): Command | undefined {
return this.commands.get(commandName);
}
getAllCommands(): Command[] {
return [...this.commands.values()];
}
}
@@ -0,0 +1,428 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { listExtensions } from '@google/gemini-cli-core';
import { SettingScope } from '../../config/settings.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { getErrorMessage } from '../../utils/errors.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
import { stat } from 'node:fs/promises';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { Config } from '@google/gemini-cli-core';
export class ExtensionsCommand implements Command {
readonly name = 'extensions';
readonly description = 'Manage extensions.';
readonly subCommands = [
new ListExtensionsCommand(),
new ExploreExtensionsCommand(),
new EnableExtensionCommand(),
new DisableExtensionCommand(),
new InstallExtensionCommand(),
new LinkExtensionCommand(),
new UninstallExtensionCommand(),
new RestartExtensionCommand(),
new UpdateExtensionCommand(),
];
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ListExtensionsCommand().execute(context, _);
}
}
export class ListExtensionsCommand implements Command {
readonly name = 'extensions list';
readonly description = 'Lists all installed extensions.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensions = listExtensions(context.config);
const data = extensions.length ? extensions : 'No extensions installed.';
return { name: this.name, data };
}
}
export class ExploreExtensionsCommand implements Command {
readonly name = 'extensions explore';
readonly description = 'Explore available extensions.';
async execute(
_context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensionsUrl = 'https://geminicli.com/extensions/';
return {
name: this.name,
data: `View or install available extensions at ${extensionsUrl}`,
};
}
}
function getEnableDisableContext(
config: Config,
args: string[],
invocationName: string,
) {
const extensionManager = config.getExtensionLoader();
if (!(extensionManager instanceof ExtensionManager)) {
return {
error: `Cannot ${invocationName} extensions in this environment.`,
};
}
if (args.length === 0) {
return {
error: `Usage: /extensions ${invocationName} <extension> [--scope=<user|workspace|session>]`,
};
}
let scope = SettingScope.User;
if (args.includes('--scope=workspace') || args.includes('workspace')) {
scope = SettingScope.Workspace;
} else if (args.includes('--scope=session') || args.includes('session')) {
scope = SettingScope.Session;
}
const name = args.filter(
(a) =>
!a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
)[0];
let names: string[] = [];
if (name === '--all') {
let extensions = extensionManager.getExtensions();
if (invocationName === 'enable') {
extensions = extensions.filter((ext) => !ext.isActive);
}
if (invocationName === 'disable') {
extensions = extensions.filter((ext) => ext.isActive);
}
names = extensions.map((ext) => ext.name);
} else if (name) {
names = [name];
} else {
return { error: 'No extension name provided.' };
}
return { extensionManager, names, scope };
}
export class EnableExtensionCommand implements Command {
readonly name = 'extensions enable';
readonly description = 'Enable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.config,
args,
'enable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.enableExtension(name, scope);
output.push(`Extension "${name}" enabled for scope "${scope}".`);
const extension = extensionManager
.getExtensions()
.find((e) => e.name === name);
if (extension?.mcpServers) {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const mcpClientManager = context.config.getMcpClientManager();
const enabledServers = await mcpEnablementManager.autoEnableServers(
Object.keys(extension.mcpServers),
);
if (mcpClientManager && enabledServers.length > 0) {
const restartPromises = enabledServers.map((serverName) =>
mcpClientManager.restartServer(serverName).catch((error) => {
output.push(
`Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
);
}),
);
await Promise.all(restartPromises);
output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
}
}
} catch (e) {
output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class DisableExtensionCommand implements Command {
readonly name = 'extensions disable';
readonly description = 'Disable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.config,
args,
'disable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.disableExtension(name, scope);
output.push(`Extension "${name}" disabled for scope "${scope}".`);
} catch (e) {
output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class InstallExtensionCommand implements Command {
readonly name = 'extensions install';
readonly description = 'Install an extension from a git repo or local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot install extensions in this environment.',
};
}
const source = args.join(' ').trim();
if (!source) {
return { name: this.name, data: `Usage: /extensions install <source>` };
}
if (/[;&|`'"]/.test(source)) {
return {
name: this.name,
data: `Invalid source: contains disallowed characters.`,
};
}
try {
const installMetadata = await inferInstallMetadata(source);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
return {
name: this.name,
data: `Extension "${extension.name}" installed successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
};
}
}
}
export class LinkExtensionCommand implements Command {
readonly name = 'extensions link';
readonly description = 'Link an extension from a local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot link extensions in this environment.',
};
}
const sourceFilepath = args.join(' ').trim();
if (!sourceFilepath) {
return { name: this.name, data: `Usage: /extensions link <source>` };
}
try {
await stat(sourceFilepath);
} catch (_error) {
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
}
try {
const extension = await extensionLoader.installOrUpdateExtension({
source: sourceFilepath,
type: 'link',
});
return {
name: this.name,
data: `Extension "${extension.name}" linked successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to link extension: ${getErrorMessage(error)}`,
};
}
}
}
export class UninstallExtensionCommand implements Command {
readonly name = 'extensions uninstall';
readonly description = 'Uninstall an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot uninstall extensions in this environment.',
};
}
const name = args.join(' ').trim();
if (!name) {
return {
name: this.name,
data: `Usage: /extensions uninstall <extension-name>`,
};
}
try {
await extensionLoader.uninstallExtension(name, false);
return {
name: this.name,
data: `Extension "${name}" uninstalled successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to uninstall extension "${name}": ${getErrorMessage(error)}`,
};
}
}
}
export class RestartExtensionCommand implements Command {
readonly name = 'extensions restart';
readonly description = 'Restart an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot restart extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions restart <extension-names>|--all',
};
}
let extensionsToRestart = extensionLoader
.getExtensions()
.filter((e) => e.isActive);
if (names) {
extensionsToRestart = extensionsToRestart.filter((e) =>
names.includes(e.name),
);
}
if (extensionsToRestart.length === 0) {
return {
name: this.name,
data: 'No active extensions matched the request.',
};
}
const output: string[] = [];
for (const extension of extensionsToRestart) {
try {
await extensionLoader.restartExtension(extension);
output.push(`Restarted "${extension.name}".`);
} catch (e) {
output.push(
`Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
);
}
}
return { name: this.name, data: output.join('\n') };
}
}
export class UpdateExtensionCommand implements Command {
readonly name = 'extensions update';
readonly description = 'Update an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot update extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions update <extension-names>|--all',
};
}
return {
name: this.name,
data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
};
}
}
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { performInit } from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class InitCommand implements Command {
name = 'init';
description = 'Analyzes the project and creates a tailored GEMINI.md file';
requiresWorkspace = true;
async execute(
context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const targetDir = context.config.getTargetDir();
if (!targetDir) {
throw new Error('Command requires a workspace.');
}
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
const result = performInit(fs.existsSync(geminiMdPath));
switch (result.type) {
case 'message':
return {
name: this.name,
data: result,
};
case 'submit_prompt':
fs.writeFileSync(geminiMdPath, '', 'utf8');
if (typeof result.content !== 'string') {
throw new Error('Init command content must be a string.');
}
// Inform the user since we can't trigger the UI-based interactive agent loop here directly.
// We output the prompt text they can use to re-trigger the generation manually,
// or just seed the GEMINI.md file as we've done above.
return {
name: this.name,
data: {
type: 'message',
messageType: 'info',
content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`,
},
};
default:
throw new Error('Unknown result type from performInit');
}
}
}
@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
addMemory,
listMemoryFiles,
refreshMemory,
showMemory,
} from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
};
export class MemoryCommand implements Command {
readonly name = 'memory';
readonly description = 'Manage memory.';
readonly subCommands = [
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
];
readonly requiresWorkspace = true;
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ShowMemoryCommand().execute(context, _);
}
}
export class ShowMemoryCommand implements Command {
readonly name = 'memory show';
readonly description = 'Shows the current memory contents.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = showMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class RefreshMemoryCommand implements Command {
readonly name = 'memory refresh';
readonly aliases = ['memory reload'];
readonly description = 'Refreshes the memory from the source.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = await refreshMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class ListMemoryCommand implements Command {
readonly name = 'memory list';
readonly description = 'Lists the paths of the GEMINI.md files in use.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = listMemoryFiles(context.config);
return { name: this.name, data: result.content };
}
}
export class AddMemoryCommand implements Command {
readonly name = 'memory add';
readonly description = 'Add content to the memory.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const textToAdd = args.join(' ').trim();
const result = addMemory(textToAdd);
if (result.type === 'message') {
return { name: this.name, data: result.content };
}
const toolRegistry = context.config.getToolRegistry();
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
const signal = abortController.signal;
await context.sendMessage(`Saving memory via ${result.toolName}...`);
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
});
await refreshMemory(context.config);
return {
name: this.name,
data: `Added memory: "${textToAdd}"`,
};
} else {
return {
name: this.name,
data: `Error: Tool ${result.toolName} not found.`,
};
}
}
}
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
getCheckpointInfoList,
getToolCallDataSchema,
isNodeError,
performRestore,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class RestoreCommand implements Command {
readonly name = 'restore';
readonly description =
'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
readonly requiresWorkspace = true;
readonly subCommands = [new ListCheckpointsCommand()];
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const { config, git: gitService } = context;
const argsStr = args.join(' ');
try {
if (!argsStr) {
return await new ListCheckpointsCommand().execute(context);
}
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const selectedFile = argsStr.endsWith('.json')
? argsStr
: `${argsStr}.json`;
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
const filePath = path.join(checkpointDir, selectedFile);
let data: string;
try {
data = await fs.readFile(filePath, 'utf-8');
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return {
name: this.name,
data: `File not found: ${selectedFile}`,
};
}
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const toolCallData = JSON.parse(data);
const ToolCallDataSchema = getToolCallDataSchema();
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
if (!parseResult.success) {
return {
name: this.name,
data: 'Checkpoint file is invalid or corrupted.',
};
}
const restoreResultGenerator = performRestore(
parseResult.data,
gitService,
);
const restoreResult = [];
for await (const result of restoreResultGenerator) {
restoreResult.push(result);
}
// Format the result nicely since Zed just dumps data
const formattedResult = restoreResult
.map((r) => {
if (r.type === 'message') {
return `[${r.messageType.toUpperCase()}] ${r.content}`;
} else if (r.type === 'load_history') {
return `Loaded history with ${r.clientHistory.length} messages.`;
}
return `Restored: ${JSON.stringify(r)}`;
})
.join('\n');
return {
name: this.name,
data: formattedResult,
};
} catch (error) {
return {
name: this.name,
data: `An unexpected error occurred during restore: ${error}`,
};
}
}
}
export class ListCheckpointsCommand implements Command {
readonly name = 'restore list';
readonly description = 'Lists all available checkpoints.';
async execute(context: CommandContext): Promise<CommandExecutionResponse> {
const { config } = context;
try {
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
try {
await fs.mkdir(checkpointDir, { recursive: true });
} catch (_e) {
// Ignore
}
const files = await fs.readdir(checkpointDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
if (jsonFiles.length === 0) {
return { name: this.name, data: 'No checkpoints found.' };
}
const checkpointFiles = new Map<string, string>();
for (const file of jsonFiles) {
const filePath = path.join(checkpointDir, file);
const data = await fs.readFile(filePath, 'utf-8');
checkpointFiles.set(file, data);
}
const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
const formatted = checkpointInfoList
.map((info) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const i = info as Record<string, any>;
const fileName = String(i['fileName'] || 'Unknown');
const toolName = String(i['toolName'] || 'Unknown');
const status = String(i['status'] || 'Unknown');
const timestamp = new Date(
Number(i['timestamp']) || 0,
).toLocaleString();
return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
})
.join('\n');
return {
name: this.name,
data: `Available Checkpoints:\n${formatted}`,
};
} catch (_error) {
return {
name: this.name,
data: 'An unexpected error occurred while listing checkpoints.',
};
}
}
}
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, GitService } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
export interface CommandContext {
config: Config;
settings: LoadedSettings;
git?: GitService;
sendMessage: (text: string) => Promise<void>;
}
export interface CommandArgument {
readonly name: string;
readonly description: string;
readonly isRequired?: boolean;
}
export interface Command {
readonly name: string;
readonly aliases?: string[];
readonly description: string;
readonly arguments?: CommandArgument[];
readonly subCommands?: Command[];
readonly requiresWorkspace?: boolean;
execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse>;
}
export interface CommandExecutionResponse {
readonly name: string;
readonly data: unknown;
}
@@ -15,6 +15,7 @@ import {
type Mocked,
} from 'vitest';
import { GeminiAgent, Session } from './zedIntegration.js';
import type { CommandHandler } from './commandHandler.js';
import * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
@@ -26,6 +27,7 @@ import {
type Config,
type MessageBus,
LlmRole,
type GitService,
} from '@google/gemini-cli-core';
import {
SettingScope,
@@ -62,7 +64,33 @@ vi.mock('node:path', async (importOriginal) => {
};
});
// Mock ReadManyFilesTool
vi.mock('../ui/commands/memoryCommand.js', () => ({
memoryCommand: {
name: 'memory',
action: vi.fn(),
},
}));
vi.mock('../ui/commands/extensionsCommand.js', () => ({
extensionsCommand: vi.fn().mockReturnValue({
name: 'extensions',
action: vi.fn(),
}),
}));
vi.mock('../ui/commands/restoreCommand.js', () => ({
restoreCommand: vi.fn().mockReturnValue({
name: 'restore',
action: vi.fn(),
}),
}));
vi.mock('../ui/commands/initCommand.js', () => ({
initCommand: {
name: 'init',
action: vi.fn(),
},
}));
vi.mock(
'@google/gemini-cli-core',
async (
@@ -145,6 +173,9 @@ describe('GeminiAgent', () => {
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
mockSettings = {
merged: {
@@ -225,6 +256,7 @@ describe('GeminiAgent', () => {
});
it('should create a new session', async () => {
vi.useFakeTimers();
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
@@ -237,6 +269,17 @@ describe('GeminiAgent', () => {
expect(loadCliConfig).toHaveBeenCalled();
expect(mockConfig.initialize).toHaveBeenCalled();
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
// Verify deferred call
await vi.runAllTimersAsync();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
}),
}),
);
vi.useRealTimers();
});
it('should return modes without plan mode when plan is disabled', async () => {
@@ -263,6 +306,38 @@ describe('GeminiAgent', () => {
],
currentModeId: 'default',
});
expect(response.models).toEqual({
availableModels: expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-2.5',
name: 'Auto (Gemini 2.5)',
}),
]),
currentModelId: 'gemini-pro',
});
});
it('should include preview models when user has access', async () => {
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
const response = await agent.newSession({
cwd: '/tmp',
mcpServers: [],
});
expect(response.models?.availableModels).toEqual(
expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-3',
name: expect.stringContaining('Auto'),
}),
expect.objectContaining({
modelId: 'gemini-3.1-pro-preview',
name: 'gemini-3.1-pro-preview',
}),
]),
);
});
it('should return modes with plan mode when plan is enabled', async () => {
@@ -290,6 +365,15 @@ describe('GeminiAgent', () => {
],
currentModeId: 'plan',
});
expect(response.models).toEqual({
availableModels: expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-2.5',
name: 'Auto (Gemini 2.5)',
}),
]),
currentModelId: 'gemini-pro',
});
});
it('should fail session creation if Gemini API key is missing', async () => {
@@ -439,6 +523,32 @@ describe('GeminiAgent', () => {
}),
).rejects.toThrow('Session not found: unknown');
});
it('should delegate setModel to session (unstable)', async () => {
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const session = (
agent as unknown as { sessions: Map<string, Session> }
).sessions.get('test-session-id');
if (!session) throw new Error('Session not found');
session.setModel = vi.fn().mockReturnValue({});
const result = await agent.unstable_setSessionModel({
sessionId: 'test-session-id',
modelId: 'gemini-2.0-pro-exp',
});
expect(session.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
expect(result).toEqual({});
});
it('should throw error when setting model on non-existent session (unstable)', async () => {
await expect(
agent.unstable_setSessionModel({
sessionId: 'unknown',
modelId: 'gemini-2.0-pro-exp',
}),
).rejects.toThrow('Session not found: unknown');
});
});
describe('Session', () => {
@@ -477,6 +587,7 @@ describe('Session', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getMcpServers: vi.fn(),
getFileService: vi.fn().mockReturnValue({
shouldIgnoreFile: vi.fn().mockReturnValue(false),
}),
@@ -486,7 +597,10 @@ describe('Session', () => {
getDebugMode: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
setModel: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
} as unknown as Mocked<Config>;
mockConnection = {
@@ -495,13 +609,38 @@ describe('Session', () => {
sendNotification: vi.fn(),
} as unknown as Mocked<acp.AgentSideConnection>;
session = new Session('session-1', mockChat, mockConfig, mockConnection);
session = new Session('session-1', mockChat, mockConfig, mockConnection, {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
merged: { settings: {} },
errors: [],
} as unknown as LoadedSettings);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should send available commands', async () => {
await session.sendAvailableCommands();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
availableCommands: expect.arrayContaining([
expect.objectContaining({ name: 'memory' }),
expect.objectContaining({ name: 'extensions' }),
expect.objectContaining({ name: 'restore' }),
expect.objectContaining({ name: 'init' }),
]),
}),
}),
);
});
it('should await MCP initialization before processing a prompt', async () => {
const stream = createMockStream([
{
@@ -551,6 +690,113 @@ describe('Session', () => {
expect(result).toEqual({ stopReason: 'end_turn' });
});
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/memory view' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/memory view',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /extensions command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/extensions list' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/extensions list',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /extensions explore command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/extensions explore' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/extensions explore',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /restore command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/restore' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/restore',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /init command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/init' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object));
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle tool calls', async () => {
const stream1 = createMockStream([
{
@@ -1207,4 +1453,30 @@ describe('Session', () => {
'Invalid or unavailable mode: invalid-mode',
);
});
it('should set model on config', () => {
session.setModel('gemini-2.0-flash-exp');
expect(mockConfig.setModel).toHaveBeenCalledWith('gemini-2.0-flash-exp');
});
it('should handle unquoted commands from autocomplete (with empty leading parts)', async () => {
// Mock handleCommand to verify it gets called
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
await session.prompt({
sessionId: 'session-1',
prompt: [
{ type: 'text', text: '' },
{ type: 'text', text: '/memory' },
],
});
expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything());
});
});
@@ -4,15 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
GeminiChat,
ToolResult,
ToolCallConfirmationDetails,
FilterFilesOptions,
ConversationRecord,
} from '@google/gemini-cli-core';
import {
type Config,
type GeminiChat,
type ToolResult,
type ToolCallConfirmationDetails,
type FilterFilesOptions,
type ConversationRecord,
CoreToolCallStatus,
AuthType,
logToolCall,
@@ -39,6 +37,16 @@ import {
ApprovalMode,
getVersion,
convertSessionToClientHistory,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
getDisplayString,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -61,11 +69,14 @@ import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
import { CommandHandler } from './commandHandler.js';
export async function runZedIntegration(
config: Config,
settings: LoadedSettings,
argv: CliArgs,
) {
// ... (skip unchanged lines) ...
const { stdout: workingStdout } = createWorkingStdio();
const stdout = Writable.toWeb(workingStdout) as WritableStream;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -240,16 +251,37 @@ export class GeminiAgent {
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(sessionId, chat, config, this.connection);
const session = new Session(
sessionId,
chat,
config,
this.connection,
this.settings,
);
this.sessions.set(sessionId, session);
return {
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
loadedSettings,
);
const response = {
sessionId,
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
async loadSession({
@@ -291,6 +323,7 @@ export class GeminiAgent {
geminiClient.getChat(),
config,
this.connection,
this.settings,
);
this.sessions.set(sessionId, session);
@@ -298,12 +331,27 @@ export class GeminiAgent {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
return {
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
this.settings,
);
const response = {
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
private async initializeSessionConfig(
@@ -414,16 +462,28 @@ export class GeminiAgent {
}
return session.setMode(params.modeId);
}
async unstable_setSessionModel(
params: acp.SetSessionModelRequest,
): Promise<acp.SetSessionModelResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return session.setModel(params.modelId);
}
}
export class Session {
private pendingPrompt: AbortController | null = null;
private commandHandler = new CommandHandler();
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly config: Config,
private readonly connection: acp.AgentSideConnection,
private readonly settings: LoadedSettings,
) {}
async cancelPendingPrompt(): Promise<void> {
@@ -446,6 +506,27 @@ export class Session {
return {};
}
private getAvailableCommands() {
return this.commandHandler.getAvailableCommands();
}
async sendAvailableCommands(): Promise<void> {
const availableCommands = this.getAvailableCommands().map((command) => ({
name: command.name,
description: command.description,
}));
await this.sendUpdate({
sessionUpdate: 'available_commands_update',
availableCommands,
});
}
setModel(modelId: acp.ModelId): acp.SetSessionModelResponse {
this.config.setModel(modelId);
return {};
}
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
for (const msg of messages) {
const contentString = partListUnionToString(msg.content);
@@ -528,6 +609,41 @@ export class Session {
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
// Command interception
let commandText = '';
for (const part of parts) {
if (typeof part === 'object' && part !== null) {
if ('text' in part) {
// It is a text part
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
const text = (part as any).text;
if (typeof text === 'string') {
commandText += text;
}
} else {
// Non-text part (image, embedded resource)
// Stop looking for command
break;
}
}
}
commandText = commandText.trim();
if (
commandText &&
(commandText.startsWith('/') || commandText.startsWith('$'))
) {
// If we found a command, pass it to handleCommand
// Note: handleCommand currently expects `commandText` to be the command string
// It uses `parts` argument but effectively ignores it in current implementation
const handled = await this.handleCommand(commandText, parts);
if (handled) {
return { stopReason: 'end_turn' };
}
}
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
@@ -627,9 +743,28 @@ export class Session {
return { stopReason: 'end_turn' };
}
private async sendUpdate(
update: acp.SessionNotification['update'],
): Promise<void> {
private async handleCommand(
commandText: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parts: Part[],
): Promise<boolean> {
const gitService = await this.config.getGitService();
const commandContext = {
config: this.config,
settings: this.settings,
git: gitService,
sendMessage: async (text: string) => {
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
});
},
};
return this.commandHandler.handleCommand(commandText, commandContext);
}
private async sendUpdate(update: acp.SessionUpdate): Promise<void> {
const params: acp.SessionNotification = {
sessionId: this.id,
update,
@@ -1377,3 +1512,94 @@ function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
return modes;
}
function buildAvailableModels(
config: Config,
settings: LoadedSettings,
): {
availableModels: Array<{
modelId: string;
name: string;
description?: string;
}>;
currentModelId: string;
} {
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
const mainOptions = [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
description:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
},
];
if (shouldShowPreviewModels) {
mainOptions.unshift({
value: PREVIEW_GEMINI_MODEL_AUTO,
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
description: useGemini31
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
});
}
const manualOptions = [
{
value: DEFAULT_GEMINI_MODEL,
title: getDisplayString(DEFAULT_GEMINI_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
},
];
if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
: PREVIEW_GEMINI_MODEL;
const previewProValue = useCustomToolModel
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
manualOptions.unshift(
{
value: previewProValue,
title: getDisplayString(previewProModel),
},
{
value: PREVIEW_GEMINI_FLASH_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
},
);
}
const scaleOptions = (
options: Array<{ value: string; title: string; description?: string }>,
) =>
options.map((o) => ({
modelId: o.value,
name: o.title,
description: o.description,
}));
return {
availableModels: [
...scaleOptions(mainOptions),
...scaleOptions(manualOptions),
],
currentModelId: preferredModel,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+61
View File
@@ -10,6 +10,7 @@ import {
extractIdsFromResponse,
isTerminalState,
A2AResultReassembler,
AUTH_REQUIRED_MSG,
} from './a2aUtils.js';
import type { SendMessageResult } from './a2a-client-manager.js';
import type {
@@ -285,6 +286,66 @@ describe('a2aUtils', () => {
);
});
it('should handle auth-required state with a message', () => {
const reassembler = new A2AResultReassembler();
reassembler.update({
kind: 'status-update',
status: {
state: 'auth-required',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'I need your permission.' }],
} as Message,
},
} as unknown as SendMessageResult);
expect(reassembler.toString()).toContain('I need your permission.');
expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG);
});
it('should handle auth-required state without relying on metadata', () => {
const reassembler = new A2AResultReassembler();
reassembler.update({
kind: 'status-update',
status: {
state: 'auth-required',
},
} as unknown as SendMessageResult);
expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG);
});
it('should not duplicate the auth instruction OR agent message if multiple identical auth-required chunks arrive', () => {
const reassembler = new A2AResultReassembler();
const chunk = {
kind: 'status-update',
status: {
state: 'auth-required',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'You need to login here.' }],
} as Message,
},
} as unknown as SendMessageResult;
reassembler.update(chunk);
// Simulate multiple updates with the same overall state
reassembler.update(chunk);
reassembler.update(chunk);
const output = reassembler.toString();
// The substring should only appear exactly once
expect(output.split(AUTH_REQUIRED_MSG).length - 1).toBe(1);
// Crucially, the agent's actual custom message should ALSO only appear exactly once
expect(output.split('You need to login here.').length - 1).toBe(1);
});
it('should fallback to history in a task chunk if no message or artifacts exist and task is terminal', () => {
const reassembler = new A2AResultReassembler();
+15
View File
@@ -16,6 +16,8 @@ import type {
} from '@a2a-js/sdk';
import type { SendMessageResult } from './a2a-client-manager.js';
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
/**
* Reassembles incremental A2A streaming updates into a coherent result.
* Shows sequential status/messages followed by all reassembled artifacts.
@@ -33,6 +35,7 @@ export class A2AResultReassembler {
switch (chunk.kind) {
case 'status-update':
this.appendStateInstructions(chunk.status?.state);
this.pushMessage(chunk.status?.message);
break;
@@ -65,6 +68,7 @@ export class A2AResultReassembler {
break;
case 'task':
this.appendStateInstructions(chunk.status?.state);
this.pushMessage(chunk.status?.message);
if (chunk.artifacts) {
for (const art of chunk.artifacts) {
@@ -106,6 +110,17 @@ export class A2AResultReassembler {
}
}
private appendStateInstructions(state: TaskState | undefined) {
if (state !== 'auth-required') {
return;
}
// Prevent duplicate instructions if multiple chunks report auth-required
if (!this.messageLog.includes(AUTH_REQUIRED_MSG)) {
this.messageLog.push(AUTH_REQUIRED_MSG);
}
}
private pushMessage(message: Message | undefined) {
if (!message) return;
const text = extractPartsText(message.parts, '\n');
+11 -3
View File
@@ -508,6 +508,16 @@ export class CodeAssistServer implements ContentGenerator {
}
interface VpcScErrorResponse {
response?: {
data?: {
error?: {
details?: unknown[];
};
};
};
}
function isVpcScErrorResponse(error: unknown): error is VpcScErrorResponse & {
response: {
data: {
error: {
@@ -515,9 +525,7 @@ interface VpcScErrorResponse {
};
};
};
}
function isVpcScErrorResponse(error: unknown): error is VpcScErrorResponse {
} {
return (
!!error &&
typeof error === 'object' &&
+24 -4
View File
@@ -2950,9 +2950,11 @@ describe('Plans Directory Initialization', () => {
afterEach(() => {
vi.mocked(fs.promises.mkdir).mockRestore();
vi.mocked(fs.promises.access).mockRestore?.();
});
it('should create plans directory and add it to workspace context when plan is enabled', async () => {
it('should add plans directory to workspace context if it exists', async () => {
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
const config = new Config({
...baseParams,
plan: true,
@@ -2961,14 +2963,32 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, {
recursive: true,
});
// Should NOT create the directory eagerly
expect(fs.promises.mkdir).not.toHaveBeenCalled();
// Should check if it exists
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should NOT add plans directory to workspace context if it does not exist', async () => {
vi.spyOn(fs.promises, 'access').mockRejectedValue({ code: 'ENOENT' });
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
});
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
const config = new Config({
...baseParams,
+18 -2
View File
@@ -339,6 +339,15 @@ export interface GeminiCLIExtension {
* Safety checkers contributed by this extension.
*/
checkers?: SafetyCheckerRule[];
/**
* Planning features configuration contributed by this extension.
*/
plan?: {
/**
* The directory where planning artifacts are stored.
*/
directory?: string;
};
}
export interface ExtensionInstallMetadata {
@@ -1093,8 +1102,15 @@ export class Config implements McpContext {
// Add plans directory to workspace context for plan file storage
if (this.planEnabled) {
const plansDir = this.storage.getPlansDir();
await fs.promises.mkdir(plansDir, { recursive: true });
this.workspaceContext.addDirectory(plansDir);
try {
await fs.promises.access(plansDir);
this.workspaceContext.addDirectory(plansDir);
} catch {
// Directory does not exist yet, so we don't add it to the workspace context.
// It will be created when the first plan is written. Since custom plan
// directories must be within the project root, they are automatically
// covered by the project-wide file discovery once created.
}
}
// Initialize centralized FileDiscoveryService
+42 -22
View File
@@ -20,7 +20,10 @@ import { ToolErrorType } from '../tools/tool-error.js';
import { ToolCallEvent } from '../telemetry/types.js';
import { runInDevTraceSpan } from '../telemetry/trace.js';
import { ToolModificationHandler } from '../scheduler/tool-modifier.js';
import { getToolSuggestion } from '../utils/tool-utils.js';
import {
getToolSuggestion,
isToolCallResponseInfo,
} from '../utils/tool-utils.js';
import type { ToolConfirmationRequest } from '../confirmation-bus/types.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -225,32 +228,36 @@ export class CoreToolScheduler {
const durationMs = existingStartTime
? Date.now() - existingStartTime
: undefined;
return {
request: currentCall.request,
tool: toolInstance,
invocation,
status: CoreToolCallStatus.Success,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
response: auxiliaryData as ToolCallResponseInfo,
durationMs,
outcome,
approvalMode,
} as SuccessfulToolCall;
if (isToolCallResponseInfo(auxiliaryData)) {
return {
request: currentCall.request,
tool: toolInstance,
invocation,
status: CoreToolCallStatus.Success,
response: auxiliaryData,
durationMs,
outcome,
approvalMode,
} as SuccessfulToolCall;
}
throw new Error('Invalid response data for tool success');
}
case CoreToolCallStatus.Error: {
const durationMs = existingStartTime
? Date.now() - existingStartTime
: undefined;
return {
request: currentCall.request,
status: CoreToolCallStatus.Error,
tool: toolInstance,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
response: auxiliaryData as ToolCallResponseInfo,
durationMs,
outcome,
approvalMode,
} as ErroredToolCall;
if (isToolCallResponseInfo(auxiliaryData)) {
return {
request: currentCall.request,
status: CoreToolCallStatus.Error,
tool: toolInstance,
response: auxiliaryData,
durationMs,
outcome,
approvalMode,
} as ErroredToolCall;
}
throw new Error('Invalid response data for tool error');
}
case CoreToolCallStatus.AwaitingApproval:
return {
@@ -280,6 +287,19 @@ export class CoreToolScheduler {
? Date.now() - existingStartTime
: undefined;
if (isToolCallResponseInfo(auxiliaryData)) {
return {
request: currentCall.request,
tool: toolInstance,
invocation,
status: CoreToolCallStatus.Cancelled,
response: auxiliaryData,
durationMs,
outcome,
approvalMode,
} as CancelledToolCall;
}
// Preserve diff for cancelled edit operations
let resultDisplay: ToolResultDisplay | undefined = undefined;
if (currentCall.status === CoreToolCallStatus.AwaitingApproval) {
+10 -1
View File
@@ -72,7 +72,16 @@ priority = 70
modes = ["plan"]
[[rule]]
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search", "activate_skill"]
toolName = [
"glob",
"grep_search",
"list_directory",
"read_file",
"google_web_search",
"activate_skill",
"codebase_investigator",
"cli_help"
]
decision = "allow"
priority = 70
modes = ["plan"]
@@ -51,3 +51,8 @@ priority = 50
toolName = "google_web_search"
decision = "allow"
priority = 50
[[rule]]
toolName = ["codebase_investigator", "cli_help"]
decision = "allow"
priority = 50
@@ -1593,7 +1593,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.PLAN],
},
{
toolName: 'codebase_investigator',
toolName: 'unknown_subagent',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
},
@@ -1605,7 +1605,7 @@ describe('PolicyEngine', () => {
});
const fixedResult = await fixedEngine.check(
{ name: 'codebase_investigator' },
{ name: 'unknown_subagent' },
undefined,
);
+24 -5
View File
@@ -909,7 +909,7 @@ priority = 100
}
});
it('should override default subagent rules when in Plan Mode', async () => {
it('should override default subagent rules when in Plan Mode for unknown subagents', async () => {
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
const tempPolicyDir = await fs.mkdtemp(
@@ -931,9 +931,9 @@ priority = 100
approvalMode: ApprovalMode.PLAN,
});
// 3. Simulate a Subagent being registered (Dynamic Rule)
// 3. Simulate an unknown Subagent being registered (Dynamic Rule)
engine.addRule({
toolName: 'codebase_investigator',
toolName: 'unknown_subagent',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
source: 'AgentRegistry (Dynamic)',
@@ -942,13 +942,13 @@ priority = 100
// 4. Verify Behavior:
// The Plan Mode "Catch-All Deny" (from plan.toml) should override the Subagent Allow
const checkResult = await engine.check(
{ name: 'codebase_investigator' },
{ name: 'unknown_subagent' },
undefined,
);
expect(
checkResult.decision,
'Subagent should be DENIED in Plan Mode',
'Unknown subagent should be DENIED in Plan Mode',
).toBe(PolicyDecision.DENY);
// 5. Verify Explicit Allows still work
@@ -958,6 +958,25 @@ priority = 100
readResult.decision,
'Explicitly allowed tools (read_file) should be ALLOWED in Plan Mode',
).toBe(PolicyDecision.ALLOW);
// 6. Verify Built-in Research Subagents are ALLOWED
const codebaseResult = await engine.check(
{ name: 'codebase_investigator' },
undefined,
);
expect(
codebaseResult.decision,
'codebase_investigator should be ALLOWED in Plan Mode',
).toBe(PolicyDecision.ALLOW);
const cliHelpResult = await engine.check(
{ name: 'cli_help' },
undefined,
);
expect(
cliHelpResult.decision,
'cli_help should be ALLOWED in Plan Mode',
).toBe(PolicyDecision.ALLOW);
} finally {
await fs.rm(tempPolicyDir, { recursive: true, force: true });
}
@@ -946,7 +946,7 @@ describe('Scheduler (Orchestrator)', () => {
expect(mockStateManager.updateStatus).toHaveBeenCalledWith(
'call-1',
CoreToolCallStatus.Cancelled,
'Operation cancelled',
{ callId: 'call-1', responseParts: [] },
);
});
+1 -1
View File
@@ -741,7 +741,7 @@ export class Scheduler {
this.state.updateStatus(
callId,
CoreToolCallStatus.Cancelled,
'Operation cancelled',
result.response,
);
} else {
this.state.updateStatus(
+27 -15
View File
@@ -30,6 +30,7 @@ import {
MessageBusType,
type SerializableConfirmationDetails,
} from '../confirmation-bus/types.js';
import { isToolCallResponseInfo } from '../utils/tool-utils.js';
/**
* Handler for terminal tool calls.
@@ -127,7 +128,7 @@ export class SchedulerStateManager {
updateStatus(
callId: string,
status: CoreToolCallStatus.Cancelled,
data: string,
data: string | ToolCallResponseInfo,
): void;
updateStatus(
callId: string,
@@ -264,7 +265,7 @@ export class SchedulerStateManager {
): ToolCall {
switch (newStatus) {
case CoreToolCallStatus.Success: {
if (!this.isToolCallResponseInfo(auxiliaryData)) {
if (!isToolCallResponseInfo(auxiliaryData)) {
throw new Error(
`Invalid data for 'success' transition (callId: ${call.request.callId})`,
);
@@ -272,7 +273,7 @@ export class SchedulerStateManager {
return this.toSuccess(call, auxiliaryData);
}
case CoreToolCallStatus.Error: {
if (!this.isToolCallResponseInfo(auxiliaryData)) {
if (!isToolCallResponseInfo(auxiliaryData)) {
throw new Error(
`Invalid data for 'error' transition (callId: ${call.request.callId})`,
);
@@ -290,9 +291,12 @@ export class SchedulerStateManager {
case CoreToolCallStatus.Scheduled:
return this.toScheduled(call);
case CoreToolCallStatus.Cancelled: {
if (typeof auxiliaryData !== 'string') {
if (
typeof auxiliaryData !== 'string' &&
!isToolCallResponseInfo(auxiliaryData)
) {
throw new Error(
`Invalid reason (string) for 'cancelled' transition (callId: ${call.request.callId})`,
`Invalid reason (string) or response for 'cancelled' transition (callId: ${call.request.callId})`,
);
}
return this.toCancelled(call, auxiliaryData);
@@ -317,15 +321,6 @@ export class SchedulerStateManager {
}
}
private isToolCallResponseInfo(data: unknown): data is ToolCallResponseInfo {
return (
typeof data === 'object' &&
data !== null &&
'callId' in data &&
'responseParts' in data
);
}
private isExecutingToolCallPatch(
data: unknown,
): data is Partial<ExecutingToolCall> {
@@ -451,7 +446,10 @@ export class SchedulerStateManager {
};
}
private toCancelled(call: ToolCall, reason: string): CancelledToolCall {
private toCancelled(
call: ToolCall,
reason: string | ToolCallResponseInfo,
): CancelledToolCall {
this.validateHasToolAndInvocation(call, CoreToolCallStatus.Cancelled);
const startTime = 'startTime' in call ? call.startTime : undefined;
@@ -478,6 +476,20 @@ export class SchedulerStateManager {
}
}
if (isToolCallResponseInfo(reason)) {
return {
request: call.request,
tool: call.tool,
invocation: call.invocation,
status: CoreToolCallStatus.Cancelled,
response: reason,
durationMs: startTime ? Date.now() - startTime : undefined,
outcome: call.outcome,
schedulerId: call.schedulerId,
approvalMode: call.approvalMode,
};
}
const errorMessage = `[Operation Cancelled] Reason: ${reason}`;
return {
request: call.request,
@@ -534,4 +534,113 @@ describe('ToolExecutor', () => {
}),
);
});
it('should return cancelled result with partial output when signal is aborted', async () => {
const mockTool = new MockTool({
name: 'slowTool',
});
const invocation = mockTool.build({});
const partialOutput = 'Some partial output before cancellation';
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
async () => ({
llmContent: partialOutput,
returnDisplay: `[Cancelled] ${partialOutput}`,
}),
);
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-cancel-partial',
name: 'slowTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-cancel',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
const controller = new AbortController();
controller.abort();
const result = await executor.execute({
call: scheduledCall,
signal: controller.signal,
onUpdateToolCall: vi.fn(),
});
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
if (result.status === CoreToolCallStatus.Cancelled) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
expect(response).toEqual({
error: '[Operation Cancelled] User cancelled tool execution.',
output: partialOutput,
});
expect(result.response.resultDisplay).toBe(
`[Cancelled] ${partialOutput}`,
);
}
});
it('should truncate large shell output even on cancellation', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
const invocation = mockTool.build({});
const longOutput = 'This is a very long output that should be truncated.';
// 2. Mock execution returning long content
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: longOutput,
returnDisplay: longOutput,
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-trunc-cancel',
name: SHELL_TOOL_NAME,
args: { command: 'echo long' },
isClientInitiated: false,
prompt_id: 'prompt-trunc-cancel',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Abort immediately
const controller = new AbortController();
controller.abort();
// 4. Execute
const result = await executor.execute({
call: scheduledCall,
signal: controller.signal,
onUpdateToolCall: vi.fn(),
});
// 5. Verify Truncation Logic was applied in cancelled path
expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith(
longOutput,
SHELL_TOOL_NAME,
'call-trunc-cancel',
expect.any(String),
'test-session-id',
);
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
if (result.status === CoreToolCallStatus.Cancelled) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
expect(response['output']).toBe('TruncatedContent...');
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
}
});
});
+109 -56
View File
@@ -9,7 +9,6 @@ import type {
ToolCallResponseInfo,
ToolResult,
Config,
ToolResultDisplay,
ToolLiveOutput,
} from '../index.js';
import {
@@ -19,8 +18,8 @@ import {
runInDevTraceSpan,
} from '../index.js';
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { ShellToolInvocation } from '../tools/shell.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
import {
saveTruncatedToolOutput,
@@ -36,6 +35,7 @@ import type {
CancelledToolCall,
} from './types.js';
import { CoreToolCallStatus } from './types.js';
import type { PartListUnion, Part } from '@google/genai';
import {
GeminiCliOperation,
GEN_AI_TOOL_CALL_ID,
@@ -132,10 +132,10 @@ export class ToolExecutor {
const toolResult: ToolResult = await promise;
if (signal.aborted) {
completedToolCall = this.createCancelledResult(
completedToolCall = await this.createCancelledResult(
call,
'User cancelled tool execution.',
toolResult.returnDisplay,
toolResult,
);
} else if (toolResult.error === undefined) {
completedToolCall = await this.createSuccessResult(
@@ -163,7 +163,7 @@ export class ToolExecutor {
executionError.message.includes('Operation cancelled by user'));
if (signal.aborted || isAbortError) {
completedToolCall = this.createCancelledResult(
completedToolCall = await this.createCancelledResult(
call,
'User cancelled tool execution.',
);
@@ -186,56 +186,13 @@ export class ToolExecutor {
);
}
private createCancelledResult(
private async truncateOutputIfNeeded(
call: ToolCall,
reason: string,
resultDisplay?: ToolResultDisplay,
): CancelledToolCall {
const errorMessage = `[Operation Cancelled] ${reason}`;
const startTime = 'startTime' in call ? call.startTime : undefined;
if (!('tool' in call) || !('invocation' in call)) {
// This should effectively never happen in execution phase, but we handle
// it safely
throw new Error('Cancelled tool call missing tool/invocation references');
}
return {
status: CoreToolCallStatus.Cancelled,
request: call.request,
response: {
callId: call.request.callId,
responseParts: [
{
functionResponse: {
id: call.request.callId,
name: call.request.name,
response: { error: errorMessage },
},
},
],
resultDisplay,
error: undefined,
errorType: undefined,
contentLength: errorMessage.length,
},
tool: call.tool,
invocation: call.invocation,
durationMs: startTime ? Date.now() - startTime : undefined,
startTime,
endTime: Date.now(),
outcome: call.outcome,
};
}
private async createSuccessResult(
call: ToolCall,
toolResult: ToolResult,
): Promise<SuccessfulToolCall> {
let content = toolResult.llmContent;
let outputFile: string | undefined;
const toolName = call.request.originalRequestName || call.request.name;
content: PartListUnion,
): Promise<{ truncatedContent: PartListUnion; outputFile?: string }> {
const toolName = call.request.name;
const callId = call.request.callId;
let outputFile: string | undefined;
if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) {
const threshold = this.config.getTruncateToolOutputThreshold();
@@ -250,17 +207,23 @@ export class ToolExecutor {
this.config.getSessionId(),
);
outputFile = savedPath;
content = formatTruncatedToolOutput(content, outputFile, threshold);
const truncatedContent = formatTruncatedToolOutput(
content,
outputFile,
threshold,
);
logToolOutputTruncated(
this.config,
new ToolOutputTruncatedEvent(call.request.prompt_id, {
toolName,
originalContentLength,
truncatedContentLength: content.length,
truncatedContentLength: truncatedContent.length,
threshold,
}),
);
return { truncatedContent, outputFile };
}
} else if (
Array.isArray(content) &&
@@ -288,7 +251,12 @@ export class ToolExecutor {
outputFile,
threshold,
);
content[0] = { ...firstPart, text: truncatedText };
// We need to return a NEW array to avoid mutating the original toolResult if it matters,
// though here we are creating the response so it's probably fine to mutate or return new.
const truncatedContent: Part[] = [
{ ...firstPart, text: truncatedText },
];
logToolOutputTruncated(
this.config,
@@ -299,10 +267,95 @@ export class ToolExecutor {
threshold,
}),
);
return { truncatedContent, outputFile };
}
}
}
return { truncatedContent: content, outputFile };
}
private async createCancelledResult(
call: ToolCall,
reason: string,
toolResult?: ToolResult,
): Promise<CancelledToolCall> {
const errorMessage = `[Operation Cancelled] ${reason}`;
const startTime = 'startTime' in call ? call.startTime : undefined;
if (!('tool' in call) || !('invocation' in call)) {
// This should effectively never happen in execution phase, but we handle
// it safely
throw new Error('Cancelled tool call missing tool/invocation references');
}
let responseParts: Part[] = [];
let outputFile: string | undefined;
if (toolResult?.llmContent) {
// Attempt to truncate and save output if we have content, even in cancellation case
// This is to handle cases where the tool may have produced output before cancellation
const { truncatedContent: output, outputFile: truncatedOutputFile } =
await this.truncateOutputIfNeeded(call, toolResult?.llmContent);
outputFile = truncatedOutputFile;
responseParts = convertToFunctionResponse(
call.request.name,
call.request.callId,
output,
this.config.getActiveModel(),
);
// Inject the cancellation error into the response object
const mainPart = responseParts[0];
if (mainPart?.functionResponse?.response) {
const respObj = mainPart.functionResponse.response;
respObj['error'] = errorMessage;
}
} else {
responseParts = [
{
functionResponse: {
id: call.request.callId,
name: call.request.name,
response: { error: errorMessage },
},
},
];
}
return {
status: CoreToolCallStatus.Cancelled,
request: call.request,
response: {
callId: call.request.callId,
responseParts,
resultDisplay: toolResult?.returnDisplay,
error: undefined,
errorType: undefined,
outputFile,
contentLength: JSON.stringify(responseParts).length,
},
tool: call.tool,
invocation: call.invocation,
durationMs: startTime ? Date.now() - startTime : undefined,
startTime,
endTime: Date.now(),
outcome: call.outcome,
};
}
private async createSuccessResult(
call: ToolCall,
toolResult: ToolResult,
): Promise<SuccessfulToolCall> {
const { truncatedContent: content, outputFile } =
await this.truncateOutputIfNeeded(call, toolResult.llmContent);
const toolName = call.request.originalRequestName || call.request.name;
const callId = call.request.callId;
const response = convertToFunctionResponse(
toolName,
callId,
@@ -309,23 +309,33 @@ describe('ChatRecordingService', () => {
});
describe('deleteSession', () => {
it('should delete the session file and tool outputs if they exist', () => {
it('should delete the session file, tool outputs, session directory, and logs if they exist', () => {
const sessionId = 'test-session-id';
const chatsDir = path.join(testTempDir, 'chats');
const logsDir = path.join(testTempDir, 'logs');
const toolOutputsDir = path.join(testTempDir, 'tool-outputs');
const sessionDir = path.join(testTempDir, sessionId);
fs.mkdirSync(chatsDir, { recursive: true });
const sessionFile = path.join(chatsDir, 'test-session-id.json');
fs.mkdirSync(logsDir, { recursive: true });
fs.mkdirSync(toolOutputsDir, { recursive: true });
fs.mkdirSync(sessionDir, { recursive: true });
const sessionFile = path.join(chatsDir, `${sessionId}.json`);
fs.writeFileSync(sessionFile, '{}');
const toolOutputDir = path.join(
testTempDir,
'tool-outputs',
'session-test-session-id',
);
const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
fs.writeFileSync(logFile, '{}');
const toolOutputDir = path.join(toolOutputsDir, `session-${sessionId}`);
fs.mkdirSync(toolOutputDir, { recursive: true });
chatRecordingService.deleteSession('test-session-id');
chatRecordingService.deleteSession(sessionId);
expect(fs.existsSync(sessionFile)).toBe(false);
expect(fs.existsSync(logFile)).toBe(false);
expect(fs.existsSync(toolOutputDir)).toBe(false);
expect(fs.existsSync(sessionDir)).toBe(false);
});
it('should not throw if session file does not exist', () => {
@@ -569,6 +569,13 @@ export class ChatRecordingService {
fs.unlinkSync(sessionPath);
}
// Cleanup Activity logs in the project logs directory
const logsDir = path.join(tempDir, 'logs');
const logPath = path.join(logsDir, `session-${sessionId}.jsonl`);
if (fs.existsSync(logPath)) {
fs.unlinkSync(logPath);
}
// Cleanup tool outputs for this session
const safeSessionId = sanitizeFilenamePart(sessionId);
const toolOutputDir = path.join(
@@ -585,6 +592,13 @@ export class ChatRecordingService {
) {
fs.rmSync(toolOutputDir, { recursive: true, force: true });
}
// ALSO cleanup the session-specific directory (contains plans, tasks, etc.)
const sessionDir = path.join(tempDir, safeSessionId);
// Robustness: Ensure the path is strictly within the temp root
if (fs.existsSync(sessionDir) && sessionDir.startsWith(tempDir)) {
fs.rmSync(sessionDir, { recursive: true, force: true });
}
} catch (error) {
debugLogger.error('Error deleting session file.', error);
throw error;
+80 -1
View File
@@ -54,7 +54,7 @@ describe('generateValidName', () => {
it('should truncate long names', () => {
expect(generateValidName('x'.repeat(80))).toBe(
'xxxxxxxxxxxxxxxxxxxxxxxxxxxx___xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
);
});
@@ -933,3 +933,82 @@ describe('DiscoveredMCPTool', () => {
});
});
});
describe('MCP Tool Naming Regression Fixes', () => {
describe('generateValidName', () => {
it('should replace spaces with underscores', () => {
expect(generateValidName('My Tool')).toBe('My_Tool');
});
it('should allow colons', () => {
expect(generateValidName('namespace:tool')).toBe('namespace:tool');
});
it('should ensure name starts with a letter or underscore', () => {
expect(generateValidName('123-tool')).toBe('_123-tool');
expect(generateValidName('-tool')).toBe('_-tool');
expect(generateValidName('.tool')).toBe('_.tool');
});
it('should handle very long names by truncating in the middle', () => {
const longName = 'a'.repeat(40) + '__' + 'b'.repeat(40);
const result = generateValidName(longName);
expect(result.length).toBeLessThanOrEqual(63);
expect(result).toMatch(/^a{30}\.\.\.b{30}$/);
});
it('should handle very long names starting with a digit', () => {
const longName = '1' + 'a'.repeat(80);
const result = generateValidName(longName);
expect(result.length).toBeLessThanOrEqual(63);
expect(result.startsWith('_1')).toBe(true);
});
});
describe('DiscoveredMCPTool qualified names', () => {
it('should generate a valid qualified name even with spaces in server name', () => {
const tool = new DiscoveredMCPTool(
{} as any,
'My Server',
'my-tool',
'desc',
{},
{} as any,
);
const qn = tool.getFullyQualifiedName();
expect(qn).toBe('My_Server__my-tool');
});
it('should handle long server and tool names in qualified name', () => {
const serverName = 'a'.repeat(40);
const toolName = 'b'.repeat(40);
const tool = new DiscoveredMCPTool(
{} as any,
serverName,
toolName,
'desc',
{},
{} as any,
);
const qn = tool.getFullyQualifiedName();
expect(qn.length).toBeLessThanOrEqual(63);
expect(qn).toContain('...');
});
it('should handle server names starting with digits', () => {
const tool = new DiscoveredMCPTool(
{} as any,
'123-server',
'tool',
'desc',
{},
{} as any,
);
const qn = tool.getFullyQualifiedName();
expect(qn).toBe('_123-server__tool');
});
});
});
+29 -11
View File
@@ -96,14 +96,17 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
) {
// Use composite format for policy checks: serverName__toolName
// This enables server wildcards (e.g., "google-workspace__*")
// while still allowing specific tool rules
// while still allowing specific tool rules.
// We use the same sanitized names as the registry to ensure policy matches.
super(
params,
messageBus,
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
generateValidName(
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
),
displayName,
serverName,
generateValidName(serverName),
toolAnnotationsData,
);
}
@@ -273,7 +276,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
private readonly _toolAnnotations?: Record<string, unknown>,
) {
super(
nameOverride ?? generateValidName(serverToolName),
generateValidName(nameOverride ?? serverToolName),
`${serverToolName} (${serverName} MCP Server)`,
description,
Kind.Other,
@@ -305,7 +308,9 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
}
getFullyQualifiedName(): string {
return `${this.getFullyQualifiedPrefix()}${generateValidName(this.serverToolName)}`;
return generateValidName(
`${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${this.serverToolName}`,
);
}
asFullyQualifiedTool(): DiscoveredMCPTool {
@@ -482,16 +487,29 @@ function getStringifiedResultForDisplay(rawResponse: Part[]): string {
return displayParts.join('\n');
}
/**
* Maximum length for a function name in the Gemini API.
* @see https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/function-calling#functiondeclaration
*/
const MAX_FUNCTION_NAME_LENGTH = 64;
/** Visible for testing */
export function generateValidName(name: string) {
// Replace invalid characters (based on 400 error message from Gemini API) with underscores
let validToolname = name.replace(/[^a-zA-Z0-9_.-]/g, '_');
let validToolname = name.replace(/[^a-zA-Z0-9_.:-]/g, '_');
// If longer than 63 characters, replace middle with '___'
// (Gemini API says max length 64, but actual limit seems to be 63)
if (validToolname.length > 63) {
validToolname =
validToolname.slice(0, 28) + '___' + validToolname.slice(-32);
// Ensure it starts with a letter or underscore
if (/^[^a-zA-Z_]/.test(validToolname)) {
validToolname = `_${validToolname}`;
}
// If longer than the API limit, replace middle with '...'
// Note: We use 63 instead of 64 to be safe, as some environments have off-by-one behaviors.
const safeLimit = MAX_FUNCTION_NAME_LENGTH - 1;
if (validToolname.length > safeLimit) {
validToolname =
validToolname.slice(0, 30) + '...' + validToolname.slice(-30);
}
return validToolname;
}
+8 -7
View File
@@ -388,16 +388,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
} else {
if (this.params.is_background || result.backgrounded) {
returnDisplayMessage = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
} else if (result.aborted) {
const cancelMsg = timeoutMessage || 'Command cancelled by user.';
if (result.output.trim()) {
returnDisplayMessage = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`;
} else {
returnDisplayMessage = cancelMsg;
}
} else if (result.output.trim()) {
returnDisplayMessage = result.output;
} else {
if (result.aborted) {
if (timeoutMessage) {
returnDisplayMessage = timeoutMessage;
} else {
returnDisplayMessage = 'Command cancelled by user.';
}
} else if (result.signal) {
if (result.signal) {
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
} else if (result.error) {
returnDisplayMessage = `Command failed: ${getErrorMessage(
@@ -58,6 +58,8 @@ describe('tool-names', () => {
it('should validate MCP tool names (server__tool)', () => {
expect(isValidToolName('server__tool')).toBe(true);
expect(isValidToolName('my-server__my-tool')).toBe(true);
expect(isValidToolName('my.server__my:tool')).toBe(true);
expect(isValidToolName('my-server...truncated__tool')).toBe(true);
});
it('should validate legacy tool aliases', async () => {
+4 -2
View File
@@ -260,8 +260,10 @@ export function isValidToolName(
return !!options.allowWildcards;
}
// Basic slug validation for server and tool names
const slugRegex = /^[a-z0-9-_]+$/i;
// Basic slug validation for server and tool names.
// We allow dots (.) and colons (:) as they are valid in function names and
// used for truncation markers.
const slugRegex = /^[a-z0-9_.:-]+$/i;
return slugRegex.test(server) && slugRegex.test(tool);
}
+18 -1
View File
@@ -9,13 +9,30 @@ import { isTool } from '../index.js';
import { SHELL_TOOL_NAMES } from './shell-utils.js';
import levenshtein from 'fast-levenshtein';
import { ApprovalMode } from '../policy/types.js';
import { CoreToolCallStatus } from '../scheduler/types.js';
import {
CoreToolCallStatus,
type ToolCallResponseInfo,
} from '../scheduler/types.js';
import {
ASK_USER_DISPLAY_NAME,
WRITE_FILE_DISPLAY_NAME,
EDIT_DISPLAY_NAME,
} from '../tools/tool-names.js';
/**
* Validates if an object is a ToolCallResponseInfo.
*/
export function isToolCallResponseInfo(
data: unknown,
): data is ToolCallResponseInfo {
return (
typeof data === 'object' &&
data !== null &&
'callId' in data &&
'responseParts' in data
);
}
/**
* Options for determining if a tool call should be hidden in the CLI history.
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.2",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {

Some files were not shown because too many files have changed in this diff Show More