Compare commits

...

13 Commits

Author SHA1 Message Date
Abhi e6a51901a7 feat(skills): promote Agent Skills to stable (#17693) 2026-01-27 17:28:34 -05:00
Jack Wotherspoon 2a3c879782 feat: add clearContext to AfterAgent hooks (#16574) 2026-01-27 09:32:10 -08:00
Jacob Richman 958cc45937 Fix so rewind starts at the bottom and loadHistory refreshes static content. (#17335) 2026-01-27 09:27:36 -08:00
Adib234 9c667cf7ba feat: implement /rewind command (#15720) 2026-01-27 09:14:23 -08:00
gemini-cli-robot c593a29647 chore(release): v0.26.0-preview.4 2026-01-25 17:57:54 +00:00
gemini-cli-robot cebe386d79 fix(patch): cherry-pick 12a5490 to release/v0.26.0-preview.3-pr-17395 [CONFLICTS] (#17424)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
Co-authored-by: A.K.M. Adib <adibakm@google.com>
2026-01-25 08:59:09 -05:00
gemini-cli-robot 1c207e2f82 chore(release): v0.26.0-preview.3 2026-01-22 22:27:22 +00:00
gemini-cli-robot ee87c98f43 fix(patch): cherry-pick addb57c to release/v0.26.0-preview.2-pr-17331 [CONFLICTS] (#17345)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-22 14:04:49 -08:00
gemini-cli-robot 75b5eeeb12 chore(release): v0.26.0-preview.2 2026-01-22 19:03:19 +00:00
gemini-cli-robot 0fa9a54088 fix(patch): cherry-pick 87a0db2 to release/v0.26.0-preview.1-pr-17308 [CONFLICTS] (#17317)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-01-22 18:34:24 +00:00
gemini-cli-robot 603e66b2ea chore(release): v0.26.0-preview.1 2026-01-22 01:02:56 +00:00
gemini-cli-robot dc8fc75ac0 fix(patch): cherry-pick 61040d0 to release/v0.26.0-preview.0-pr-17247 to patch version v0.26.0-preview.0 and create version 0.26.0-preview.1 (#17258)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-01-22 00:38:07 +00:00
gemini-cli-robot 2b58605227 chore(release): v0.26.0-preview.0 2026-01-21 04:56:03 +00:00
78 changed files with 1992 additions and 708 deletions
-5
View File
@@ -1,5 +0,0 @@
{
"experimental": {
"skills": true
}
}
+2 -2
View File
@@ -205,8 +205,8 @@ Slash commands provide meta-level control over the CLI itself.
while others require a restart.
- [**`/skills`**](./skills.md)
- **Description:** (Experimental) Manage Agent Skills, which provide on-demand
expertise and specialized workflows.
- **Description:** Manage Agent Skills, which provide on-demand expertise and
specialized workflows.
- **Sub-commands:**
- **`list`**:
- **Description:** List all discovered skills and their current status
+2 -2
View File
@@ -29,8 +29,8 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
in an enterprise environment.
- **[Sandboxing](./sandbox.md):** Isolate tool execution in a secure,
containerized environment.
- **[Agent Skills](./skills.md):** (Experimental) Extend the CLI with
specialized expertise and procedural workflows.
- **[Agent Skills](./skills.md):** Extend the CLI with specialized expertise and
procedural workflows.
- **[Telemetry](./telemetry.md):** Configure observability to monitor usage and
performance.
- **[Token caching](./token-caching.md):** Optimize API costs by caching tokens.
+1
View File
@@ -62,6 +62,7 @@ available combinations.
| Start reverse search through history. | `Ctrl + R` |
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
| Browse and rewind previous interactions. | `Double Esc` |
#### Navigation
+6 -1
View File
@@ -115,13 +115,18 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
| Enable CLI Help Agent | `experimental.cliHelpAgentSettings.enabled` | Enable the CLI Help Agent. | `true` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
### Skills
| UI Label | Setting | Description | Default |
| ------------------- | ---------------- | -------------------- | ------- |
| Enable Agent Skills | `skills.enabled` | Enable Agent Skills. | `true` |
### HooksConfig
| UI Label | Setting | Description | Default |
-4
View File
@@ -1,9 +1,5 @@
# Agent Skills
_Note: This is an experimental feature enabled via `experimental.skills`. You
can also search for "Skills" within the `/settings` interactive UI to toggle
this and manage other skill-related settings._
Agent Skills allow you to extend Gemini CLI with specialized expertise,
procedural workflows, and task-specific resources. Based on the
[Agent Skills](https://agentskills.io) open standard, a "skill" is a
+5 -32
View File
@@ -1,37 +1,10 @@
# Getting Started with Agent Skills
Agent Skills allow you to extend Gemini CLI with specialized expertise. This
tutorial will guide you through creating your first skill, enabling it, and
using it in a session.
tutorial will guide you through creating your first skill and using it in a
session.
## 1. Enable Agent Skills
Agent Skills are currently an experimental feature and must be enabled in your
settings.
### Via the interactive UI
1. Start a Gemini CLI session by running `gemini`.
2. Type `/settings` to open the interactive settings dialog.
3. Search for "Skills".
4. Toggle **Agent Skills** to `true`.
5. Press `Esc` to save and exit. You may need to restart the CLI for the
changes to take effect.
### Via `settings.json`
Alternatively, you can manually edit your global settings file at
`~/.gemini/settings.json` (create it if it doesn't exist):
```json
{
"experimental": {
"skills": true
}
}
```
## 2. Create Your First Skill
## 1. Create your first skill
A skill is a directory containing a `SKILL.md` file. Let's create an **API
Auditor** skill that helps you verify if local or remote endpoints are
@@ -86,7 +59,7 @@ responding correctly.
.catch((e) => console.error(`Result: Failed (${e.message})`));
```
## 3. Verify the Skill is Discovered
## 2. Verify the skill is discovered
Use the `/skills` slash command (or `gemini skills list` from your terminal) to
see if Gemini CLI has found your new skill.
@@ -99,7 +72,7 @@ In a Gemini CLI session:
You should see `api-auditor` in the list of available skills.
## 4. Use the Skill in a Chat
## 3. Use the skill in a chat
Now, let's see the skill in action. Start a new session and ask a question about
an endpoint.
@@ -224,8 +224,6 @@ file in every session where the extension is active.
## (Optional) Step 6: Add an Agent Skill
_Note: This is an experimental feature enabled via `experimental.skills`._
[Agent Skills](../cli/skills.md) let you bundle specialized expertise and
procedural workflows. Unlike `GEMINI.md`, which provides persistent context,
skills are activated only when needed, saving context tokens.
+6 -6
View File
@@ -850,11 +850,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.skills`** (boolean):
- **Description:** Enable Agent Skills (experimental).
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.enabled`** (boolean):
- **Description:** Enable the Codebase Investigator agent.
- **Default:** `true`
@@ -899,6 +894,11 @@ their corresponding top-level category object in your `settings.json` file.
#### `skills`
- **`skills.enabled`** (boolean):
- **Description:** Enable Agent Skills.
- **Default:** `true`
- **Requires restart:** Yes
- **`skills.disabled`** (array):
- **Description:** List of disabled skills.
- **Default:** `[]`
@@ -909,7 +909,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`hooksConfig.enabled`** (boolean):
- **Description:** Canonical toggle for the hooks system. When disabled, no
hooks will be executed.
- **Default:** `false`
- **Default:** `true`
- **`hooksConfig.disabled`** (array):
- **Description:** List of hook names (commands) that should be disabled.
+211 -7
View File
@@ -106,13 +106,217 @@ If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON.
### `hookSpecificOutput` Reference
| Field | Supported Events | Description |
| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- |
| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. |
| `llm_request` | `BeforeModel` | A `Partial<LLMRequest>` to override parameters of the outgoing call. |
| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. |
| `llm_response` | `AfterModel` | A `Partial<LLMResponse>` to modify the model's response before the agent sees it. |
| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. |
### Matchers and tool names
For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is
compared against the name of the tool being executed.
- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`,
`run_shell_command`). See the [Tools Reference](/docs/tools) for a full list
of available tool names.
- **MCP Tools**: Tools from MCP servers follow the naming pattern
`mcp__<server_name>__<tool_name>`.
- **Regex Support**: Matchers support regular expressions (e.g.,
`matcher: "read_.*"` matches all file reading tools).
### `BeforeTool`
Fires before a tool is invoked. Used for argument validation, security checks,
and parameter rewriting.
- **Input Fields**:
- `tool_name`: (`string`) The name of the tool being called.
- `tool_input`: (`object`) The raw arguments generated by the model.
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
executing.
- `reason`: Required if denied. This text is sent **to the agent** as a tool
error, allowing it to respond or retry.
- `hookSpecificOutput.tool_input`: An object that **merges with and
overrides** the model's arguments before execution.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the
`reason` sent to the agent. **The turn continues.**
### `AfterTool`
Fires after a tool executes. Used for result auditing, context injection, or
hiding sensitive output from the agent.
- **Input Fields**:
- `tool_name`: (`string`)
- `tool_input`: (`object`) The original arguments.
- `tool_response`: (`object`) The result containing `llmContent`,
`returnDisplay`, and optional `error`.
- `mcp_context`: (`object`)
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
- `reason`: Required if denied. This text **replaces** the tool result sent
back to the model.
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
tool result for the agent.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
replacement content sent to the agent. **The turn continues.**
---
## Agent hooks
### `BeforeAgent`
Fires after a user submits a prompt, but before the agent begins planning. Used
for prompt validation or injecting dynamic context.
- **Input Fields**:
- `prompt`: (`string`) The original text submitted by the user.
- **Relevant Output Fields**:
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
prompt for this turn only.
- `decision`: Set to `"deny"` to block the turn and **discard the user's
message** (it will not appear in history).
- `continue`: Set to `false` to block the turn but **save the message to
history**.
- `reason`: Required if denied or stopped.
- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from
context. Same as `decision: "deny"`.
### `AfterAgent`
Fires once per turn after the model generates its final response. Primary use
case is response validation and automatic retries.
- **Input Fields**:
- `prompt`: (`string`) The user's original request.
- `prompt_response`: (`string`) The final text generated by the agent.
- `stop_hook_active`: (`boolean`) Indicates if this hook is already running as
part of a retry sequence.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to **reject the response** and force a retry.
- `reason`: Required if denied. This text is sent **to the agent as a new
prompt** to request a correction.
- `continue`: Set to `false` to **stop the session** without retrying.
- `clearContext`: If `true`, clears conversation history (LLM memory) while
preserving UI display.
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
turn using `stderr` as the feedback prompt.
---
## Model hooks
### `BeforeModel`
Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic
request format.
- **Input Fields**:
- `llm_request`: (`object`) Contains `model`, `messages`, and `config`
(generation params).
- **Relevant Output Fields**:
- `hookSpecificOutput.llm_request`: An object that **overrides** parts of the
outgoing request (e.g., changing models or temperature).
- `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If
provided, the CLI skips the LLM call entirely and uses this as the response.
- `decision`: Set to `"deny"` to block the request and abort the turn.
- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses
`stderr` as the error message.
### `BeforeToolSelection`
Fires before the LLM decides which tools to call. Used to filter the available
toolset or force specific tool modes.
- **Input Fields**:
- `llm_request`: (`object`) Same format as `BeforeModel`.
- **Relevant Output Fields**:
- `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`)
- `"NONE"`: Disables all tools (Wins over other hooks).
- `"ANY"`: Forces at least one tool call.
- `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist
of tool names.
- **Union Strategy**: Multiple hooks' whitelists are **combined**.
- **Limitations**: Does **not** support `decision`, `continue`, or
`systemMessage`.
### `AfterModel`
Fires immediately after an LLM response chunk is received. Used for real-time
redaction or PII filtering.
- **Input Fields**:
- `llm_request`: (`object`) The original request.
- `llm_response`: (`object`) The model's response (or a single chunk during
streaming).
- **Relevant Output Fields**:
- `hookSpecificOutput.llm_response`: An object that **replaces** the model's
response chunk.
- `decision`: Set to `"deny"` to discard the response chunk and block the
turn.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Note on Streaming**: Fired for **every chunk** generated by the model.
Modifying the response only affects the current chunk.
- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's
output. Uses `stderr` as the error message.
---
## Lifecycle & system hooks
### `SessionStart`
Fires on application startup, resuming a session, or after a `/clear` command.
Used for loading initial context.
- **Input fields**:
- `source`: (`"startup" | "resume" | "clear"`)
- **Relevant output fields**:
- `hookSpecificOutput.additionalContext`: (`string`)
- **Interactive**: Injected as the first turn in history.
- **Non-interactive**: Prepended to the user's prompt.
- `systemMessage`: Shown at the start of the session.
- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup
is never blocked.
### `SessionEnd`
Fires when the CLI exits or a session is cleared. Used for cleanup or final
telemetry.
- **Input Fields**:
- `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`)
- **Relevant Output Fields**:
- `systemMessage`: Displayed to the user during shutdown.
- **Best Effort**: The CLI **will not wait** for this hook to complete and
ignores all flow-control fields (`continue`, `decision`).
### `Notification`
Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for
external logging or cross-platform alerts.
- **Input Fields**:
- `notification_type`: (`"ToolPermission"`)
- `message`: Summary of the alert.
- `details`: JSON object with alert-specific metadata (e.g., tool name, file
path).
- **Relevant Output Fields**:
- `systemMessage`: Displayed alongside the system alert.
- **Observability Only**: This hook **cannot** block alerts or grant permissions
automatically. Flow-control fields are ignored.
### `PreCompress`
Fires before the CLI summarizes history to save tokens. Used for logging or
state saving.
- **Input Fields**:
- `trigger`: (`"auto" | "manual"`)
- **Relevant Output Fields**:
- `systemMessage`: Displayed to the user before compression.
- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the
compression process. Flow-control fields are ignored.
---
+2 -2
View File
@@ -56,8 +56,8 @@ This documentation is organized into the following sections:
commands with `/model`.
- **[Sandbox](./cli/sandbox.md):** Isolate tool execution in a secure,
containerized environment.
- **[Agent Skills](./cli/skills.md):** (Experimental) Extend the CLI with
specialized expertise and procedural workflows.
- **[Agent Skills](./cli/skills.md):** Extend the CLI with specialized expertise
and procedural workflows.
- **[Settings](./cli/settings.md):** Configure various aspects of the CLI's
behavior and appearance with `/settings`.
- **[Telemetry](./cli/telemetry.md):** Overview of telemetry in the CLI.
+11 -14
View File
@@ -204,21 +204,18 @@
{
"label": "Hooks",
"items": [
{ "label": "Getting started", "slug": "docs/hooks/getting-started" },
{ "label": "Hook types", "slug": "docs/hooks/hook-types" },
{ "label": "API reference", "slug": "docs/hooks/api-reference" }
]
},
{
"label": "Ecosystem and extensibility",
"items": [
{ "label": "Agent skills", "slug": "docs/cli/skills" },
{
"label": "Introduction",
"slug": "docs/hooks"
},
{
"label": "Writing hooks",
"slug": "docs/hooks/writing-hooks"
},
{
"label": "Hooks reference",
"slug": "docs/hooks/reference"
},
{
"label": "Best practices",
"slug": "docs/hooks/best-practices"
"label": "Sub-agents (experimental)",
"slug": "docs/core/subagents"
}
]
},
+3 -3
View File
@@ -91,8 +91,8 @@ Additionally, these tools incorporate:
- **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the
Gemini model and your local environment or other services like APIs.
- **[Agent Skills](../cli/skills.md)**: (Experimental) On-demand expertise
packages that are activated via the `activate_skill` tool to provide
specialized guidance and resources.
- **[Agent Skills](../cli/skills.md)**: On-demand expertise packages that are
activated via the `activate_skill` tool to provide specialized guidance and
resources.
- **[Sandboxing](../cli/sandbox.md)**: Sandboxing isolates the model and its
changes from your environment to reduce potential risk.
@@ -155,6 +155,84 @@ describe('Hooks Agent Flow', () => {
// The fake response contains "Hello World"
expect(afterAgentLog?.hookCall.stdout).toContain('Hello World');
});
it('should process clearContext in AfterAgent hook output', async () => {
await rig.setup('should process clearContext in AfterAgent hook output', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.after-agent.responses',
),
});
// BeforeModel hook to track message counts across LLM calls
const messageCountFile = join(rig.testDir!, 'message-counts.json');
const beforeModelScript = `
const fs = require('fs');
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
const messageCount = input.llm_request?.contents?.length || 0;
let counts = [];
try { counts = JSON.parse(fs.readFileSync('${messageCountFile}', 'utf-8')); } catch (e) {}
counts.push(messageCount);
fs.writeFileSync('${messageCountFile}', JSON.stringify(counts));
console.log(JSON.stringify({ decision: 'allow' }));
`;
const beforeModelScriptPath = join(
rig.testDir!,
'before_model_counter.cjs',
);
writeFileSync(beforeModelScriptPath, beforeModelScript);
await rig.setup('should process clearContext in AfterAgent hook output', {
settings: {
hooks: {
enabled: true,
BeforeModel: [
{
hooks: [
{
type: 'command',
command: `node "${beforeModelScriptPath}"`,
timeout: 5000,
},
],
},
],
AfterAgent: [
{
hooks: [
{
type: 'command',
command: `node -e "console.log(JSON.stringify({decision: 'block', reason: 'Security policy triggered', hookSpecificOutput: {hookEventName: 'AfterAgent', clearContext: true}}))"`,
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run({ args: 'Hello test' });
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
expect(hookTelemetryFound).toBeTruthy();
const hookLogs = rig.readHookLogs();
const afterAgentLog = hookLogs.find(
(log) => log.hookCall.hook_event_name === 'AfterAgent',
);
expect(afterAgentLog).toBeDefined();
expect(afterAgentLog?.hookCall.stdout).toContain('clearContext');
expect(afterAgentLog?.hookCall.stdout).toContain('true');
expect(result).toContain('Security policy triggered');
// Verify context was cleared: second call should not have more messages than first
const countsRaw = rig.readFile('message-counts.json');
const counts = JSON.parse(countsRaw) as number[];
expect(counts.length).toBeGreaterThanOrEqual(2);
expect(counts[1]).toBeLessThanOrEqual(counts[0]);
});
});
describe('Multi-step Loops', () => {
+22 -33
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"workspaces": [
"packages/*"
],
@@ -2474,7 +2474,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2655,7 +2654,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2689,7 +2687,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -3058,7 +3055,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -3092,7 +3088,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -3145,7 +3140,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4358,7 +4352,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4636,7 +4629,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5641,7 +5633,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6086,7 +6077,8 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -7370,6 +7362,7 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -8689,7 +8682,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9292,6 +9284,7 @@
"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"
}
@@ -9301,6 +9294,7 @@
"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"
}
@@ -9310,6 +9304,7 @@
"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"
}
@@ -9563,6 +9558,7 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -9581,6 +9577,7 @@
"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"
}
@@ -9589,13 +9586,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"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"
}
@@ -10878,7 +10877,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14063,7 +14061,8 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -14640,7 +14639,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14651,7 +14649,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16911,7 +16908,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17135,8 +17131,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -17144,7 +17139,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -17328,7 +17322,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17491,6 +17484,7 @@
"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"
}
@@ -17545,7 +17539,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17659,7 +17652,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17672,7 +17664,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18377,7 +18368,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18393,7 +18383,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -18703,7 +18693,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -18807,7 +18797,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -18944,7 +18934,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18967,7 +18956,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18984,7 +18973,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"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.26.0-nightly.20260115.6cb3ae4e0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-preview.4"
},
"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.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"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.26.0-nightly.20260115.6cb3ae4e0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-preview.4"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -511,8 +511,5 @@ describe('migrate command', () => {
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
);
});
});
@@ -244,9 +244,6 @@ export async function handleMigrateFromClaude() {
debugLogger.log(
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
debugLogger.log(
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
);
} catch (error) {
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
}
+36
View File
@@ -534,6 +534,42 @@ describe('parseArguments', () => {
'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.',
);
});
it('should set isCommand to true for mcp command', async () => {
process.argv = ['node', 'script.js', 'mcp', 'list'];
const argv = await parseArguments(createTestMergedSettings());
expect(argv.isCommand).toBe(true);
});
it('should set isCommand to true for extensions command', async () => {
process.argv = ['node', 'script.js', 'extensions', 'list'];
// Extensions command uses experimental settings
const settings = createTestMergedSettings({
experimental: { extensionManagement: true },
});
const argv = await parseArguments(settings);
expect(argv.isCommand).toBe(true);
});
it('should set isCommand to true for skills command', async () => {
process.argv = ['node', 'script.js', 'skills', 'list'];
// Skills command enabled by default or via experimental
const settings = createTestMergedSettings({
skills: { enabled: true },
});
const argv = await parseArguments(settings);
expect(argv.isCommand).toBe(true);
});
it('should set isCommand to true for hooks command', async () => {
process.argv = ['node', 'script.js', 'hooks', 'migrate'];
// Hooks command enabled via tools settings
const settings = createTestMergedSettings({
tools: { enableHooks: true },
});
const argv = await parseArguments(settings);
expect(argv.isCommand).toBe(true);
});
});
describe('loadCliConfig', () => {
+6 -7
View File
@@ -37,6 +37,7 @@ import {
type HookEventName,
type OutputFormat,
GEMINI_MODEL_ALIAS_AUTO,
coreEvents,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -47,7 +48,6 @@ import {
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { appEvents } from '../utils/events.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -289,7 +289,7 @@ export async function parseArguments(
yargsInstance.command(extensionsCommand);
}
if (settings.experimental?.skills || (settings.skills?.enabled ?? true)) {
if (settings.skills?.enabled ?? true) {
yargsInstance.command(skillsCommand);
}
// Register hooks command if hooks are enabled
@@ -450,7 +450,7 @@ export async function loadCliConfig(
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
});
await extensionManager.loadExtensions();
@@ -725,8 +725,7 @@ export async function loadCliConfig(
plan: settings.experimental?.plan,
enableEventDrivenScheduler:
settings.experimental?.enableEventDrivenScheduler,
skillsSupport:
settings.experimental?.skills || (settings.skills?.enabled ?? true),
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
noBrowser: !!process.env['NO_BROWSER'],
@@ -746,7 +745,7 @@ export async function loadCliConfig(
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: appEvents,
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
@@ -763,7 +762,7 @@ export async function loadCliConfig(
// TODO: loading of hooks based on workspace trust
enableHooks:
(settings.tools?.enableHooks ?? true) &&
(settings.hooksConfig?.enabled ?? false),
(settings.hooksConfig?.enabled ?? true),
enableHooksUI: settings.tools?.enableHooks ?? true,
hooks: settings.hooks || {},
disabledHooks: settings.hooksConfig?.disabled || [],
@@ -872,6 +872,7 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooksConfig.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
+4
View File
@@ -49,6 +49,7 @@ export enum Command {
REVERSE_SEARCH = 'history.search.start',
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
REWIND = 'history.rewind',
// Navigation
NAVIGATION_UP = 'nav.up',
@@ -183,6 +184,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
[Command.REWIND]: [{ key: 'double escape' }],
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
@@ -313,6 +315,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
Command.REWIND,
],
},
{
@@ -409,6 +412,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
[Command.REWIND]: 'Browse and rewind previous interactions.',
// Navigation
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
+1 -11
View File
@@ -1452,15 +1452,6 @@ const SETTINGS_SCHEMA = {
description: 'Enable Just-In-Time (JIT) context loading.',
showInDialog: false,
},
skills: {
type: 'boolean',
label: 'Agent Skills',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable Agent Skills (experimental).',
showInDialog: true,
},
codebaseInvestigatorSettings: {
type: 'object',
label: 'Codebase Investigator Settings',
@@ -1615,7 +1606,6 @@ const SETTINGS_SCHEMA = {
default: true,
description: 'Enable Agent Skills.',
showInDialog: true,
ignoreInDocs: true,
},
disabled: {
type: 'array',
@@ -1646,7 +1636,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
default: false,
default: true,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
@@ -1,158 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { loadCliConfig, parseArguments } from './config.js';
import * as trustedFolders from './trustedFolders.js';
import { loadServerHierarchicalMemory } from '@google/gemini-cli-core';
import { type Settings, createTestMergedSettings } from './settings.js';
vi.mock('./trustedFolders.js');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
loadServerHierarchicalMemory: vi.fn(),
getPty: vi.fn().mockResolvedValue({ name: 'test-pty' }),
getVersion: vi.fn().mockResolvedValue('0.0.0-test'),
};
});
describe('Agent Skills Backward Compatibility', () => {
const originalArgv = process.argv;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(trustedFolders.isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
} as unknown as trustedFolders.TrustResult);
});
afterEach(() => {
process.argv = originalArgv;
});
describe('loadCliConfig', () => {
it('should default skillsSupport to true when no settings are present', async () => {
vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
});
process.argv = ['node', 'gemini'];
const settings = createTestMergedSettings({});
const config = await loadCliConfig(
settings,
'test-session',
await parseArguments(settings),
);
expect(
(
config as unknown as { isSkillsSupportEnabled: () => boolean }
).isSkillsSupportEnabled(),
).toBe(true);
});
it('should prioritize skills.enabled=false from settings', async () => {
vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
});
const settings = createTestMergedSettings({
skills: { enabled: false },
} as unknown as Settings);
process.argv = ['node', 'gemini'];
const config = await loadCliConfig(
settings,
'test-session',
await parseArguments(settings),
);
expect(
(
config as unknown as { isSkillsSupportEnabled: () => boolean }
).isSkillsSupportEnabled(),
).toBe(false);
});
it('should support legacy experimental.skills=true from settings', async () => {
vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
});
const settings = createTestMergedSettings({
experimental: { skills: true },
} as unknown as Settings);
process.argv = ['node', 'gemini'];
const config = await loadCliConfig(
settings,
'test-session',
await parseArguments(settings),
);
expect(
(
config as unknown as { isSkillsSupportEnabled: () => boolean }
).isSkillsSupportEnabled(),
).toBe(true);
});
it('should prioritize legacy experimental.skills=true over new skills.enabled=false', async () => {
vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
});
const settings = createTestMergedSettings({
skills: { enabled: false },
experimental: { skills: true },
} as unknown as Settings);
process.argv = ['node', 'gemini'];
const config = await loadCliConfig(
settings,
'test-session',
await parseArguments(settings),
);
expect(
(
config as unknown as { isSkillsSupportEnabled: () => boolean }
).isSkillsSupportEnabled(),
).toBe(true);
});
it('should still be enabled by default if legacy experimental.skills is false (since new default is true)', async () => {
vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
});
const settings = createTestMergedSettings({
experimental: { skills: false },
} as unknown as Settings);
process.argv = ['node', 'gemini'];
const config = await loadCliConfig(
settings,
'test-session',
await parseArguments(settings),
);
expect(
(
config as unknown as { isSkillsSupportEnabled: () => boolean }
).isSkillsSupportEnabled(),
).toBe(true);
});
});
});
+2
View File
@@ -1085,6 +1085,8 @@ describe('gemini.tsx main function exit codes', () => {
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any);
vi.mocked(loadCliConfig).mockResolvedValue({
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
+6 -2
View File
@@ -373,6 +373,7 @@ export async function main() {
// Refresh auth to fetch remote admin settings from CCPA and before entering
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
let initialAuthFailed = false;
if (
settings.merged.security.auth.selectedType &&
!settings.merged.security.auth.useExternal
@@ -400,8 +401,7 @@ export async function main() {
}
} catch (err) {
debugLogger.error('Error authenticating:', err);
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
initialAuthFailed = true;
}
}
@@ -427,6 +427,10 @@ export async function main() {
// another way to decouple refreshAuth from requiring a config.
if (sandboxConfig) {
if (initialAuthFailed) {
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
let stdinData = '';
if (!process.stdin.isTTY) {
stdinData = await readStdin();
@@ -119,11 +119,12 @@ describe('BuiltinCommandLoader', () => {
getEnableHooks: () => false,
getEnableHooksUI: () => false,
getExtensionsEnabled: vi.fn().mockReturnValue(true),
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
isSkillsSupportEnabled: vi.fn().mockReturnValue(true),
isAgentsEnabled: vi.fn().mockReturnValue(false),
getMcpEnabled: vi.fn().mockReturnValue(true),
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
} as unknown as Config;
@@ -260,11 +261,12 @@ describe('BuiltinCommandLoader profile', () => {
getEnableHooks: () => false,
getEnableHooksUI: () => false,
getExtensionsEnabled: vi.fn().mockReturnValue(true),
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
isSkillsSupportEnabled: vi.fn().mockReturnValue(true),
isAgentsEnabled: vi.fn().mockReturnValue(false),
getMcpEnabled: vi.fn().mockReturnValue(true),
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
} as unknown as Config;
});
@@ -27,6 +27,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
@@ -106,6 +107,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
await ideCommand(),
initCommand,
...(this.config?.getMcpEnabled() === false
+30 -2
View File
@@ -103,6 +103,7 @@ import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useMcpStatus } from './hooks/useMcpStatus.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import {
@@ -129,6 +130,7 @@ import {
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
import { isSlashCommand } from './utils/commandUtils.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -689,6 +691,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
setText: (text: string) => buffer.setText(text),
}),
[
setAuthState,
@@ -705,6 +708,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog,
addConfirmUpdateExtensionRequest,
toggleDebugProfiler,
buffer,
],
);
@@ -861,6 +865,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isActive: !embeddedShellFocused,
});
const { isMcpReady } = useMcpStatus(config);
const {
messageQueue,
addMessage,
@@ -871,6 +877,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isConfigInitialized,
streamingState,
submitQuery,
isMcpReady,
});
cancelHandlerRef.current = useCallback(
@@ -909,10 +916,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
addMessage(submittedValue);
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
if (isSlash || (isIdle && isMcpReady)) {
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
if (isIdle && !isMcpReady && messageQueue.length === 0) {
coreEvents.emitFeedback(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
);
}
addMessage(submittedValue);
}
addInput(submittedValue); // Track input for up-arrow history
},
[addMessage, addInput],
[
addMessage,
addInput,
submitQuery,
isMcpReady,
streamingState,
messageQueue.length,
],
);
const handleClearScreen = useCallback(() => {
@@ -0,0 +1,351 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { rewindCommand } from './rewindCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { waitFor } from '../../test-utils/async.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import {
type OpenCustomDialogActionReturn,
type CommandContext,
} from './types.js';
import type { ReactElement } from 'react';
import { coreEvents } from '@google/gemini-cli-core';
// Mock dependencies
const mockRewindTo = vi.fn();
const mockRecordMessage = vi.fn();
const mockSetHistory = vi.fn();
const mockSendMessageStream = vi.fn();
const mockGetChatRecordingService = vi.fn();
const mockGetConversation = vi.fn();
const mockRemoveComponent = vi.fn();
const mockLoadHistory = vi.fn();
const mockAddItem = vi.fn();
const mockSetPendingItem = vi.fn();
const mockResetContext = vi.fn();
const mockSetInput = vi.fn();
const mockRevertFileChanges = vi.fn();
const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
...actual.coreEvents,
emitFeedback: vi.fn(),
},
};
});
vi.mock('../components/RewindViewer.js', () => ({
RewindViewer: () => null,
}));
vi.mock('../hooks/useSessionBrowser.js', () => ({
convertSessionToHistoryFormats: vi.fn().mockReturnValue({
uiHistory: [
{ type: 'user', text: 'old user' },
{ type: 'gemini', text: 'old gemini' },
],
clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }],
}),
}));
vi.mock('../utils/rewindFileOps.js', () => ({
revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args),
}));
interface RewindViewerProps {
onRewind: (
messageId: string,
newText: string,
outcome: RewindOutcome,
) => Promise<void>;
conversation: unknown;
onExit: () => void;
}
describe('rewindCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockGetConversation.mockReturnValue({
messages: [{ id: 'msg-1', type: 'user', content: 'hello' }],
sessionId: 'test-session',
});
mockRewindTo.mockReturnValue({
messages: [], // Mocked rewound messages
});
mockGetChatRecordingService.mockReturnValue({
getConversation: mockGetConversation,
rewindTo: mockRewindTo,
recordMessage: mockRecordMessage,
});
mockContext = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({
getChatRecordingService: mockGetChatRecordingService,
setHistory: mockSetHistory,
sendMessageStream: mockSendMessageStream,
}),
getSessionId: () => 'test-session-id',
getContextManager: () => ({ refresh: mockResetContext }),
getProjectRoot: mockGetProjectRoot,
},
},
ui: {
removeComponent: mockRemoveComponent,
loadHistory: mockLoadHistory,
addItem: mockAddItem,
setPendingItem: mockSetPendingItem,
},
}) as unknown as CommandContext;
});
it('should initialize successfully', async () => {
const result = await rewindCommand.action!(mockContext, '');
expect(result).toHaveProperty('type', 'custom_dialog');
});
it('should handle RewindOnly correctly', async () => {
// 1. Run the command to get the component
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
// Access onRewind from props
const onRewind = component.props.onRewind;
expect(onRewind).toBeDefined();
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(mockRevertFileChanges).not.toHaveBeenCalled();
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
expect(mockSetHistory).toHaveBeenCalled();
expect(mockResetContext).toHaveBeenCalled();
expect(mockLoadHistory).toHaveBeenCalledWith(
[
expect.objectContaining({ text: 'old user', id: 1 }),
expect.objectContaining({ text: 'old gemini', id: 2 }),
],
'New Prompt',
);
expect(mockRemoveComponent).toHaveBeenCalled();
});
// Verify setInput was NOT called directly (it's handled via loadHistory now)
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle RewindAndRevert correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert);
await waitFor(() => {
expect(mockRevertFileChanges).toHaveBeenCalledWith(
mockGetConversation(),
'msg-id-123',
);
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
expect(mockLoadHistory).toHaveBeenCalledWith(
expect.any(Array),
'New Prompt',
);
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle RevertOnly correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly);
await waitFor(() => {
expect(mockRevertFileChanges).toHaveBeenCalledWith(
mockGetConversation(),
'msg-id-123',
);
expect(mockRewindTo).not.toHaveBeenCalled();
expect(mockRemoveComponent).toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'File changes reverted.',
);
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle Cancel correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel);
await waitFor(() => {
expect(mockRevertFileChanges).not.toHaveBeenCalled();
expect(mockRewindTo).not.toHaveBeenCalled();
expect(mockRemoveComponent).toHaveBeenCalled();
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle onExit correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onExit = component.props.onExit;
onExit();
expect(mockRemoveComponent).toHaveBeenCalled();
});
it('should handle rewind error correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
mockRewindTo.mockImplementation(() => {
throw new Error('Rewind Failed');
});
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Rewind Failed',
);
});
});
it('should handle null conversation from rewindTo', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
mockRewindTo.mockReturnValue(null);
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Could not fetch conversation file',
);
expect(mockRemoveComponent).toHaveBeenCalled();
});
});
it('should fail if config is missing', () => {
const context = { services: {} } as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not found',
});
});
it('should fail if client is not initialized', () => {
const context = createMockCommandContext({
services: {
config: { getGeminiClient: () => undefined },
},
}) as unknown as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Client not initialized',
});
});
it('should fail if recording service is unavailable', () => {
const context = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({ getChatRecordingService: () => undefined }),
},
},
}) as unknown as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Recording service unavailable',
});
});
it('should return info if no conversation found', () => {
mockGetConversation.mockReturnValue(null);
const result = rewindCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found.',
});
});
it('should return info if no user interactions found', () => {
mockGetConversation.mockReturnValue({
messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }],
sessionId: 'test-session',
});
const result = rewindCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Nothing to rewind to.',
});
});
});
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from './types.js';
import { RewindViewer } from '../components/RewindViewer.js';
import { type HistoryItem } from '../types.js';
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
import { revertFileChanges } from '../utils/rewindFileOps.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import { checkExhaustive } from '../../utils/checks.js';
import type { Content } from '@google/genai';
import type {
ChatRecordingService,
GeminiClient,
} from '@google/gemini-cli-core';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
/**
* Helper function to handle the core logic of rewinding a conversation.
* This function encapsulates the steps needed to rewind the conversation,
* update the client and UI history, and clear the component.
*
* @param context The command context.
* @param client Gemini client
* @param recordingService The chat recording service.
* @param messageId The ID of the message to rewind to.
* @param newText The new text for the input field after rewinding.
*/
async function rewindConversation(
context: CommandContext,
client: GeminiClient,
recordingService: ChatRecordingService,
messageId: string,
newText: string,
) {
try {
const conversation = recordingService.rewindTo(messageId);
if (!conversation) {
const errorMsg = 'Could not fetch conversation file';
debugLogger.error(errorMsg);
context.ui.removeComponent();
coreEvents.emitFeedback('error', errorMsg);
return;
}
// Convert to UI and Client formats
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
conversation.messages,
);
client.setHistory(clientHistory as Content[]);
// Reset context manager as we are rewinding history
await context.services.config?.getContextManager()?.refresh();
// Update UI History
// We generate IDs based on index for the rewind history
const startId = 1;
const historyWithIds = uiHistory.map(
(item, idx) =>
({
...item,
id: startId + idx,
}) as HistoryItem,
);
// 1. Remove component FIRST to avoid flicker and clear the stage
context.ui.removeComponent();
// 2. Load the rewound history and set the input
context.ui.loadHistory(historyWithIds, newText);
} catch (error) {
// If an error occurs, we still want to remove the component if possible
context.ui.removeComponent();
coreEvents.emitFeedback(
'error',
error instanceof Error ? error.message : 'Unknown error during rewind',
);
}
}
export const rewindCommand: SlashCommand = {
name: 'rewind',
description: 'Jump back to a specific message and restart the conversation',
kind: CommandKind.BUILT_IN,
action: (context) => {
const config = context.services.config;
if (!config)
return {
type: 'message',
messageType: 'error',
content: 'Config not found',
};
const client = config.getGeminiClient();
if (!client)
return {
type: 'message',
messageType: 'error',
content: 'Client not initialized',
};
const recordingService = client.getChatRecordingService();
if (!recordingService)
return {
type: 'message',
messageType: 'error',
content: 'Recording service unavailable',
};
const conversation = recordingService.getConversation();
if (!conversation)
return {
type: 'message',
messageType: 'info',
content: 'No conversation found.',
};
const hasUserInteractions = conversation.messages.some(
(msg) => msg.type === 'user',
);
if (!hasUserInteractions) {
return {
type: 'message',
messageType: 'info',
content: 'Nothing to rewind to.',
};
}
return {
type: 'custom_dialog',
component: (
<RewindViewer
conversation={conversation}
onExit={() => {
context.ui.removeComponent();
}}
onRewind={async (messageId, newText, outcome) => {
switch (outcome) {
case RewindOutcome.Cancel:
context.ui.removeComponent();
return;
case RewindOutcome.RevertOnly:
if (conversation) {
await revertFileChanges(conversation, messageId);
}
context.ui.removeComponent();
coreEvents.emitFeedback('info', 'File changes reverted.');
return;
case RewindOutcome.RewindAndRevert:
if (conversation) {
await revertFileChanges(conversation, messageId);
}
await rewindConversation(
context,
client,
recordingService,
messageId,
newText,
);
return;
case RewindOutcome.RewindOnly:
await rewindConversation(
context,
client,
recordingService,
messageId,
newText,
);
return;
default:
checkExhaustive(outcome);
}
}}
/>
),
};
},
};
+2 -1
View File
@@ -66,8 +66,9 @@ export interface CommandContext {
* Loads a new set of history items, replacing the current history.
*
* @param history The array of history items to load.
* @param postLoadInput Optional text to set in the input buffer after loading history.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleDebugProfiler: () => void;
@@ -1911,7 +1911,9 @@ describe('InputPrompt', () => {
await act(async () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
});
unmount();
+14 -12
View File
@@ -27,7 +27,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
import {
parseInputForHighlighting,
parseSegmentsFromTokens,
@@ -505,18 +505,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
} else {
// Second ESC
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
} else {
if (history.length > 0) {
onSubmit('/rewind');
}
}
return;
}
// Second ESC
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
return;
} else if (history.length > 0) {
onSubmit('/rewind');
return;
}
coreEvents.emitFeedback('info', 'Nothing to rewind to');
return;
}
@@ -239,6 +239,7 @@ describe('RewindViewer', () => {
// Select
act(() => {
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
stdin.write('\r');
});
expect(lastFrame()).toMatchSnapshot('confirmation-dialog');
@@ -252,17 +253,16 @@ describe('RewindViewer', () => {
it.each([
{
description: 'removes reference markers',
prompt:
'some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---',
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
},
{
description: 'strips expanded MCP resource content',
prompt:
'read @server3:mcp://demo-resource hello\n' +
'--- Content from referenced files ---\n' +
`--- Content from referenced files ---\n` +
'\nContent from @server3:mcp://demo-resource:\n' +
'This is the content of the demo resource.\n' +
'--- End of content ---',
`--- End of content ---`,
},
])('$description', async ({ prompt }) => {
const conversation = createConversation([
@@ -281,6 +281,7 @@ describe('RewindViewer', () => {
// Select
act(() => {
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
stdin.write('\r'); // Select
});
+114 -37
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import {
@@ -19,8 +19,9 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useRewind } from '../hooks/useRewind.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
import { stripReferenceContent } from '../utils/formatters.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
interface RewindViewerProps {
conversation: ConversationRecord;
@@ -29,7 +30,7 @@ interface RewindViewerProps {
messageId: string,
newText: string,
outcome: RewindOutcome,
) => void;
) => Promise<void>;
}
const MAX_LINES_PER_BOX = 2;
@@ -39,6 +40,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
onExit,
onRewind,
}) => {
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const {
selectedMessageId,
@@ -48,28 +50,58 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
clearSelection,
} = useRewind(conversation);
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const interactions = useMemo(
() => conversation.messages.filter((msg) => msg.type === 'user'),
[conversation.messages],
);
const items = useMemo(
() =>
interactions
.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}))
.reverse(),
[interactions],
);
const items = useMemo(() => {
const interactionItems = interactions.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}));
// Add "Current Position" as the last item
return [
...interactionItems,
{
key: 'current-position',
value: {
id: 'current-position',
type: 'user',
content: 'Stay at current position',
timestamp: new Date().toISOString(),
} as MessageRecord,
index: interactionItems.length,
},
];
}, [interactions]);
useKeypress(
(key) => {
if (!selectedMessageId) {
if (keyMatchers[Command.ESCAPE](key)) {
onExit();
return;
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (
highlightedMessageId &&
highlightedMessageId !== 'current-position'
) {
setExpandedMessageId(highlightedMessageId);
}
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
setExpandedMessageId(null);
}
}
},
@@ -89,6 +121,28 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
if (selectedMessageId) {
if (isRewinding) {
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
padding={1}
width={terminalWidth}
flexDirection="row"
>
<Box>
<CliSpinner />
</Box>
<Text>Rewinding...</Text>
</Box>
);
}
if (selectedMessageId === 'current-position') {
onExit();
return null;
}
const selectedMessage = interactions.find(
(m) => m.id === selectedMessageId,
);
@@ -97,7 +151,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
stats={confirmationStats}
terminalWidth={terminalWidth}
timestamp={selectedMessage?.timestamp}
onConfirm={(outcome) => {
onConfirm={async (outcome) => {
if (outcome === RewindOutcome.Cancel) {
clearSelection();
} else {
@@ -109,7 +163,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
onRewind(selectedMessageId, cleanedText, outcome);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
}
}
}}
@@ -133,17 +188,48 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
<Box flexDirection="column" flexGrow={1}>
<BaseSelectionList
items={items}
initialIndex={items.length - 1}
isFocused={true}
showNumbers={false}
wrapAround={false}
onSelect={(item: MessageRecord) => {
const userPrompt = item;
if (userPrompt && userPrompt.id) {
selectMessage(userPrompt.id);
if (userPrompt.id === 'current-position') {
onExit();
} else {
selectMessage(userPrompt.id);
}
}
}}
onHighlight={(item: MessageRecord) => {
if (item.id) {
setHighlightedMessageId(item.id);
// Collapse when moving selection
setExpandedMessageId(null);
}
}}
maxItemsToShow={maxItemsToShow}
renderItem={(itemWrapper, { isSelected }) => {
const userPrompt = itemWrapper.value;
if (userPrompt.id === 'current-position') {
return (
<Box flexDirection="column" marginBottom={1}>
<Text
color={
isSelected ? theme.status.success : theme.text.primary
}
>
{partToString(userPrompt.content)}
</Text>
<Text color={theme.text.secondary}>
Cancel rewind and stay here
</Text>
</Box>
);
}
const stats = getStats(userPrompt);
const firstFileName = stats?.details?.at(0)?.fileName;
const originalUserText = userPrompt.content
@@ -154,25 +240,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<MaxSizedBox
maxWidth={terminalWidth - 4}
maxHeight={isSelected ? undefined : MAX_LINES_PER_BOX + 1}
overflowDirection="bottom"
>
{cleanedText.split('\n').map((line, i) => (
<Box key={i}>
<Text
color={
isSelected
? theme.status.success
: theme.text.primary
}
>
{line}
</Text>
</Box>
))}
</MaxSizedBox>
<ExpandableText
label={cleanedText}
isExpanded={expandedMessageId === userPrompt.id}
textColor={
isSelected ? theme.status.success : theme.text.primary
}
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
maxLines={MAX_LINES_PER_BOX}
/>
</Box>
{stats ? (
<Box flexDirection="row">
@@ -203,7 +279,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Use Enter to select a message, Esc to close)
(Use Enter to select a message, Esc to close, Right/Left to
expand/collapse)
</Text>
</Box>
</Box>
@@ -6,7 +6,7 @@
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js';
import { CommandKind } from '../commands/types.js';
import { Colors } from '../colors.js';
export interface Suggestion {
@@ -85,7 +85,7 @@ export function SuggestionsDisplay({
const textColor = isActive ? theme.text.accent : theme.text.secondary;
const isLong = suggestion.value.length >= MAX_WIDTH;
const labelElement = (
<PrepareLabel
<ExpandableText
label={suggestion.value}
matchedIndex={suggestion.matchedIndex}
userInput={userInput}
@@ -5,11 +5,14 @@ exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = `
│ │
│ > Rewind │
│ │
some command @file │
some command @file │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -19,11 +22,14 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
│ │
│ > Rewind │
│ │
read @server3:mcp://demo-resource hello │
read @server3:mcp://demo-resource hello │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -63,17 +69,20 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`]
│ │
│ > Rewind │
│ │
│ Q3 │
│ No files have been changed │
│ │
│ ● Q2 │
│ No files have been changed │
│ │
│ Q1 │
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
(Use Enter to select a message, Esc to close)
Q3
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -83,17 +92,20 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
│ │
│ > Rewind │
│ │
│ Q3
│ Q1
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ ● Q1
│ ● Q3
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -103,17 +115,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`]
│ │
│ > Rewind │
│ │
Q3
Q1
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ Q1
│ Q3
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -123,17 +138,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] =
│ │
│ > Rewind │
│ │
│ Q3
│ Q1
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ ● Q1
│ ● Q3
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -143,11 +161,14 @@ exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = `
│ │
│ > Rewind │
│ │
Hello │
Hello │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -157,17 +178,15 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] =
│ │
│ > Rewind │
│ │
1 │
│ 2
│ 3 │
│ 4 │
│ 5 │
│ 6 │
│ 7 │
1 │
│ 2...
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -177,8 +196,11 @@ exports[`RewindViewer > Rendering > renders 'nothing interesting for empty conve
│ │
│ > Rewind │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -188,14 +210,17 @@ exports[`RewindViewer > updates content when conversation changes (background up
│ │
│ > Rewind │
│ │
│ ● Message 2 │
│ No files have been changed │
│ │
│ Message 1 │
│ No files have been changed │
│ │
│ Message 2 │
│ No files have been changed │
│ │
(Use Enter to select a message, Esc to close)
● Stay at current position
│ Cancel rewind and stay here │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -205,11 +230,14 @@ exports[`RewindViewer > updates content when conversation changes (background up
│ │
│ > Rewind │
│ │
Message 1 │
Message 1 │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -219,22 +247,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do
│ │
│ > Rewind │
│ │
│ Line A │
│ Line B... │
│ No files have been changed │
│ │
│ Line 1 │
│ Line 2
│ ... last 5 lines hidden ... │
│ Line 2...
│ No files have been changed │
│ │
│ ● Line A
Line B
│ Line C │
│ Line D │
│ Line E │
│ Line F │
│ Line G │
│ No files have been changed │
│ ● Stay at current position
Cancel rewind and stay here
│ │
│ │
│ (Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -244,22 +269,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial-
│ │
│ > Rewind │
│ │
│ ● Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ No files have been changed │
│ │
│ Line A │
│ Line B
│ ... last 5 lines hidden ... │
│ Line B...
│ No files have been changed │
│ │
│ Line 1 │
│ Line 2... │
│ No files have been changed │
│ │
(Use Enter to select a message, Esc to close)
● Stay at current position
│ Cancel rewind and stay here │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -125,6 +125,7 @@ describe('BaseSelectionList', () => {
onHighlight: mockOnHighlight,
isFocused,
showNumbers,
wrapAround: true,
});
});
@@ -30,6 +30,7 @@ export interface BaseSelectionListProps<
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
wrapAround?: boolean;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -59,6 +60,7 @@ export function BaseSelectionList<
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
wrapAround = true,
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -68,6 +70,7 @@ export function BaseSelectionList<
onHighlight,
isFocused,
showNumbers,
wrapAround,
});
const [scrollOffset, setScrollOffset] = useState(0);
@@ -1,20 +1,20 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { render } from '../../test-utils/render.js';
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
import { render } from '../../../test-utils/render.js';
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
describe('PrepareLabel', () => {
describe('ExpandableText', () => {
const color = 'white';
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
it('renders plain label when no match (short label)', () => {
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label="simple command"
userInput=""
matchedIndex={undefined}
@@ -29,7 +29,7 @@ describe('PrepareLabel', () => {
it('truncates long label when collapsed and no match', () => {
const long = 'x'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={long}
userInput=""
textColor={color}
@@ -47,7 +47,7 @@ describe('PrepareLabel', () => {
it('shows full long label when expanded and no match', () => {
const long = 'y'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={long}
userInput=""
textColor={color}
@@ -66,7 +66,7 @@ describe('PrepareLabel', () => {
const userInput = 'commit';
const matchedIndex = label.indexOf(userInput);
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={label}
userInput={userInput}
matchedIndex={matchedIndex}
@@ -86,7 +86,7 @@ describe('PrepareLabel', () => {
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={label}
userInput={core}
matchedIndex={matchedIndex}
@@ -111,7 +111,7 @@ describe('PrepareLabel', () => {
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={label}
userInput={core}
matchedIndex={matchedIndex}
@@ -128,4 +128,24 @@ describe('PrepareLabel', () => {
expect(out).toMatchSnapshot();
unmount();
});
it('respects custom maxWidth', () => {
const customWidth = 50;
const long = 'z'.repeat(100);
const { lastFrame, unmount } = render(
<ExpandableText
label={long}
userInput=""
textColor={color}
isExpanded={false}
maxWidth={customWidth}
/>,
);
const out = lastFrame();
const f = flat(out);
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(customWidth + 3);
expect(out).toMatchSnapshot();
unmount();
});
});
@@ -1,29 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { theme } from '../../semantic-colors.js';
export const MAX_WIDTH = 150; // Maximum width for the text that is shown
export const MAX_WIDTH = 150;
export interface PrepareLabelProps {
export interface ExpandableTextProps {
label: string;
matchedIndex?: number;
userInput: string;
textColor: string;
userInput?: string;
textColor?: string;
isExpanded?: boolean;
maxWidth?: number;
maxLines?: number;
}
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
const _ExpandableText: React.FC<ExpandableTextProps> = ({
label,
matchedIndex,
userInput,
textColor,
userInput = '',
textColor = theme.text.primary,
isExpanded = false,
maxWidth = MAX_WIDTH,
maxLines,
}) => {
const hasMatch =
matchedIndex !== undefined &&
@@ -33,11 +37,27 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
// Render the plain label if there's no match
if (!hasMatch) {
const display = isExpanded
? label
: label.length > MAX_WIDTH
? label.slice(0, MAX_WIDTH) + '...'
: label;
let display = label;
if (!isExpanded) {
if (maxLines !== undefined) {
const lines = label.split('\n');
// 1. Truncate by logical lines
let truncated = lines.slice(0, maxLines).join('\n');
const hasMoreLines = lines.length > maxLines;
// 2. Truncate by characters (visual approximation) to prevent massive wrapping
if (truncated.length > maxWidth) {
truncated = truncated.slice(0, maxWidth) + '...';
} else if (hasMoreLines) {
truncated += '...';
}
display = truncated;
} else if (label.length > maxWidth) {
display = label.slice(0, maxWidth) + '...';
}
}
return (
<Text wrap="wrap" color={textColor}>
{display}
@@ -51,18 +71,18 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
let after = '';
// Case 1: Show the full string if it's expanded or already fits
if (isExpanded || label.length <= MAX_WIDTH) {
if (isExpanded || label.length <= maxWidth) {
before = label.slice(0, matchedIndex);
match = label.slice(matchedIndex, matchedIndex + matchLength);
after = label.slice(matchedIndex + matchLength);
}
// Case 2: The match itself is too long, so we only show a truncated portion of the match
else if (matchLength >= MAX_WIDTH) {
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
else if (matchLength >= maxWidth) {
match = label.slice(matchedIndex, matchedIndex + maxWidth - 1) + '...';
}
// Case 3: Truncate the string to create a window around the match
else {
const contextSpace = MAX_WIDTH - matchLength;
const contextSpace = maxWidth - matchLength;
const beforeSpace = Math.floor(contextSpace / 2);
const afterSpace = Math.ceil(contextSpace / 2);
@@ -113,4 +133,4 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
);
};
export const PrepareLabel = React.memo(_PrepareLabel);
export const ExpandableText = React.memo(_ExpandableText);
@@ -0,0 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExpandablePrompt > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
`;
exports[`ExpandablePrompt > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandablePrompt > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandablePrompt > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandablePrompt > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandablePrompt > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandablePrompt > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
@@ -0,0 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
@@ -212,6 +212,9 @@ describe('KeypressContext', () => {
name: 'return',
sequence: '\r',
insertable: true,
shift: true,
meta: false,
ctrl: false,
}),
);
});
@@ -158,6 +158,9 @@ function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler {
keypressHandler({
...key,
name: 'return',
shift: true, // to make it a newline, not a submission
ctrl: false,
meta: false,
sequence: '\r',
insertable: true,
});
@@ -18,12 +18,17 @@ import {
isNodeError,
unescapePath,
ReadManyFilesTool,
REFERENCE_CONTENT_START,
REFERENCE_CONTENT_END,
} from '@google/gemini-cli-core';
import { Buffer } from 'node:buffer';
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
import { ToolCallStatus } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
interface HandleAtCommandParams {
query: string;
config: Config;
@@ -499,10 +504,17 @@ export async function handleAtCommand({
const resourceResults = await Promise.all(resourcePromises);
const resourceReadDisplays: IndividualToolCallDisplay[] = [];
let resourceErrorOccurred = false;
let hasAddedReferenceHeader = false;
for (const result of resourceResults) {
resourceReadDisplays.push(result.display);
if (result.success) {
if (!hasAddedReferenceHeader) {
processedQueryParts.push({
text: REF_CONTENT_HEADER,
});
hasAddedReferenceHeader = true;
}
processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` });
processedQueryParts.push(...result.parts);
} else {
@@ -540,6 +552,9 @@ export async function handleAtCommand({
userMessageTimestamp,
);
}
if (hasAddedReferenceHeader) {
processedQueryParts.push({ text: REF_CONTENT_FOOTER });
}
return { processedQuery: processedQueryParts };
}
@@ -570,9 +585,12 @@ export async function handleAtCommand({
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
});
if (!hasAddedReferenceHeader) {
processedQueryParts.push({
text: REF_CONTENT_HEADER,
});
hasAddedReferenceHeader = true;
}
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);
@@ -156,11 +156,22 @@ describe('useSlashCommandProcessor', () => {
});
const setupProcessorHook = async (
builtinCommands: SlashCommand[] = [],
fileCommands: SlashCommand[] = [],
mcpCommands: SlashCommand[] = [],
setIsProcessing = vi.fn(),
options: {
builtinCommands?: SlashCommand[];
fileCommands?: SlashCommand[];
mcpCommands?: SlashCommand[];
setIsProcessing?: (isProcessing: boolean) => void;
refreshStatic?: () => void;
} = {},
) => {
const {
builtinCommands = [],
fileCommands = [],
mcpCommands = [],
setIsProcessing = vi.fn(),
refreshStatic = vi.fn(),
} = options;
mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands));
mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands));
@@ -177,7 +188,7 @@ describe('useSlashCommandProcessor', () => {
mockAddItem,
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
refreshStatic,
vi.fn(), // toggleVimEnabled
setIsProcessing,
{
@@ -195,6 +206,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
setText: vi.fn(),
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
@@ -233,7 +245,9 @@ describe('useSlashCommandProcessor', () => {
context.ui.clear();
},
});
const result = await setupProcessorHook([clearCommand]);
const result = await setupProcessorHook({
builtinCommands: [clearCommand],
});
await act(async () => {
await result.current.handleSlashCommand('/clear');
@@ -250,7 +264,9 @@ describe('useSlashCommandProcessor', () => {
context.ui.clear();
},
});
const result = await setupProcessorHook([clearCommand]);
const result = await setupProcessorHook({
builtinCommands: [clearCommand],
});
await act(async () => {
await result.current.handleSlashCommand('/clear');
@@ -270,7 +286,9 @@ describe('useSlashCommandProcessor', () => {
it('should call loadCommands and populate state after mounting', async () => {
const testCommand = createTestCommand({ name: 'test' });
const result = await setupProcessorHook([testCommand]);
const result = await setupProcessorHook({
builtinCommands: [testCommand],
});
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(1);
@@ -284,7 +302,9 @@ describe('useSlashCommandProcessor', () => {
it('should provide an immutable array of commands to consumers', async () => {
const testCommand = createTestCommand({ name: 'test' });
const result = await setupProcessorHook([testCommand]);
const result = await setupProcessorHook({
builtinCommands: [testCommand],
});
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(1);
@@ -312,7 +332,10 @@ describe('useSlashCommandProcessor', () => {
CommandKind.FILE,
);
const result = await setupProcessorHook([builtinCommand], [fileCommand]);
const result = await setupProcessorHook({
builtinCommands: [builtinCommand],
fileCommands: [fileCommand],
});
await waitFor(() => {
// The service should only return one command with the name 'override'
@@ -362,7 +385,9 @@ describe('useSlashCommandProcessor', () => {
},
],
};
const result = await setupProcessorHook([parentCommand]);
const result = await setupProcessorHook({
builtinCommands: [parentCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -396,7 +421,9 @@ describe('useSlashCommandProcessor', () => {
},
],
};
const result = await setupProcessorHook([parentCommand]);
const result = await setupProcessorHook({
builtinCommands: [parentCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -420,7 +447,9 @@ describe('useSlashCommandProcessor', () => {
it('sets isProcessing to false if the the input is not a command', async () => {
const setMockIsProcessing = vi.fn();
const result = await setupProcessorHook([], [], [], setMockIsProcessing);
const result = await setupProcessorHook({
setIsProcessing: setMockIsProcessing,
});
await act(async () => {
await result.current.handleSlashCommand('imnotacommand');
@@ -436,12 +465,10 @@ describe('useSlashCommandProcessor', () => {
action: vi.fn().mockRejectedValue(new Error('oh no!')),
});
const result = await setupProcessorHook(
[failCommand],
[],
[],
setMockIsProcessing,
);
const result = await setupProcessorHook({
builtinCommands: [failCommand],
setIsProcessing: setMockIsProcessing,
});
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
@@ -460,12 +487,10 @@ describe('useSlashCommandProcessor', () => {
action: () => new Promise((resolve) => setTimeout(resolve, 50)),
});
const result = await setupProcessorHook(
[command],
[],
[],
mockSetIsProcessing,
);
const result = await setupProcessorHook({
builtinCommands: [command],
setIsProcessing: mockSetIsProcessing,
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
const executionPromise = act(async () => {
@@ -507,7 +532,9 @@ describe('useSlashCommandProcessor', () => {
.fn()
.mockResolvedValue({ type: 'dialog', dialog: dialogType }),
});
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() =>
expect(result.current.slashCommands).toHaveLength(1),
);
@@ -536,20 +563,42 @@ describe('useSlashCommandProcessor', () => {
clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }],
}),
});
const result = await setupProcessorHook([command]);
const mockRefreshStatic = vi.fn();
const result = await setupProcessorHook({
builtinCommands: [command],
refreshStatic: mockRefreshStatic,
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/load');
});
// ui.clear() is called which calls refreshStatic()
expect(mockClearItems).toHaveBeenCalledTimes(1);
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'user', text: 'old prompt' },
expect.any(Number),
);
});
it('should call refreshStatic exactly once when ui.loadHistory is called', async () => {
const mockRefreshStatic = vi.fn();
const result = await setupProcessorHook({
refreshStatic: mockRefreshStatic,
});
await act(async () => {
result.current.commandContext.ui.loadHistory([]);
});
expect(mockLoadHistory).toHaveBeenCalled();
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
});
it('should handle a "quit" action', async () => {
const quitAction = vi
.fn()
@@ -558,7 +607,9 @@ describe('useSlashCommandProcessor', () => {
name: 'exit',
action: quitAction,
});
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
@@ -581,7 +632,9 @@ describe('useSlashCommandProcessor', () => {
CommandKind.FILE,
);
const result = await setupProcessorHook([], [fileCommand]);
const result = await setupProcessorHook({
fileCommands: [fileCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
let actionResult;
@@ -613,7 +666,9 @@ describe('useSlashCommandProcessor', () => {
CommandKind.MCP_PROMPT,
);
const result = await setupProcessorHook([], [], [mcpCommand]);
const result = await setupProcessorHook({
mcpCommands: [mcpCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
let actionResult;
@@ -636,7 +691,9 @@ describe('useSlashCommandProcessor', () => {
describe('Command Parsing and Matching', () => {
it('should be case-sensitive', async () => {
const command = createTestCommand({ name: 'test' });
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -662,7 +719,9 @@ describe('useSlashCommandProcessor', () => {
description: 'a command with an alias',
action,
});
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -678,7 +737,9 @@ describe('useSlashCommandProcessor', () => {
it('should handle extra whitespace around the command', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'test', action });
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -691,7 +752,9 @@ describe('useSlashCommandProcessor', () => {
it('should handle `?` as a command prefix', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'help', action });
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -720,7 +783,10 @@ describe('useSlashCommandProcessor', () => {
CommandKind.FILE,
);
const result = await setupProcessorHook([], [fileCommand], [mcpCommand]);
const result = await setupProcessorHook({
fileCommands: [fileCommand],
mcpCommands: [mcpCommand],
});
await waitFor(() => {
// The service should only return one command with the name 'override'
@@ -756,7 +822,10 @@ describe('useSlashCommandProcessor', () => {
// The order of commands in the final loaded array is not guaranteed,
// so the test must work regardless of which comes first.
const result = await setupProcessorHook([quitCommand], [exitCommand]);
const result = await setupProcessorHook({
builtinCommands: [quitCommand],
fileCommands: [exitCommand],
});
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(2);
@@ -783,7 +852,10 @@ describe('useSlashCommandProcessor', () => {
CommandKind.FILE,
);
const result = await setupProcessorHook([quitCommand], [exitCommand]);
const result = await setupProcessorHook({
builtinCommands: [quitCommand],
fileCommands: [exitCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
await act(async () => {
@@ -879,7 +951,9 @@ describe('useSlashCommandProcessor', () => {
desc: 'command path when alias is used',
},
])('should log $desc', async ({ command, expectedLog }) => {
const result = await setupProcessorHook(loggingTestCommands);
const result = await setupProcessorHook({
builtinCommands: loggingTestCommands,
});
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
await act(async () => {
@@ -898,7 +972,9 @@ describe('useSlashCommandProcessor', () => {
{ command: '/bogusbogusbogus', desc: 'bogus command' },
{ command: '/unknown', desc: 'unknown command' },
])('should not log for $desc', async ({ command }) => {
const result = await setupProcessorHook(loggingTestCommands);
const result = await setupProcessorHook({
builtinCommands: loggingTestCommands,
});
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
await act(async () => {
@@ -76,6 +76,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
setText: (text: string) => void;
}
/**
@@ -210,7 +211,13 @@ export const useSlashCommandProcessor = (
refreshStatic();
setBannerVisible(false);
},
loadHistory,
loadHistory: (history, postLoadInput) => {
loadHistory(history);
refreshStatic();
if (postLoadInput !== undefined) {
actions.setText(postLoadInput);
}
},
setDebugMessage: actions.setDebugMessage,
pendingItem,
setPendingItem,
@@ -1962,73 +1962,6 @@ describe('useGeminiStream', () => {
});
});
describe('MCP Discovery State', () => {
it('should block non-slash command queries when discovery is in progress and servers exist', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
expect(mockSendMessageStream).not.toHaveBeenCalled();
});
it('should NOT block queries when discovery is NOT_STARTED but there are no servers', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.NOT_STARTED),
getMcpServerCount: vi.fn().mockReturnValue(0),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
expect(mockSendMessageStream).toHaveBeenCalled();
});
it('should NOT block slash commands even when discovery is in progress', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('/help');
});
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
});
});
describe('handleFinishedEvent', () => {
it('should add info message for MAX_TOKENS finish reason', async () => {
// Setup mock to return a stream with MAX_TOKENS finish reason
@@ -3182,68 +3115,4 @@ describe('useGeminiStream', () => {
});
});
});
describe('MCP Server Initialization', () => {
it('should allow slash commands to run while MCP servers are initializing', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('/help');
});
// Slash command should be handled, and no Gemini call should be made.
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help');
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
});
it('should block normal prompts and provide feedback while MCP servers are initializing', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('a normal prompt');
});
// No slash command, no Gemini call, but feedback should be emitted.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
expect(mockSendMessageStream).not.toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
});
it('should allow normal prompts to run when MCP servers are finished initializing', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('a normal prompt');
});
// Prompt should be sent to Gemini.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
expect(mockSendMessageStream).toHaveBeenCalled();
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
});
});
});
+32 -22
View File
@@ -31,7 +31,6 @@ import {
ValidationRequiredError,
coreEvents,
CoreEvent,
MCPDiscoveryState,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -803,7 +802,12 @@ export const useGeminiStream = (
);
const handleAgentExecutionStoppedEvent = useCallback(
(reason: string, userMessageTimestamp: number, systemMessage?: string) => {
(
reason: string,
userMessageTimestamp: number,
systemMessage?: string,
contextCleared?: boolean,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
@@ -815,13 +819,27 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
if (contextCleared) {
addItem(
{
type: MessageType.INFO,
text: 'Conversation context has been cleared.',
},
userMessageTimestamp,
);
}
setIsResponding(false);
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setIsResponding],
);
const handleAgentExecutionBlockedEvent = useCallback(
(reason: string, userMessageTimestamp: number, systemMessage?: string) => {
(
reason: string,
userMessageTimestamp: number,
systemMessage?: string,
contextCleared?: boolean,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
@@ -833,6 +851,15 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
if (contextCleared) {
addItem(
{
type: MessageType.INFO,
text: 'Conversation context has been cleared.',
},
userMessageTimestamp,
);
}
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
@@ -873,6 +900,7 @@ export const useGeminiStream = (
event.value.reason,
userMessageTimestamp,
event.value.systemMessage,
event.value.contextCleared,
);
break;
case ServerGeminiEventType.AgentExecutionBlocked:
@@ -880,6 +908,7 @@ export const useGeminiStream = (
event.value.reason,
userMessageTimestamp,
event.value.systemMessage,
event.value.contextCleared,
);
break;
case ServerGeminiEventType.ChatCompressed:
@@ -961,25 +990,6 @@ export const useGeminiStream = (
async ({ metadata: spanMetadata }) => {
spanMetadata.input = query;
const discoveryState = config
.getMcpClientManager()
?.getDiscoveryState();
const mcpServerCount =
config.getMcpClientManager()?.getMcpServerCount() ?? 0;
if (
!options?.isContinuation &&
typeof query === 'string' &&
!isSlashCommand(query.trim()) &&
mcpServerCount > 0 &&
discoveryState !== MCPDiscoveryState.COMPLETED
) {
coreEvents.emitFeedback(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
return;
}
const queryId = `${Date.now()}-${Math.random()}`;
activeQueryIdRef.current = queryId;
if (
@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { act } from 'react';
import { render } from '../../test-utils/render.js';
import { useMcpStatus } from './useMcpStatus.js';
import {
MCPDiscoveryState,
type Config,
CoreEvent,
coreEvents,
} from '@google/gemini-cli-core';
describe('useMcpStatus', () => {
let mockConfig: Config;
let mockMcpClientManager: {
getDiscoveryState: Mock<() => MCPDiscoveryState>;
getMcpServerCount: Mock<() => number>;
};
beforeEach(() => {
mockMcpClientManager = {
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.NOT_STARTED),
getMcpServerCount: vi.fn().mockReturnValue(0),
};
mockConfig = {
getMcpClientManager: vi.fn().mockReturnValue(mockMcpClientManager),
} as unknown as Config;
});
const renderMcpStatusHook = (config: Config) => {
let hookResult: ReturnType<typeof useMcpStatus>;
function TestComponent({ config }: { config: Config }) {
hookResult = useMcpStatus(config);
return null;
}
render(<TestComponent config={config} />);
return {
result: {
get current() {
return hookResult;
},
},
};
};
it('should initialize with correct values (no servers)', () => {
const { result } = renderMcpStatusHook(mockConfig);
expect(result.current.discoveryState).toBe(MCPDiscoveryState.NOT_STARTED);
expect(result.current.mcpServerCount).toBe(0);
expect(result.current.isMcpReady).toBe(true);
});
it('should initialize with correct values (with servers, not started)', () => {
mockMcpClientManager.getMcpServerCount.mockReturnValue(1);
const { result } = renderMcpStatusHook(mockConfig);
expect(result.current.isMcpReady).toBe(false);
});
it('should not be ready while in progress', () => {
mockMcpClientManager.getDiscoveryState.mockReturnValue(
MCPDiscoveryState.IN_PROGRESS,
);
mockMcpClientManager.getMcpServerCount.mockReturnValue(1);
const { result } = renderMcpStatusHook(mockConfig);
expect(result.current.isMcpReady).toBe(false);
});
it('should update state when McpClientUpdate is emitted', () => {
mockMcpClientManager.getMcpServerCount.mockReturnValue(1);
mockMcpClientManager.getDiscoveryState.mockReturnValue(
MCPDiscoveryState.IN_PROGRESS,
);
const { result } = renderMcpStatusHook(mockConfig);
expect(result.current.isMcpReady).toBe(false);
mockMcpClientManager.getDiscoveryState.mockReturnValue(
MCPDiscoveryState.COMPLETED,
);
act(() => {
coreEvents.emit(CoreEvent.McpClientUpdate, new Map());
});
expect(result.current.discoveryState).toBe(MCPDiscoveryState.COMPLETED);
expect(result.current.isMcpReady).toBe(true);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useState } from 'react';
import {
type Config,
coreEvents,
MCPDiscoveryState,
CoreEvent,
} from '@google/gemini-cli-core';
export function useMcpStatus(config: Config) {
const [discoveryState, setDiscoveryState] = useState<MCPDiscoveryState>(
() =>
config.getMcpClientManager()?.getDiscoveryState() ??
MCPDiscoveryState.NOT_STARTED,
);
const [mcpServerCount, setMcpServerCount] = useState<number>(
() => config.getMcpClientManager()?.getMcpServerCount() ?? 0,
);
useEffect(() => {
const onChange = () => {
const manager = config.getMcpClientManager();
if (manager) {
setDiscoveryState(manager.getDiscoveryState());
setMcpServerCount(manager.getMcpServerCount());
}
};
coreEvents.on(CoreEvent.McpClientUpdate, onChange);
return () => {
coreEvents.off(CoreEvent.McpClientUpdate, onChange);
};
}, [config]);
// We are ready if discovery has completed, OR if it hasn't even started and there are no servers.
const isMcpReady =
discoveryState === MCPDiscoveryState.COMPLETED ||
(discoveryState === MCPDiscoveryState.NOT_STARTED && mcpServerCount === 0);
return {
discoveryState,
mcpServerCount,
isMcpReady,
};
}
@@ -28,6 +28,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: boolean;
streamingState: StreamingState;
submitQuery: (query: string) => void;
isMcpReady: boolean;
}) => {
let hookResult: ReturnType<typeof useMessageQueue>;
function TestComponent(props: typeof initialProps) {
@@ -51,6 +52,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
expect(result.current.messageQueue).toEqual([]);
@@ -62,6 +64,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
act(() => {
@@ -80,6 +83,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
act(() => {
@@ -100,6 +104,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
act(() => {
@@ -120,6 +125,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
act(() => {
@@ -133,11 +139,12 @@ describe('useMessageQueue', () => {
);
});
it('should auto-submit queued messages when transitioning to Idle', async () => {
it('should auto-submit queued messages when transitioning to Idle and MCP is ready', async () => {
const { result, rerender } = renderMessageQueueHook({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
// Add some messages
@@ -157,11 +164,37 @@ describe('useMessageQueue', () => {
});
});
it('should wait for MCP readiness before auto-submitting', async () => {
const { result, rerender } = renderMessageQueueHook({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: false,
});
// Add some messages while Idle but MCP not ready
act(() => {
result.current.addMessage('Delayed message');
});
expect(result.current.messageQueue).toEqual(['Delayed message']);
expect(mockSubmitQuery).not.toHaveBeenCalled();
// Transition MCP to ready
rerender({ isMcpReady: true });
await waitFor(() => {
expect(mockSubmitQuery).toHaveBeenCalledWith('Delayed message');
expect(result.current.messageQueue).toEqual([]);
});
});
it('should not auto-submit when queue is empty', () => {
const { rerender } = renderMessageQueueHook({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
// Transition to Idle with empty queue
@@ -175,6 +208,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
// Add messages
@@ -194,6 +228,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
// Start responding
@@ -235,6 +270,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
// Add multiple messages
@@ -265,6 +301,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
});
let poppedMessages: string | undefined = 'not-undefined';
@@ -281,6 +318,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: false,
});
act(() => {
@@ -301,6 +339,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: false,
});
act(() => {
@@ -330,6 +369,7 @@ describe('useMessageQueue', () => {
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: false,
});
// Add messages
+10 -1
View File
@@ -11,6 +11,7 @@ export interface UseMessageQueueOptions {
isConfigInitialized: boolean;
streamingState: StreamingState;
submitQuery: (query: string) => void;
isMcpReady: boolean;
}
export interface UseMessageQueueReturn {
@@ -30,6 +31,7 @@ export function useMessageQueue({
isConfigInitialized,
streamingState,
submitQuery,
isMcpReady,
}: UseMessageQueueOptions): UseMessageQueueReturn {
const [messageQueue, setMessageQueue] = useState<string[]>([]);
@@ -67,6 +69,7 @@ export function useMessageQueue({
if (
isConfigInitialized &&
streamingState === StreamingState.Idle &&
isMcpReady &&
messageQueue.length > 0
) {
// Combine all messages with double newlines for clarity
@@ -75,7 +78,13 @@ export function useMessageQueue({
setMessageQueue([]);
submitQuery(combinedMessage);
}
}, [isConfigInitialized, streamingState, messageQueue, submitQuery]);
}, [
isConfigInitialized,
streamingState,
isMcpReady,
messageQueue,
submitQuery,
]);
return {
messageQueue,
@@ -79,6 +79,7 @@ describe('useSelectionList', () => {
initialIndex?: number;
isFocused?: boolean;
showNumbers?: boolean;
wrapAround?: boolean;
}) => {
let hookResult: ReturnType<typeof useSelectionList>;
function TestComponent(props: typeof initialProps) {
@@ -285,6 +286,39 @@ describe('useSelectionList', () => {
});
});
describe('Wrapping (wrapAround)', () => {
it('should wrap by default (wrapAround=true)', async () => {
const { result } = await renderSelectionListHook({
items,
initialIndex: items.length - 1,
onSelect: mockOnSelect,
});
expect(result.current.activeIndex).toBe(3);
pressKey('down');
expect(result.current.activeIndex).toBe(0);
pressKey('up');
expect(result.current.activeIndex).toBe(3);
});
it('should not wrap when wrapAround is false', async () => {
const { result } = await renderSelectionListHook({
items,
initialIndex: items.length - 1,
onSelect: mockOnSelect,
wrapAround: false,
});
expect(result.current.activeIndex).toBe(3);
pressKey('down');
expect(result.current.activeIndex).toBe(3); // Should stay at bottom
act(() => result.current.setActiveIndex(0));
expect(result.current.activeIndex).toBe(0);
pressKey('up');
expect(result.current.activeIndex).toBe(0); // Should stay at top
});
});
describe('Selection (Enter)', () => {
it('should call onSelect when "return" is pressed on enabled item', async () => {
await renderSelectionListHook({
+56 -12
View File
@@ -27,6 +27,7 @@ export interface UseSelectionListOptions<T> {
onHighlight?: (value: T) => void;
isFocused?: boolean;
showNumbers?: boolean;
wrapAround?: boolean;
}
export interface UseSelectionListResult {
@@ -40,6 +41,7 @@ interface SelectionListState {
pendingHighlight: boolean;
pendingSelect: boolean;
items: BaseSelectionItem[];
wrapAround: boolean;
}
type SelectionListAction =
@@ -60,7 +62,11 @@ type SelectionListAction =
}
| {
type: 'INITIALIZE';
payload: { initialIndex: number; items: BaseSelectionItem[] };
payload: {
initialIndex: number;
items: BaseSelectionItem[];
wrapAround: boolean;
};
}
| {
type: 'CLEAR_PENDING_FLAGS';
@@ -75,6 +81,7 @@ const findNextValidIndex = (
currentIndex: number,
direction: 'up' | 'down',
items: BaseSelectionItem[],
wrapAround = true,
): number => {
const len = items.length;
if (len === 0) return currentIndex;
@@ -83,13 +90,34 @@ const findNextValidIndex = (
const step = direction === 'down' ? 1 : -1;
for (let i = 0; i < len; i++) {
// Calculate the next index, wrapping around if necessary.
// We add `len` before the modulo to ensure a positive result in JS for negative steps.
nextIndex = (nextIndex + step + len) % len;
const candidateIndex = nextIndex + step;
if (wrapAround) {
// Calculate the next index, wrapping around if necessary.
// We add `len` before the modulo to ensure a positive result in JS for negative steps.
nextIndex = (candidateIndex + len) % len;
} else {
if (candidateIndex < 0 || candidateIndex >= len) {
// Out of bounds and wrapping is disabled
return currentIndex;
}
nextIndex = candidateIndex;
}
if (!items[nextIndex]?.disabled) {
return nextIndex;
}
if (!wrapAround) {
// If the item is disabled and we're not wrapping, we continue searching
// in the same direction, but we must stop if we hit the bounds.
if (
(direction === 'down' && nextIndex === len - 1) ||
(direction === 'up' && nextIndex === 0)
) {
return currentIndex;
}
}
}
// If all items are disabled, return the original index
@@ -120,7 +148,7 @@ const computeInitialIndex = (
}
if (items[targetIndex]?.disabled) {
const nextValid = findNextValidIndex(targetIndex, 'down', items);
const nextValid = findNextValidIndex(targetIndex, 'down', items, true);
targetIndex = nextValid;
}
@@ -148,8 +176,13 @@ function selectionListReducer(
}
case 'MOVE_UP': {
const { items } = state;
const newIndex = findNextValidIndex(state.activeIndex, 'up', items);
const { items, wrapAround } = state;
const newIndex = findNextValidIndex(
state.activeIndex,
'up',
items,
wrapAround,
);
if (newIndex !== state.activeIndex) {
return { ...state, activeIndex: newIndex, pendingHighlight: true };
}
@@ -157,8 +190,13 @@ function selectionListReducer(
}
case 'MOVE_DOWN': {
const { items } = state;
const newIndex = findNextValidIndex(state.activeIndex, 'down', items);
const { items, wrapAround } = state;
const newIndex = findNextValidIndex(
state.activeIndex,
'down',
items,
wrapAround,
);
if (newIndex !== state.activeIndex) {
return { ...state, activeIndex: newIndex, pendingHighlight: true };
}
@@ -170,7 +208,7 @@ function selectionListReducer(
}
case 'INITIALIZE': {
const { initialIndex, items } = action.payload;
const { initialIndex, items, wrapAround } = action.payload;
const activeKey =
initialIndex === state.initialIndex &&
state.activeIndex !== state.initialIndex
@@ -186,6 +224,7 @@ function selectionListReducer(
initialIndex,
activeIndex: targetIndex,
pendingHighlight: false,
wrapAround,
};
}
@@ -245,6 +284,7 @@ export function useSelectionList<T>({
onHighlight,
isFocused = true,
showNumbers = false,
wrapAround = true,
}: UseSelectionListOptions<T>): UseSelectionListResult {
const baseItems = toBaseItems(items);
@@ -254,12 +294,14 @@ export function useSelectionList<T>({
pendingHighlight: false,
pendingSelect: false,
items: baseItems,
wrapAround,
});
const numberInputRef = useRef('');
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
const prevBaseItemsRef = useRef(baseItems);
const prevInitialIndexRef = useRef(initialIndex);
const prevWrapAroundRef = useRef(wrapAround);
// Initialize/synchronize state when initialIndex or items change
useEffect(() => {
@@ -268,14 +310,16 @@ export function useSelectionList<T>({
baseItems,
);
const initialIndexChanged = prevInitialIndexRef.current !== initialIndex;
const wrapAroundChanged = prevWrapAroundRef.current !== wrapAround;
if (baseItemsChanged || initialIndexChanged) {
if (baseItemsChanged || initialIndexChanged || wrapAroundChanged) {
dispatch({
type: 'INITIALIZE',
payload: { initialIndex, items: baseItems },
payload: { initialIndex, items: baseItems, wrapAround },
});
prevBaseItemsRef.current = baseItems;
prevInitialIndexRef.current = initialIndex;
prevWrapAroundRef.current = wrapAround;
}
});
@@ -109,7 +109,7 @@ describe('useSessionResume', () => {
1,
true,
);
expect(mockRefreshStatic).toHaveBeenCalled();
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith(
clientHistory,
resumedData,
@@ -174,7 +174,7 @@ describe('useSessionResume', () => {
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
expect(mockRefreshStatic).toHaveBeenCalled();
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData);
});
});
@@ -338,6 +338,7 @@ describe('useSessionResume', () => {
1,
true,
);
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
expect(mockGeminiClient.resumeChat).toHaveBeenCalled();
});
+6 -4
View File
@@ -4,6 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
REFERENCE_CONTENT_START,
REFERENCE_CONTENT_END,
} from '@google/gemini-cli-core';
export const formatMemoryUsage = (bytes: number): string => {
const gb = bytes / (1024 * 1024 * 1024);
if (bytes < 1024 * 1024) {
@@ -76,12 +81,9 @@ export const formatTimeAgo = (date: string | number | Date): string => {
return `${formatDuration(diffMs)} ago`;
};
const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
const REFERENCE_CONTENT_END = '--- End of content ---';
/**
* Removes content bounded by reference content markers from the given text.
* The markers are "--- Content from referenced files ---" and "--- End of content ---".
* The markers are "${REFERENCE_CONTENT_START}" and "${REFERENCE_CONTENT_END}".
*
* @param text The input text containing potential reference blocks.
* @returns The text with reference blocks removed and trimmed.
@@ -27,6 +27,7 @@ import {
ToolCallEvent,
debugLogger,
ReadManyFilesTool,
REFERENCE_CONTENT_START,
resolveModel,
createWorkingStdio,
startupProfiler,
@@ -817,7 +818,7 @@ export class Session {
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
text: `\n${REFERENCE_CONTENT_START}`,
});
for (const part of result.llmContent) {
if (typeof part === 'string') {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+2 -2
View File
@@ -634,7 +634,7 @@ export class Config {
this.planEnabled = params.plan ?? false;
this.enableEventDrivenScheduler =
params.enableEventDrivenScheduler ?? false;
this.skillsSupport = params.skillsSupport ?? false;
this.skillsSupport = params.skillsSupport ?? true;
this.disabledSkills = params.disabledSkills ?? [];
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
this.modelAvailabilityService = new ModelAvailabilityService();
@@ -682,7 +682,7 @@ export class Config {
? false
: (params.useWriteTodos ?? true);
this.enableHooksUI = params.enableHooksUI ?? true;
this.enableHooks = params.enableHooks ?? false;
this.enableHooks = params.enableHooks ?? true;
this.disabledHooks = params.disabledHooks ?? [];
this.codebaseInvestigatorSettings = {
+64 -13
View File
@@ -3107,6 +3107,7 @@ ${JSON.stringify(
mockHookSystem.fireAfterAgentEvent.mockResolvedValue({
shouldStopExecution: () => true,
getEffectiveReason: () => 'Stopped after agent',
shouldClearContext: () => false,
systemMessage: undefined,
});
@@ -3121,10 +3122,12 @@ ${JSON.stringify(
);
const events = await fromAsync(stream);
expect(events).toContainEqual({
type: GeminiEventType.AgentExecutionStopped,
value: { reason: 'Stopped after agent' },
});
expect(events).toContainEqual(
expect.objectContaining({
type: GeminiEventType.AgentExecutionStopped,
value: expect.objectContaining({ reason: 'Stopped after agent' }),
}),
);
// sendMessageStream should not recurse
expect(mockTurnRunFn).toHaveBeenCalledTimes(1);
});
@@ -3135,11 +3138,60 @@ ${JSON.stringify(
shouldStopExecution: () => false,
isBlockingDecision: () => true,
getEffectiveReason: () => 'Please explain',
shouldClearContext: () => false,
systemMessage: undefined,
})
.mockResolvedValueOnce({
shouldStopExecution: () => false,
isBlockingDecision: () => false,
shouldClearContext: () => false,
systemMessage: undefined,
});
mockTurnRunFn.mockImplementation(async function* () {
yield { type: GeminiEventType.Content, value: 'Response' };
});
const stream = client.sendMessageStream(
{ text: 'Hi' },
new AbortController().signal,
'test-prompt',
);
const events = await fromAsync(stream);
expect(events).toContainEqual(
expect.objectContaining({
type: GeminiEventType.AgentExecutionBlocked,
value: expect.objectContaining({ reason: 'Please explain' }),
}),
);
// Should have called turn run twice (original + re-prompt)
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
2,
expect.anything(),
[{ text: 'Please explain' }],
expect.anything(),
);
});
it('should call resetChat when AfterAgent hook returns shouldClearContext: true', async () => {
const resetChatSpy = vi
.spyOn(client, 'resetChat')
.mockResolvedValue(undefined);
mockHookSystem.fireAfterAgentEvent
.mockResolvedValueOnce({
shouldStopExecution: () => false,
isBlockingDecision: () => true,
getEffectiveReason: () => 'Blocked and clearing context',
shouldClearContext: () => true,
systemMessage: undefined,
})
.mockResolvedValueOnce({
shouldStopExecution: () => false,
isBlockingDecision: () => false,
shouldClearContext: () => false,
systemMessage: undefined,
});
@@ -3156,16 +3208,15 @@ ${JSON.stringify(
expect(events).toContainEqual({
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Please explain' },
value: {
reason: 'Blocked and clearing context',
systemMessage: undefined,
contextCleared: true,
},
});
// Should have called turn run twice (original + re-prompt)
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
2,
expect.anything(),
[{ text: 'Please explain' }],
expect.anything(),
);
expect(resetChatSpy).toHaveBeenCalledTimes(1);
resetChatSpy.mockRestore();
});
});
});
+25 -7
View File
@@ -40,7 +40,10 @@ import {
logContentRetryFailure,
logNextSpeakerCheck,
} from '../telemetry/loggers.js';
import type { DefaultHookOutput } from '../hooks/types.js';
import type {
DefaultHookOutput,
AfterAgentHookOutput,
} from '../hooks/types.js';
import {
ContentRetryFailureEvent,
NextSpeakerCheckEvent,
@@ -812,26 +815,41 @@ export class GeminiClient {
turn,
);
if (hookOutput?.shouldStopExecution()) {
// Cast to AfterAgentHookOutput for access to shouldClearContext()
const afterAgentOutput = hookOutput as AfterAgentHookOutput | undefined;
if (afterAgentOutput?.shouldStopExecution()) {
const contextCleared = afterAgentOutput.shouldClearContext();
yield {
type: GeminiEventType.AgentExecutionStopped,
value: {
reason: hookOutput.getEffectiveReason(),
systemMessage: hookOutput.systemMessage,
reason: afterAgentOutput.getEffectiveReason(),
systemMessage: afterAgentOutput.systemMessage,
contextCleared,
},
};
// Clear context if requested (honor both stop + clear)
if (contextCleared) {
await this.resetChat();
}
return turn;
}
if (hookOutput?.isBlockingDecision()) {
const continueReason = hookOutput.getEffectiveReason();
if (afterAgentOutput?.isBlockingDecision()) {
const continueReason = afterAgentOutput.getEffectiveReason();
const contextCleared = afterAgentOutput.shouldClearContext();
yield {
type: GeminiEventType.AgentExecutionBlocked,
value: {
reason: continueReason,
systemMessage: hookOutput.systemMessage,
systemMessage: afterAgentOutput.systemMessage,
contextCleared,
},
};
// Clear context if requested
if (contextCleared) {
await this.resetChat();
}
const continueRequest = [{ text: continueReason }];
yield* this.sendMessageStream(
continueRequest,
+2
View File
@@ -79,6 +79,7 @@ export type ServerGeminiAgentExecutionStoppedEvent = {
value: {
reason: string;
systemMessage?: string;
contextCleared?: boolean;
};
};
@@ -87,6 +88,7 @@ export type ServerGeminiAgentExecutionBlockedEvent = {
value: {
reason: string;
systemMessage?: string;
contextCleared?: boolean;
};
};
+16 -3
View File
@@ -16,6 +16,7 @@ import {
BeforeModelHookOutput,
BeforeToolSelectionHookOutput,
AfterModelHookOutput,
AfterAgentHookOutput,
} from './types.js';
import { HookEventName } from './types.js';
@@ -158,11 +159,21 @@ export class HookAggregator {
merged.suppressOutput = true;
}
// Merge hookSpecificOutput
if (output.hookSpecificOutput) {
// Handle clearContext (any true wins) - for AfterAgent hooks
if (output.hookSpecificOutput?.['clearContext'] === true) {
merged.hookSpecificOutput = {
...(merged.hookSpecificOutput || {}),
...output.hookSpecificOutput,
clearContext: true,
};
}
// Merge hookSpecificOutput (excluding clearContext which is handled above)
if (output.hookSpecificOutput) {
const { clearContext: _clearContext, ...restSpecificOutput } =
output.hookSpecificOutput;
merged.hookSpecificOutput = {
...(merged.hookSpecificOutput || {}),
...restSpecificOutput,
};
}
@@ -323,6 +334,8 @@ export class HookAggregator {
return new BeforeToolSelectionHookOutput(output);
case HookEventName.AfterModel:
return new AfterModelHookOutput(output);
case HookEventName.AfterAgent:
return new AfterAgentHookOutput(output);
default:
return new DefaultHookOutput(output);
}
+34
View File
@@ -140,6 +140,8 @@ export function createHookOutput(
return new BeforeToolSelectionHookOutput(data);
case 'BeforeTool':
return new BeforeToolHookOutput(data);
case 'AfterAgent':
return new AfterAgentHookOutput(data);
default:
return new DefaultHookOutput(data);
}
@@ -238,6 +240,13 @@ export class DefaultHookOutput implements HookOutput {
}
return { blocked: false, reason: '' };
}
/**
* Check if context clearing was requested by hook.
*/
shouldClearContext(): boolean {
return false;
}
}
/**
@@ -362,6 +371,21 @@ export class AfterModelHookOutput extends DefaultHookOutput {
}
}
/**
* Specific hook output class for AfterAgent events
*/
export class AfterAgentHookOutput extends DefaultHookOutput {
/**
* Check if context clearing was requested by hook
*/
override shouldClearContext(): boolean {
if (this.hookSpecificOutput && 'clearContext' in this.hookSpecificOutput) {
return this.hookSpecificOutput['clearContext'] === true;
}
return false;
}
}
/**
* Context for MCP tool executions.
* Contains non-sensitive connection information about the MCP server
@@ -475,6 +499,16 @@ export interface AfterAgentInput extends HookInput {
stop_hook_active: boolean;
}
/**
* AfterAgent hook output
*/
export interface AfterAgentOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'AfterAgent';
clearContext?: boolean;
};
}
/**
* SessionStart source types
*/
+1
View File
@@ -93,6 +93,7 @@ export * from './utils/checkpointUtils.js';
export * from './utils/secure-browser-launcher.js';
export * from './utils/apiConversionUtils.js';
export * from './utils/channel.js';
export * from './utils/constants.js';
// Export services
export * from './services/fileDiscoveryService.js';
@@ -245,6 +245,7 @@ export class McpClientManager {
if (currentPromise === this.discoveryPromise) {
this.discoveryPromise = undefined;
this.discoveryState = MCPDiscoveryState.COMPLETED;
this.eventEmitter?.emit('mcp-client-update', this.clients);
}
})
.catch(() => {}); // Prevents unhandled rejection from the .finally branch
@@ -273,6 +274,12 @@ export class McpClientManager {
this.cliConfig.getMcpServerCommand(),
);
if (Object.keys(servers).length === 0) {
this.discoveryState = MCPDiscoveryState.COMPLETED;
this.eventEmitter?.emit('mcp-client-update', this.clients);
return;
}
this.eventEmitter?.emit('mcp-client-update', this.clients);
await Promise.all(
Object.entries(servers).map(([name, config]) =>
+4 -2
View File
@@ -30,6 +30,8 @@ import { FileOperationEvent } from '../telemetry/types.js';
import { ToolErrorType } from './tool-error.js';
import { READ_MANY_FILES_TOOL_NAME } from './tool-names.js';
import { REFERENCE_CONTENT_END } from '../utils/constants.js';
/**
* Parameters for the ReadManyFilesTool.
*/
@@ -98,7 +100,7 @@ function getDefaultExcludes(config?: Config): string[] {
}
const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---';
const DEFAULT_OUTPUT_TERMINATOR = '\n--- End of content ---';
const DEFAULT_OUTPUT_TERMINATOR = `\n${REFERENCE_CONTENT_END}`;
class ReadManyFilesToolInvocation extends BaseToolInvocation<
ReadManyFilesParams,
@@ -517,7 +519,7 @@ This tool is useful when you need to understand or analyze a collection of files
- Gathering context from multiple configuration files.
- When the user asks to "read all files in X directory" or "show me the content of all Y files".
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '--- End of content ---' after the last file. Ensure glob patterns are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/audio/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/audio/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '${REFERENCE_CONTENT_END}' after the last file. Ensure glob patterns are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/audio/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/audio/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
Kind.Read,
parameterSchema,
messageBus,
+8
View File
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
export const REFERENCE_CONTENT_END = '--- End of content ---';
+5 -1
View File
@@ -5,6 +5,8 @@
*/
import { EventEmitter } from 'node:events';
import type { McpClient } from '../tools/mcp-client.js';
import type { ExtensionEvents } from './extensionLoader.js';
/**
* Defines the severity level for user-facing feedback.
@@ -115,6 +117,7 @@ export enum CoreEvent {
Output = 'output',
MemoryChanged = 'memory-changed',
ExternalEditorClosed = 'external-editor-closed',
McpClientUpdate = 'mcp-client-update',
SettingsChanged = 'settings-changed',
HookStart = 'hook-start',
HookEnd = 'hook-end',
@@ -123,13 +126,14 @@ export enum CoreEvent {
RetryAttempt = 'retry-attempt',
}
export interface CoreEvents {
export interface CoreEvents extends ExtensionEvents {
[CoreEvent.UserFeedback]: [UserFeedbackPayload];
[CoreEvent.ModelChanged]: [ModelChangedPayload];
[CoreEvent.ConsoleLog]: [ConsoleLogPayload];
[CoreEvent.Output]: [OutputPayload];
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
[CoreEvent.ExternalEditorClosed]: never[];
[CoreEvent.McpClientUpdate]: Array<Map<string, McpClient> | never>;
[CoreEvent.SettingsChanged]: never[];
[CoreEvent.HookStart]: [HookStartPayload];
[CoreEvent.HookEnd]: [HookEndPayload];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"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.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.4",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+2 -9
View File
@@ -1421,13 +1421,6 @@
"default": false,
"type": "boolean"
},
"skills": {
"title": "Agent Skills",
"description": "Enable Agent Skills (experimental).",
"markdownDescription": "Enable Agent Skills (experimental).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"codebaseInvestigatorSettings": {
"title": "Codebase Investigator Settings",
"description": "Configuration for Codebase Investigator.",
@@ -1574,8 +1567,8 @@
"enabled": {
"title": "Enable Hooks",
"description": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.",
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"disabled": {
+1
View File
@@ -27,6 +27,7 @@ const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md'];
const KEY_NAME_OVERRIDES: Record<string, string> = {
return: 'Enter',
escape: 'Esc',
'double escape': 'Double Esc',
tab: 'Tab',
backspace: 'Backspace',
delete: 'Delete',