mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea96293e16 | |||
| cd7e60e008 | |||
| 59bde4a612 | |||
| 02f7e48c51 | |||
| aeb6602266 | |||
| 805114aef8 | |||
| 91035ad7b0 | |||
| 12a9bc3ed9 | |||
| 2141b39c3d | |||
| 268627469b | |||
| 6a72cd064b | |||
| aebe3ace3c | |||
| c402784d97 | |||
| bed6ab1cce | |||
| 1b08a6c063 | |||
| 82fa7a0660 | |||
| 2e9236fab4 | |||
| dadf05809c | |||
| 29c3825604 | |||
| faf6a5497a | |||
| dd85aaa951 | |||
| aacae1de43 | |||
| 8d993156e7 | |||
| 57003ca68c | |||
| 47de37eb0a | |||
| dc7b4fda64 | |||
| 3dcca31796 | |||
| c194a6ac3b | |||
| d421fa9e64 | |||
| 2778c7d851 | |||
| b465145229 | |||
| f2d6748432 | |||
| 08f1431946 | |||
| 43d5aaa798 | |||
| 5c8268b6f4 | |||
| d0cda58f1f | |||
| c7a1de4983 | |||
| 49001a0f83 | |||
| 11ecf6fc86 | |||
| 99ba2f6424 | |||
| 93f8fe3671 | |||
| e7b468e122 | |||
| dca040908a | |||
| 2180dd13dc | |||
| 11808ef7ed | |||
| 8da6d23688 | |||
| 37b83e05a7 | |||
| 5caf23d627 | |||
| d1bfba1abb | |||
| b9fe4fc263 | |||
| e506b40c27 | |||
| 83a04c4755 | |||
| 94b7b402c5 | |||
| a8984a9b30 | |||
| acd48a1259 | |||
| 70478b92a9 | |||
| 2cdaf912ba | |||
| 072d8ba289 | |||
| 03ed37d0dc | |||
| bedcbb9feb |
@@ -242,6 +242,8 @@ To hit a breakpoint inside the sandbox container run:
|
||||
DEBUG=1 gemini
|
||||
```
|
||||
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
|
||||
|
||||
### React DevTools
|
||||
|
||||
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x.
|
||||
|
||||
@@ -45,7 +45,7 @@ You have two options to install Gemini CLI.
|
||||
### With Homebrew
|
||||
|
||||
1. **Prerequisites:** Ensure you have [Homebrew](https://brew.sh/) installed.
|
||||
2. **Install the CLI** Execute the following command in your terminal:
|
||||
2. **Install the CLI:** Execute the following command in your terminal:
|
||||
|
||||
```bash
|
||||
brew install gemini-cli
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ find initiatives that interest you.
|
||||
Gemini CLI is an open-source project, and we welcome contributions from the community! Whether you're a developer, a designer, or just an enthusiastic user you can find our [Community Guidelines here](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) to learn how to get started. There are many ways to get involved:
|
||||
|
||||
- **Roadmap:** Please review and find areas in our [roadmap](https://github.com/google-gemini/gemini-cli/issues/4191) that you would like to contribute to. Contributions based on this will be easiest to integrate with.
|
||||
- **Report Bugs:** If you find an issue, please create a bug(https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priorty/p0`.
|
||||
- **Report Bugs:** If you find an issue, please create a [bug](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priority/p0`.
|
||||
- **Suggest Features:** Have a great idea? We'd love to hear it! Open a [feature request](https://github.com/google-gemini/gemini-cli/issues/new?template=feature_request.yml).
|
||||
- **Contribute Code:** Check out our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) file for guidelines on how to submit pull requests. We have a list of "good first issues" for new contributors.
|
||||
- **Write Documentation:** Help us improve our documentation, tutorials, and examples.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
The Gemini CLI requires you to authenticate with Google's AI services. On initial startup you'll need to configure **one** of the following authentication methods:
|
||||
|
||||
1. **Login with Google (Gemini Code Assist):**
|
||||
- Use this option to log in with your google account.
|
||||
- Use this option to log in with your Google account.
|
||||
- During initial startup, Gemini CLI will direct you to a webpage for authentication. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
|
||||
- Note that the web login must be done in a browser that can communicate with the machine Gemini CLI is being run from. (Specifically, the browser will be redirected to a localhost url that Gemini CLI will be listening on).
|
||||
- <a id="workspace-gca">Users may have to specify a GOOGLE_CLOUD_PROJECT if:</a>
|
||||
@@ -91,6 +91,8 @@ The Gemini CLI requires you to authenticate with Google's AI services. On initia
|
||||
|
||||
You can create a **`.gemini/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.gemini/.env` is recommended to keep Gemini variables isolated from other tools.
|
||||
|
||||
**Important:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior. Use `.gemini/.env` files for gemini-cli specific variables.
|
||||
|
||||
Gemini CLI automatically loads environment variables from the **first** `.env` file it finds, using the following search order:
|
||||
|
||||
1. Starting in the **current directory** and moving upward toward `/`, for each directory it checks:
|
||||
|
||||
@@ -27,6 +27,9 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Usage:** `/chat resume <tag>`
|
||||
- **`list`**
|
||||
- **Description:** Lists available tags for chat state resumption.
|
||||
- **`delete`**
|
||||
- **Description:** Deletes a saved conversation checkpoint.
|
||||
- **Usage:** `/chat delete <tag>`
|
||||
|
||||
- **`/clear`**
|
||||
- **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared.
|
||||
@@ -46,7 +49,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Usage:** `/directory add <path1>,<path2>`
|
||||
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
|
||||
- **`show`**:
|
||||
- **Description:** Display all directories added by `/direcotry add` and `--include-directories`.
|
||||
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
|
||||
- **Usage:** `/directory show`
|
||||
|
||||
- **`/editor`**
|
||||
@@ -253,7 +256,7 @@ Please generate a Conventional Commit message based on the following git diff:
|
||||
|
||||
```diff
|
||||
!{git diff --staged}
|
||||
````
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
@@ -274,7 +277,7 @@ First, ensure the user commands directory exists, then create a `refactor` subdi
|
||||
```bash
|
||||
mkdir -p ~/.gemini/commands/refactor
|
||||
touch ~/.gemini/commands/refactor/pure.toml
|
||||
````
|
||||
```
|
||||
|
||||
**2. Add the content to the file:**
|
||||
|
||||
|
||||
@@ -240,6 +240,34 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
}
|
||||
```
|
||||
|
||||
- **`excludedProjectEnvVars`** (array of strings):
|
||||
- **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded.
|
||||
- **Default:** `["DEBUG", "DEBUG_MODE"]`
|
||||
- **Example:**
|
||||
```json
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
|
||||
```
|
||||
|
||||
- **`includeDirectories`** (array of strings):
|
||||
- **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. This allows you to work with files across multiple directories as if they were one. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag.
|
||||
- **Default:** `[]`
|
||||
- **Example:**
|
||||
```json
|
||||
"includeDirectories": [
|
||||
"/path/to/another/project",
|
||||
"../shared-library",
|
||||
"~/common-utils"
|
||||
]
|
||||
```
|
||||
|
||||
- **`loadMemoryFromIncludeDirectories`** (boolean):
|
||||
- **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `GEMINI.md` files should be loaded from all directories that are added. If set to `false`, `GEMINI.md` should only be loaded from the current directory.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
```json
|
||||
"loadMemoryFromIncludeDirectories": true
|
||||
```
|
||||
|
||||
### Example `settings.json`:
|
||||
|
||||
```json
|
||||
@@ -271,7 +299,10 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
"run_shell_command": {
|
||||
"tokenBudget": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"],
|
||||
"includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"],
|
||||
"loadMemoryFromIncludeDirectories": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -293,6 +324,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory.
|
||||
3. If still not found, it looks for `~/.env` (in the user's home directory).
|
||||
|
||||
**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from being loaded from project `.env` files to prevent interference with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file.
|
||||
|
||||
- **`GEMINI_API_KEY`** (Required):
|
||||
- Your API key for the Gemini API.
|
||||
- **Crucial for operation.** The CLI will not function without it.
|
||||
@@ -332,6 +365,7 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
- `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.sb` in your project's `.gemini/` directory (e.g., `my-project/.gemini/sandbox-macos-custom.sb`).
|
||||
- **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself):
|
||||
- Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting.
|
||||
- **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with gemini-cli behavior. Use `.gemini/.env` files if you need to set these for gemini-cli specifically.
|
||||
- **`NO_COLOR`**:
|
||||
- Set to any value to disable all color output in the CLI.
|
||||
- **`CLI_TITLE`**:
|
||||
|
||||
+8
-1
@@ -58,7 +58,11 @@ Add a `customThemes` block to your user, project, or system `settings.json` file
|
||||
"AccentYellow": "#E5C07B",
|
||||
"AccentRed": "#E06C75",
|
||||
"Comment": "#5C6370",
|
||||
"Gray": "#ABB2BF"
|
||||
"Gray": "#ABB2BF",
|
||||
"DiffAdded": "#A6E3A1",
|
||||
"DiffRemoved": "#F38BA8",
|
||||
"DiffModified": "#89B4FA",
|
||||
"GradientColors": ["#4796E4", "#847ACE", "#C3677F"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +81,9 @@ Add a `customThemes` block to your user, project, or system `settings.json` file
|
||||
- `AccentRed`
|
||||
- `Comment`
|
||||
- `Gray`
|
||||
- `DiffAdded` (optional, for added lines in diffs)
|
||||
- `DiffRemoved` (optional, for removed lines in diffs)
|
||||
- `DiffModified` (optional, for modified lines in diffs)
|
||||
|
||||
**Required Properties:**
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ The Gemini CLI core (`packages/core`) features a robust system for defining, reg
|
||||
- `execute()`: The core method that performs the tool's action and returns a `ToolResult`.
|
||||
|
||||
- **`ToolResult` (`tools.ts`):** An interface defining the structure of a tool's execution outcome:
|
||||
- `llmContent`: The factual string content to be included in the history sent back to the LLM for context.
|
||||
- `llmContent`: The factual content to be included in the history sent back to the LLM for context. This can be a simple string or a `PartListUnion` (an array of `Part` objects and strings) for rich content.
|
||||
- `returnDisplay`: A user-friendly string (often Markdown) or a special object (like `FileDiff`) for display in the CLI.
|
||||
|
||||
- **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content.
|
||||
|
||||
- **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for:
|
||||
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`).
|
||||
- **Discovering Tools:** It can also discover tools dynamically:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Automation and Triage Processes
|
||||
|
||||
This document provides a detailed overview of the automated processes we use to manage and triage issues and pull requests. Our goal is to provide prompt feedback and ensure that contributions are reviewed and integrated efficiently. Understanding this automation will help you as a contributor know what to expect and how to best interact with our repository bots.
|
||||
|
||||
## Guiding Principle: Issues and Pull Requests
|
||||
|
||||
First and foremost, almost every Pull Request (PR) should be linked to a corresponding Issue. The issue describes the "what" and the "why" (the bug or feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle.
|
||||
|
||||
---
|
||||
|
||||
## Detailed Automation Workflows
|
||||
|
||||
Here is a breakdown of the specific automation workflows that run in our repository.
|
||||
|
||||
### 1. When you open an Issue: `Automated Issue Triage`
|
||||
|
||||
This is the first bot you will interact with when you create an issue. Its job is to perform an initial analysis and apply the correct labels.
|
||||
|
||||
- **Workflow File**: `.github/workflows/gemini-automated-issue-triage.yml`
|
||||
- **When it runs**: Immediately after an issue is created or reopened.
|
||||
- **What it does**:
|
||||
- It uses a Gemini model to analyze the issue's title and body against a detailed set of guidelines.
|
||||
- **Applies one `area/*` label**: Categorizes the issue into a functional area of the project (e.g., `area/ux`, `area/models`, `area/platform`).
|
||||
- **Applies one `kind/*` label**: Identifies the type of issue (e.g., `kind/bug`, `kind/enhancement`, `kind/question`).
|
||||
- **Applies one `priority/*` label**: Assigns a priority from P0 (critical) to P3 (low) based on the described impact.
|
||||
- **May apply `status/need-information`**: If the issue lacks critical details (like logs or reproduction steps), it will be flagged for more information.
|
||||
- **May apply `status/need-retesting`**: If the issue references a CLI version that is more than six versions old, it will be flagged for retesting on a current version.
|
||||
- **What you should do**:
|
||||
- Fill out the issue template as completely as possible. The more detail you provide, the more accurate the triage will be.
|
||||
- If the `status/need-information` label is added, please provide the requested details in a comment.
|
||||
|
||||
### 2. When you open a Pull Request: `Continuous Integration (CI)`
|
||||
|
||||
This workflow ensures that all changes meet our quality standards before they can be merged.
|
||||
|
||||
- **Workflow File**: `.github/workflows/ci.yml`
|
||||
- **When it runs**: On every push to a pull request.
|
||||
- **What it does**:
|
||||
- **Lint**: Checks that your code adheres to our project's formatting and style rules.
|
||||
- **Test**: Runs our full suite of automated tests across macOS, Windows, and Linux, and on multiple Node.js versions. This is the most time-consuming part of the CI process.
|
||||
- **Post Coverage Comment**: After all tests have successfully passed, a bot will post a comment on your PR. This comment provides a summary of how well your changes are covered by tests.
|
||||
- **What you should do**:
|
||||
- Ensure all CI checks pass. A green checkmark ✅ will appear next to your commit when everything is successful.
|
||||
- If a check fails (a red "X" ❌), click the "Details" link next to the failed check to view the logs, identify the problem, and push a fix.
|
||||
|
||||
### 3. Ongoing Triage for Pull Requests: `PR Auditing and Label Sync`
|
||||
|
||||
This workflow runs periodically to ensure all open PRs are correctly linked to issues and have consistent labels.
|
||||
|
||||
- **Workflow File**: `.github/workflows/gemini-scheduled-pr-triage.yml`
|
||||
- **When it runs**: Every 15 minutes on all open pull requests.
|
||||
- **What it does**:
|
||||
- **Checks for a linked issue**: The bot scans your PR description for a keyword that links it to an issue (e.g., `Fixes #123`, `Closes #456`).
|
||||
- **Adds `status/need-issue`**: If no linked issue is found, the bot will add the `status/need-issue` label to your PR. This is a clear signal that an issue needs to be created and linked.
|
||||
- **Synchronizes labels**: If an issue _is_ linked, the bot ensures the PR's labels perfectly match the issue's labels. It will add any missing labels and remove any that don't belong, and it will remove the `status/need-issue` label if it was present.
|
||||
- **What you should do**:
|
||||
- **Always link your PR to an issue.** This is the most important step. Add a line like `Resolves #<issue-number>` to your PR description.
|
||||
- This will ensure your PR is correctly categorized and moves through the review process smoothly.
|
||||
|
||||
### 4. Ongoing Triage for Issues: `Scheduled Issue Triage`
|
||||
|
||||
This is a fallback workflow to ensure that no issue gets missed by the triage process.
|
||||
|
||||
- **Workflow File**: `.github/workflows/gemini-scheduled-issue-triage.yml`
|
||||
- **When it runs**: Every hour on all open issues.
|
||||
- **What it does**:
|
||||
- It actively seeks out issues that either have no labels at all or still have the `status/need-triage` label.
|
||||
- It then triggers the same powerful Gemini-based analysis as the initial triage bot to apply the correct labels.
|
||||
- **What you should do**:
|
||||
- You typically don't need to do anything. This workflow is a safety net to ensure every issue is eventually categorized, even if the initial triage fails.
|
||||
|
||||
### 5. Release Automation
|
||||
|
||||
This workflow handles the process of packaging and publishing new versions of the Gemini CLI.
|
||||
|
||||
- **Workflow File**: `.github/workflows/release.yml`
|
||||
- **When it runs**: On a daily schedule for "nightly" releases, and manually for official patch/minor releases.
|
||||
- **What it does**:
|
||||
- Automatically builds the project, bumps the version numbers, and publishes the packages to npm.
|
||||
- Creates a corresponding release on GitHub with generated release notes.
|
||||
- **What you should do**:
|
||||
- As a contributor, you don't need to do anything for this process. You can be confident that once your PR is merged into the `main` branch, your changes will be included in the very next nightly release.
|
||||
|
||||
We hope this detailed overview is helpful. If you have any questions about our automation or processes, please don't hesitate to ask!
|
||||
@@ -129,6 +129,8 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping
|
||||
DEBUG=1 gemini -s -p "debug command"
|
||||
```
|
||||
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
|
||||
|
||||
### Inspect sandbox
|
||||
|
||||
```bash
|
||||
|
||||
@@ -169,6 +169,7 @@ Use the `/mcp auth` command to manage OAuth authentication:
|
||||
- **`scopes`** (string[]): Required OAuth scopes
|
||||
- **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`)
|
||||
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
|
||||
- **`audiences`** (string[]): Audiences the token is valid for
|
||||
|
||||
#### Token Management
|
||||
|
||||
@@ -571,6 +572,56 @@ The MCP integration tracks several states:
|
||||
|
||||
This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use.
|
||||
|
||||
## Returning Rich Content from Tools
|
||||
|
||||
MCP tools are not limited to returning simple text. You can return rich, multi-part content, including text, images, audio, and other binary data in a single tool response. This allows you to build powerful tools that can provide diverse information to the model in a single turn.
|
||||
|
||||
All data returned from the tool is processed and sent to the model as context for its next generation, enabling it to reason about or summarize the provided information.
|
||||
|
||||
### How It Works
|
||||
|
||||
To return rich content, your tool's response must adhere to the MCP specification for a [`CallToolResult`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). The `content` field of the result should be an array of `ContentBlock` objects. The Gemini CLI will correctly process this array, separating text from binary data and packaging it for the model.
|
||||
|
||||
You can mix and match different content block types in the `content` array. The supported block types include:
|
||||
|
||||
- `text`
|
||||
- `image`
|
||||
- `audio`
|
||||
- `resource` (embedded content)
|
||||
- `resource_link`
|
||||
|
||||
### Example: Returning Text and an Image
|
||||
|
||||
Here is an example of a valid JSON response from an MCP tool that returns both a text description and an image:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Here is the logo you requested."
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"data": "BASE64_ENCODED_IMAGE_DATA_HERE",
|
||||
"mimeType": "image/png"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The logo was created in 2025."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When the Gemini CLI receives this response, it will:
|
||||
|
||||
1. Extract all the text and combine it into a single `functionResponse` part for the model.
|
||||
2. Present the image data as a separate `inlineData` part.
|
||||
3. Provide a clean, user-friendly summary in the CLI, indicating that both text and an image were received.
|
||||
|
||||
This enables you to build sophisticated tools that can provide rich, multi-modal context to the Gemini model.
|
||||
|
||||
## MCP Prompts as Slash Commands
|
||||
|
||||
In addition to tools, MCP servers can expose predefined prompts that can be executed as slash commands within the Gemini CLI. This allows you to create shortcuts for common or complex queries that can be easily invoked by name.
|
||||
|
||||
@@ -52,7 +52,7 @@ Read the main README, all Markdown files in the `docs` directory, and a specific
|
||||
read_many_files(paths=["README.md", "docs/**/*.md", "assets/logo.png"], exclude=["docs/OLD_README.md"])
|
||||
```
|
||||
|
||||
Read all JavaScript files but explicitly including test files and all JPEGs in an `images` folder:
|
||||
Read all JavaScript files but explicitly include test files and all JPEGs in an `images` folder:
|
||||
|
||||
```
|
||||
read_many_files(paths=["**/*.js"], include=["**/*.test.js", "images/**/*.jpg"], useDefaultExcludes=False)
|
||||
|
||||
+1
-2
@@ -137,6 +137,5 @@ To block all shell commands, add the `run_shell_command` wildcard to `excludeToo
|
||||
|
||||
## Security Note for `excludeTools`
|
||||
|
||||
Command-specific restrictions in
|
||||
`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
|
||||
Command-specific restrictions in `excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
|
||||
that can be executed.
|
||||
|
||||
+39
-21
@@ -1,28 +1,38 @@
|
||||
# Troubleshooting Guide
|
||||
# Troubleshooting guide
|
||||
|
||||
This guide provides solutions to common issues and debugging tips.
|
||||
This guide provides solutions to common issues and debugging tips, including topics on:
|
||||
|
||||
## Authentication
|
||||
- Authentication or login errors
|
||||
- Frequently asked questions (FAQs)
|
||||
- Debugging tips
|
||||
- Existing GitHub Issues similar to yours or creating new Issues
|
||||
|
||||
## Authentication or login errors
|
||||
|
||||
- **Error: `Failed to login. Message: Request contains an invalid argument`**
|
||||
- Users with Google Workspace accounts, or users with Google Cloud accounts
|
||||
- Users with Google Workspace accounts or Google Cloud accounts
|
||||
associated with their Gmail accounts may not be able to activate the free
|
||||
tier of the Google Code Assist plan.
|
||||
- For Google Cloud accounts, you can work around this by setting
|
||||
`GOOGLE_CLOUD_PROJECT` to your project ID.
|
||||
- You can also grab an API key from [AI Studio](https://aistudio.google.com/app/apikey), which also includes a
|
||||
- Alternatively, you can obtain the Gemini API key from
|
||||
[Google AI Studio](http://aistudio.google.com/app/apikey), which also includes a
|
||||
separate free tier.
|
||||
|
||||
## Frequently asked questions (FAQs)
|
||||
|
||||
- **Q: How do I update Gemini CLI to the latest version?**
|
||||
- A: If installed globally via npm, update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`. If run from source, pull the latest changes from the repository and rebuild using `npm run build`.
|
||||
- A: If you installed it globally via `npm`, update it using the command `npm install -g @google/gemini-cli@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
|
||||
|
||||
- **Q: Where are Gemini CLI configuration files stored?**
|
||||
- A: The CLI configuration is stored within two `settings.json` files: one in your home directory and one in your project's root directory. In both locations, `settings.json` is found in the `.gemini/` folder. Refer to [CLI Configuration](./cli/configuration.md) for more details.
|
||||
- **Q: Where are the Gemini CLI configuration or settings files stored?**
|
||||
- A: The Gemini CLI configuration is stored in two `settings.json` files:
|
||||
1. In your home directory: `~/.gemini/settings.json`.
|
||||
2. In your project's root directory: `./.gemini/settings.json`.
|
||||
|
||||
Refer to [Gemini CLI Configuration](./cli/configuration.md) for more details.
|
||||
|
||||
- **Q: Why don't I see cached token counts in my stats output?**
|
||||
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Vertex AI) but not for OAuth users (Google Personal/Enterprise accounts) at this time, as the Code Assist API does not support cached content creation. You can still view your total token usage with the `/stats` command.
|
||||
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Gemini Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command in Gemini CLI.
|
||||
|
||||
## Common error messages and solutions
|
||||
|
||||
@@ -31,28 +41,34 @@ This guide provides solutions to common issues and debugging tips.
|
||||
- **Solution:**
|
||||
Either stop the other process that is using the port or configure the MCP server to use a different port.
|
||||
|
||||
- **Error: Command not found (when attempting to run Gemini CLI).**
|
||||
- **Cause:** Gemini CLI is not correctly installed or not in your system's PATH.
|
||||
- **Error: Command not found (when attempting to run Gemini CLI with `gemini`).**
|
||||
- **Cause:** Gemini CLI is not correctly installed or it is not in your system's `PATH`.
|
||||
- **Solution:**
|
||||
1. Ensure Gemini CLI installation was successful.
|
||||
2. If installed globally, check that your npm global binary directory is in your PATH.
|
||||
3. If running from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`).
|
||||
The update depends on how you installed Gemini CLI:
|
||||
- If you installed `gemini` globally, check that your `npm` global binary directory is in your `PATH`. You can update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`.
|
||||
- If you are running `gemini` from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`). To update Gemini CLI, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
|
||||
|
||||
- **Error: `MODULE_NOT_FOUND` or import errors.**
|
||||
- **Cause:** Dependencies are not installed correctly, or the project hasn't been built.
|
||||
- **Solution:**
|
||||
1. Run `npm install` to ensure all dependencies are present.
|
||||
2. Run `npm run build` to compile the project.
|
||||
3. Verify that the build completed successfully with `npm run start`.
|
||||
|
||||
- **Error: "Operation not permitted", "Permission denied", or similar.**
|
||||
- **Cause:** If sandboxing is enabled, then the application is likely attempting an operation restricted by your sandbox, such as writing outside the project directory or system temp directory.
|
||||
- **Solution:** See [Sandboxing](./cli/configuration.md#sandboxing) for more information, including how to customize your sandbox configuration.
|
||||
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory.
|
||||
- **Solution:** Refer to the [Configuration: Sandboxing](./cli/configuration.md#sandboxing) documentation for more information, including how to customize your sandbox configuration.
|
||||
|
||||
- **CLI is not interactive in "CI" environments**
|
||||
- **Issue:** The CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
|
||||
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode.
|
||||
- **Gemini CLI is not running in interactive mode in "CI" environments**
|
||||
- **Issue:** The Gemini CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
|
||||
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the Gemini CLI from starting in its interactive mode.
|
||||
- **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN gemini`
|
||||
|
||||
- **DEBUG mode not working from project .env file**
|
||||
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for gemini-cli.
|
||||
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior.
|
||||
- **Solution:** Use a `.gemini/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables.
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
- **CLI debugging:**
|
||||
@@ -67,9 +83,11 @@ This guide provides solutions to common issues and debugging tips.
|
||||
- **Tool issues:**
|
||||
- If a specific tool is failing, try to isolate the issue by running the simplest possible version of the command or operation the tool performs.
|
||||
- For `run_shell_command`, check that the command works directly in your shell first.
|
||||
- For file system tools, double-check paths and permissions.
|
||||
- For _file system tools_, verify that paths are correct and check the permissions.
|
||||
|
||||
- **Pre-flight checks:**
|
||||
- Always run `npm run preflight` before committing code. This can catch many common issues related to formatting, linting, and type errors.
|
||||
|
||||
If you encounter an issue not covered here, consider searching the project's issue tracker on GitHub or reporting a new issue with detailed information.
|
||||
## Existing GitHub Issues similar to yours or creating new Issues
|
||||
|
||||
If you encounter an issue that was not covered here in this _Troubleshooting guide_, consider searching the Gemini CLI [Issue tracker on GitHub](https://github.com/google-gemini/gemini-cli/issues). If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome!
|
||||
|
||||
@@ -34,6 +34,7 @@ export default tseslint.config(
|
||||
'packages/server/dist/**',
|
||||
'packages/vscode-ide-companion/dist/**',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
@@ -203,6 +204,21 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['packages/vscode-ide-companion/scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Prettier config must be last
|
||||
prettierConfig,
|
||||
// extra settings for scripts that we run directly with node
|
||||
|
||||
Generated
+57
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.18",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -932,6 +932,10 @@
|
||||
"resolved": "packages/core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@google/gemini-cli-test-utils": {
|
||||
"resolved": "packages/test-utils",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||
@@ -2401,6 +2405,13 @@
|
||||
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/picomatch": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.1.tgz",
|
||||
"integrity": "sha512-dLqxmi5VJRC9XTvc/oaTtk+bDb4RRqxLZPZ3jIpYBHEnDXX8lu02w2yWI6NsPPsELuVK298Z2iR8jgoWKRdUVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@@ -11659,7 +11670,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.18",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.9.0",
|
||||
@@ -11694,6 +11705,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
"@types/diff": "^7.0.2",
|
||||
@@ -11860,7 +11872,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.18",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
@@ -11876,6 +11888,7 @@
|
||||
"chardet": "^2.1.0",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fdir": "^6.4.6",
|
||||
"glob": "^10.4.5",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
@@ -11884,6 +11897,7 @@
|
||||
"marked": "^15.0.12",
|
||||
"micromatch": "^4.0.8",
|
||||
"open": "^10.1.2",
|
||||
"picomatch": "^4.0.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
@@ -11891,10 +11905,12 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/micromatch": "^4.0.8",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
@@ -11940,6 +11956,20 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/fdir": {
|
||||
"version": "6.4.6",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"license": "MIT",
|
||||
@@ -11953,6 +11983,29 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/core/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.1.18",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.0.1",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.18",
|
||||
"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.1.16"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.18"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.18",
|
||||
"description": "Gemini CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.16"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -73,7 +73,8 @@
|
||||
"pretty-format": "^30.0.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
"vitest": "^3.1.1",
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { loadCliConfig, parseArguments } from './config.js';
|
||||
import { Settings } from './settings.js';
|
||||
import { Extension } from './extension.js';
|
||||
@@ -44,7 +46,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
},
|
||||
loadEnvironment: vi.fn(),
|
||||
loadServerHierarchicalMemory: vi.fn(
|
||||
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
|
||||
(cwd, dirs, debug, fileService, extensionPaths, _maxDirs) =>
|
||||
Promise.resolve({
|
||||
memoryContent: extensionPaths?.join(',') || '',
|
||||
fileCount: extensionPaths?.length || 0,
|
||||
@@ -487,6 +489,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
await loadCliConfig(settings, extensions, 'session-id', argv);
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[],
|
||||
false,
|
||||
expect.any(Object),
|
||||
[
|
||||
@@ -917,6 +920,68 @@ describe('loadCliConfig extensions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig model selection', () => {
|
||||
it('selects a model from settings.json if provided', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
model: 'gemini-9001-ultra',
|
||||
},
|
||||
[],
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-9001-ultra');
|
||||
});
|
||||
|
||||
it('uses the default gemini model if nothing is set', async () => {
|
||||
process.argv = ['node', 'script.js']; // No model set.
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
// No model set.
|
||||
},
|
||||
[],
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('always prefers model from argvs', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-8675309-ultra'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
model: 'gemini-9001-ultra',
|
||||
},
|
||||
[],
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-8675309-ultra');
|
||||
});
|
||||
|
||||
it('selects the model from argvs if provided', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-8675309-ultra'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
// No model provided via settings.
|
||||
},
|
||||
[],
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-8675309-ultra');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig ideModeFeature', () => {
|
||||
const originalArgv = process.argv;
|
||||
const originalEnv = { ...process.env };
|
||||
@@ -942,14 +1007,86 @@ describe('loadCliConfig ideModeFeature', () => {
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeModeFeature()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
vi.mock('fs', async () => {
|
||||
const actualFs = await vi.importActual<typeof fs>('fs');
|
||||
const MOCK_CWD1 = process.cwd();
|
||||
const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project');
|
||||
|
||||
const mockPaths = new Set([
|
||||
MOCK_CWD1,
|
||||
MOCK_CWD2,
|
||||
path.resolve(path.sep, 'cli', 'path1'),
|
||||
path.resolve(path.sep, 'settings', 'path1'),
|
||||
path.join(os.homedir(), 'settings', 'path2'),
|
||||
path.join(MOCK_CWD2, 'cli', 'path2'),
|
||||
path.join(MOCK_CWD2, 'settings', 'path3'),
|
||||
]);
|
||||
|
||||
return {
|
||||
...actualFs,
|
||||
existsSync: vi.fn((p) => mockPaths.has(p.toString())),
|
||||
statSync: vi.fn((p) => {
|
||||
if (mockPaths.has(p.toString())) {
|
||||
return { isDirectory: () => true };
|
||||
}
|
||||
// Fallback for other paths if needed, though the test should be specific.
|
||||
return actualFs.statSync(p);
|
||||
}),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
};
|
||||
});
|
||||
|
||||
describe('loadCliConfig with includeDirectories', () => {
|
||||
const originalArgv = process.argv;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(
|
||||
path.resolve(path.sep, 'home', 'user', 'project'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should combine and resolve paths from settings and CLI arguments', async () => {
|
||||
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--include-directories',
|
||||
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
|
||||
];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
process.env.SANDBOX = 'true';
|
||||
const settings: Settings = { ideModeFeature: true };
|
||||
const settings: Settings = {
|
||||
includeDirectories: [
|
||||
path.resolve(path.sep, 'settings', 'path1'),
|
||||
path.join(os.homedir(), 'settings', 'path2'),
|
||||
path.join(mockCwd, 'settings', 'path3'),
|
||||
],
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeModeFeature()).toBe(false);
|
||||
const expected = [
|
||||
mockCwd,
|
||||
path.resolve(path.sep, 'cli', 'path1'),
|
||||
path.join(mockCwd, 'cli', 'path2'),
|
||||
path.resolve(path.sep, 'settings', 'path1'),
|
||||
path.join(os.homedir(), 'settings', 'path2'),
|
||||
path.join(mockCwd, 'settings', 'path3'),
|
||||
];
|
||||
expect(config.getWorkspaceContext().getDirectories()).toEqual(
|
||||
expect.arrayContaining(expected),
|
||||
);
|
||||
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
|
||||
expected.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,13 +22,13 @@ import {
|
||||
FileDiscoveryService,
|
||||
TelemetryTarget,
|
||||
FileFilteringOptions,
|
||||
IdeClient,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Settings } from './settings.js';
|
||||
|
||||
import { Extension, annotateActiveExtensions } from './extension.js';
|
||||
import { getCliVersion } from '../utils/version.js';
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
|
||||
// Simple console logger for now - replace with actual logger if available
|
||||
const logger = {
|
||||
@@ -62,10 +62,10 @@ export interface CliArgs {
|
||||
experimentalAcp: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
ideMode?: boolean | undefined;
|
||||
ideModeFeature: boolean | undefined;
|
||||
proxy: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
loadMemoryFromIncludeDirectories: boolean | undefined;
|
||||
}
|
||||
|
||||
export async function parseArguments(): Promise<CliArgs> {
|
||||
@@ -79,7 +79,7 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
alias: 'm',
|
||||
type: 'string',
|
||||
description: `Model`,
|
||||
default: process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL,
|
||||
default: process.env.GEMINI_MODEL,
|
||||
})
|
||||
.option('prompt', {
|
||||
alias: 'p',
|
||||
@@ -213,6 +213,12 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
// Handle comma-separated values
|
||||
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
|
||||
})
|
||||
.option('load-memory-from-include-directories', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.',
|
||||
default: false,
|
||||
})
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
.alias('v', 'version')
|
||||
.help()
|
||||
@@ -240,6 +246,7 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
|
||||
export async function loadHierarchicalGeminiMemory(
|
||||
currentWorkingDirectory: string,
|
||||
includeDirectoriesToReadGemini: readonly string[] = [],
|
||||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
settings: Settings,
|
||||
@@ -265,6 +272,7 @@ export async function loadHierarchicalGeminiMemory(
|
||||
// Directly call the server function with the corrected path.
|
||||
return loadServerHierarchicalMemory(
|
||||
effectiveCwd,
|
||||
includeDirectoriesToReadGemini,
|
||||
debugMode,
|
||||
fileService,
|
||||
extensionContextFilePaths,
|
||||
@@ -287,15 +295,10 @@ export async function loadCliConfig(
|
||||
) ||
|
||||
false;
|
||||
const memoryImportFormat = settings.memoryImportFormat || 'tree';
|
||||
const ideMode =
|
||||
(argv.ideMode ?? settings.ideMode ?? false) &&
|
||||
process.env.TERM_PROGRAM === 'vscode';
|
||||
|
||||
const ideMode = settings.ideMode ?? false;
|
||||
const ideModeFeature =
|
||||
(argv.ideModeFeature ?? settings.ideModeFeature ?? false) &&
|
||||
!process.env.SANDBOX;
|
||||
|
||||
const ideClient = IdeClient.getInstance(ideMode && ideModeFeature);
|
||||
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
|
||||
|
||||
const allExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
@@ -328,9 +331,14 @@ export async function loadCliConfig(
|
||||
...settings.fileFiltering,
|
||||
};
|
||||
|
||||
const includeDirectories = (settings.includeDirectories || [])
|
||||
.map(resolvePath)
|
||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||
process.cwd(),
|
||||
settings.loadMemoryFromIncludeDirectories ? includeDirectories : [],
|
||||
debugMode,
|
||||
fileService,
|
||||
settings,
|
||||
@@ -396,7 +404,11 @@ export async function loadCliConfig(
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: sandboxConfig,
|
||||
targetDir: process.cwd(),
|
||||
includeDirectories: argv.includeDirectories,
|
||||
includeDirectories,
|
||||
loadMemoryFromIncludeDirectories:
|
||||
argv.loadMemoryFromIncludeDirectories ||
|
||||
settings.loadMemoryFromIncludeDirectories ||
|
||||
false,
|
||||
debugMode,
|
||||
question: argv.promptInteractive || argv.prompt || '',
|
||||
fullContext: argv.allFiles || argv.all_files || false,
|
||||
@@ -444,7 +456,7 @@ export async function loadCliConfig(
|
||||
cwd: process.cwd(),
|
||||
fileDiscoveryService: fileService,
|
||||
bugCommand: settings.bugCommand,
|
||||
model: argv.model!,
|
||||
model: argv.model || settings.model || DEFAULT_GEMINI_MODEL,
|
||||
extensionContextFilePaths,
|
||||
maxSessionTurns: settings.maxSessionTurns ?? -1,
|
||||
experimentalAcp: argv.experimentalAcp || false,
|
||||
@@ -455,7 +467,6 @@ export async function loadCliConfig(
|
||||
summarizeToolOutput: settings.summarizeToolOutput,
|
||||
ideMode,
|
||||
ideModeFeature,
|
||||
ideClient,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged).toEqual({
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
});
|
||||
expect(settings.errors.length).toBe(0);
|
||||
});
|
||||
@@ -145,6 +146,7 @@ describe('Settings Loading and Merging', () => {
|
||||
...systemSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +180,7 @@ describe('Settings Loading and Merging', () => {
|
||||
...userSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +212,7 @@ describe('Settings Loading and Merging', () => {
|
||||
...workspaceSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,6 +250,7 @@ describe('Settings Loading and Merging', () => {
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,6 +300,7 @@ describe('Settings Loading and Merging', () => {
|
||||
allowMCPServers: ['server1', 'server2'],
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,6 +340,86 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.contextFileName).toBe('PROJECT_SPECIFIC.md');
|
||||
});
|
||||
|
||||
it('should handle excludedProjectEnvVars correctly when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'],
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'NODE_ENV',
|
||||
'CUSTOM_VAR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle excludedProjectEnvVars correctly when only in workspace settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
const workspaceSettingsContent = {
|
||||
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'],
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'NODE_ENV',
|
||||
'USER_VAR',
|
||||
]);
|
||||
expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should default contextFileName to undefined if not in any settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = { theme: 'dark' };
|
||||
@@ -536,6 +622,40 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.mcpServers).toEqual({});
|
||||
});
|
||||
|
||||
it('should merge includeDirectories from all scopes', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const systemSettingsContent = {
|
||||
includeDirectories: ['/system/dir'],
|
||||
};
|
||||
const userSettingsContent = {
|
||||
includeDirectories: ['/user/dir1', '/user/dir2'],
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
includeDirectories: ['/workspace/dir'],
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath())
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.merged.includeDirectories).toEqual([
|
||||
'/system/dir',
|
||||
'/user/dir1',
|
||||
'/user/dir2',
|
||||
'/workspace/dir',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle JSON parsing errors gracefully', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist"
|
||||
const invalidJsonContent = 'invalid json';
|
||||
@@ -574,6 +694,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged).toEqual({
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
});
|
||||
|
||||
// Check that error objects are populated in settings.errors
|
||||
@@ -1010,6 +1131,7 @@ describe('Settings Loading and Merging', () => {
|
||||
...systemSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1055,4 +1177,140 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.theme).toBe('ocean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('excludedProjectEnvVars integration', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => {
|
||||
// Create a workspace settings file with excludedProjectEnvVars
|
||||
const workspaceSettingsContent = {
|
||||
excludedProjectEnvVars: ['DEBUG', 'DEBUG_MODE'],
|
||||
};
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Mock findEnvFile to return a project .env file
|
||||
const originalFindEnvFile = (
|
||||
loadSettings as unknown as { findEnvFile: () => string }
|
||||
).findEnvFile;
|
||||
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
|
||||
() => '/mock/project/.env';
|
||||
|
||||
// Mock fs.readFileSync for .env file content
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === '/mock/project/.env') {
|
||||
return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key';
|
||||
}
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
// This will call loadEnvironment internally with the merged settings
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify the settings were loaded correctly
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'DEBUG_MODE',
|
||||
]);
|
||||
|
||||
// Note: We can't directly test process.env changes here because the mocking
|
||||
// prevents the actual file system operations, but we can verify the settings
|
||||
// are correctly merged and passed to loadEnvironment
|
||||
} finally {
|
||||
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
|
||||
originalFindEnvFile;
|
||||
(fs.readFileSync as Mock).mockImplementation(originalReadFileSync);
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect custom excludedProjectEnvVars from user settings', () => {
|
||||
const userSettingsContent = {
|
||||
excludedProjectEnvVars: ['NODE_ENV', 'DEBUG'],
|
||||
};
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
|
||||
'NODE_ENV',
|
||||
'DEBUG',
|
||||
]);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'NODE_ENV',
|
||||
'DEBUG',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge excludedProjectEnvVars with workspace taking precedence', () => {
|
||||
const userSettingsContent = {
|
||||
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'],
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
|
||||
};
|
||||
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'NODE_ENV',
|
||||
'USER_VAR',
|
||||
]);
|
||||
expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import { CustomTheme } from '../ui/themes/theme.js';
|
||||
export const SETTINGS_DIRECTORY_NAME = '.gemini';
|
||||
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
||||
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) {
|
||||
@@ -38,6 +39,10 @@ export function getSystemSettingsPath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceSettingsPath(workspaceDir: string): string {
|
||||
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
|
||||
}
|
||||
|
||||
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
||||
|
||||
export enum SettingScope {
|
||||
@@ -81,6 +86,8 @@ export interface Settings {
|
||||
bugCommand?: BugCommandSettings;
|
||||
checkpointing?: CheckpointingSettings;
|
||||
autoConfigureMaxOldSpaceSize?: boolean;
|
||||
/** The model name to use (e.g 'gemini-9.0-pro') */
|
||||
model?: string;
|
||||
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
@@ -111,8 +118,18 @@ export interface Settings {
|
||||
// Setting for disabling auto-update.
|
||||
disableAutoUpdate?: boolean;
|
||||
|
||||
// Setting for disabling the update nag message.
|
||||
disableUpdateNag?: boolean;
|
||||
|
||||
memoryDiscoveryMaxDirs?: number;
|
||||
|
||||
// Environment variables to exclude from project .env files
|
||||
excludedProjectEnvVars?: string[];
|
||||
dnsResolutionOrder?: DnsResolutionOrder;
|
||||
|
||||
includeDirectories?: string[];
|
||||
|
||||
loadMemoryFromIncludeDirectories?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
@@ -168,6 +185,11 @@ export class LoadedSettings {
|
||||
...(workspace.mcpServers || {}),
|
||||
...(system.mcpServers || {}),
|
||||
},
|
||||
includeDirectories: [
|
||||
...(system.includeDirectories || []),
|
||||
...(user.includeDirectories || []),
|
||||
...(workspace.includeDirectories || []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -289,15 +311,61 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnvironment(): void {
|
||||
export function loadEnvironment(settings?: Settings): void {
|
||||
const envFilePath = findEnvFile(process.cwd());
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env.CLOUD_SHELL === 'true') {
|
||||
setUpCloudShellEnvironment(envFilePath);
|
||||
}
|
||||
|
||||
// If no settings provided, try to load workspace settings for exclusions
|
||||
let resolvedSettings = settings;
|
||||
if (!resolvedSettings) {
|
||||
const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd());
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const workspaceContent = fs.readFileSync(
|
||||
workspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(workspaceContent),
|
||||
) as Settings;
|
||||
resolvedSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore errors loading workspace settings
|
||||
}
|
||||
}
|
||||
|
||||
if (envFilePath) {
|
||||
dotenv.config({ path: envFilePath, quiet: true });
|
||||
// Manually parse and load environment variables to handle exclusions correctly.
|
||||
// This avoids modifying environment variables that were already set from the shell.
|
||||
try {
|
||||
const envFileContent = fs.readFileSync(envFilePath, 'utf-8');
|
||||
const parsedEnv = dotenv.parse(envFileContent);
|
||||
|
||||
const excludedVars =
|
||||
resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS;
|
||||
const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR);
|
||||
|
||||
for (const key in parsedEnv) {
|
||||
if (Object.hasOwn(parsedEnv, key)) {
|
||||
// If it's a project .env file, skip loading excluded variables.
|
||||
if (isProjectEnvFile && excludedVars.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load variable only if it's not already set in the environment.
|
||||
if (!Object.hasOwn(process.env, key)) {
|
||||
process.env[key] = parsedEnv[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,14 +374,13 @@ export function loadEnvironment(): void {
|
||||
* Project settings override user settings.
|
||||
*/
|
||||
export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
loadEnvironment();
|
||||
let systemSettings: Settings = {};
|
||||
let userSettings: Settings = {};
|
||||
let workspaceSettings: Settings = {};
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
const systemSettingsPath = getSystemSettingsPath();
|
||||
|
||||
// FIX: Resolve paths to their canonical representation to handle symlinks
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
@@ -328,6 +395,8 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
// We expect homedir to always exist and be resolvable.
|
||||
const realHomeDir = fs.realpathSync(resolvedHomeDir);
|
||||
|
||||
const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir);
|
||||
|
||||
// Load system settings
|
||||
try {
|
||||
if (fs.existsSync(systemSettingsPath)) {
|
||||
@@ -366,13 +435,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceSettingsPath = path.join(
|
||||
workspaceDir,
|
||||
SETTINGS_DIRECTORY_NAME,
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
// This comparison is now much more reliable.
|
||||
if (realWorkspaceDir !== realHomeDir) {
|
||||
// Load workspace settings
|
||||
try {
|
||||
@@ -399,7 +461,8 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
return new LoadedSettings(
|
||||
// Create LoadedSettings first
|
||||
const loadedSettings = new LoadedSettings(
|
||||
{
|
||||
path: systemSettingsPath,
|
||||
settings: systemSettings,
|
||||
@@ -414,6 +477,11 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
},
|
||||
settingsErrors,
|
||||
);
|
||||
|
||||
// Load environment with merged settings
|
||||
loadEnvironment(loadedSettings.merged);
|
||||
|
||||
return loadedSettings;
|
||||
}
|
||||
|
||||
export function saveSettings(settingsFile: SettingsFile): void {
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('runNonInteractive', () => {
|
||||
getIdeMode: vi.fn().mockReturnValue(false),
|
||||
getFullContext: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
|
||||
@@ -17,28 +17,37 @@ import {
|
||||
import { Content, Part, FunctionCall } from '@google/genai';
|
||||
|
||||
import { parseAndFormatApiError } from './ui/utils/errorParsing.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
|
||||
export async function runNonInteractive(
|
||||
config: Config,
|
||||
input: string,
|
||||
prompt_id: string,
|
||||
): Promise<void> {
|
||||
await config.initialize();
|
||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') {
|
||||
// Exit gracefully if the pipe is closed.
|
||||
process.exit(0);
|
||||
}
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const toolRegistry: ToolRegistry = await config.getToolRegistry();
|
||||
|
||||
const abortController = new AbortController();
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: [{ text: input }] }];
|
||||
let turnCount = 0;
|
||||
try {
|
||||
await config.initialize();
|
||||
consolePatcher.patch();
|
||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') {
|
||||
// Exit gracefully if the pipe is closed.
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const toolRegistry: ToolRegistry = await config.getToolRegistry();
|
||||
|
||||
const abortController = new AbortController();
|
||||
let currentMessages: Content[] = [
|
||||
{ role: 'user', parts: [{ text: input }] },
|
||||
];
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
@@ -133,6 +142,7 @@ export async function runNonInteractive(
|
||||
);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
if (isTelemetrySdkInitialized()) {
|
||||
await shutdownTelemetry();
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import { AuthInProgress } from './components/AuthInProgress.js';
|
||||
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
||||
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
|
||||
import { Colors } from './colors.js';
|
||||
import { Help } from './components/Help.js';
|
||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { Tips } from './components/Tips.js';
|
||||
@@ -146,7 +145,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
|
||||
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||
const [themeError, setThemeError] = useState<string | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
@@ -278,6 +276,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
try {
|
||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||
process.cwd(),
|
||||
settings.merged.loadMemoryFromIncludeDirectories
|
||||
? config.getWorkspaceContext().getDirectories()
|
||||
: [],
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
settings.merged,
|
||||
@@ -473,7 +474,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
clearItems,
|
||||
loadHistory,
|
||||
refreshStatic,
|
||||
setShowHelp,
|
||||
setDebugMessage,
|
||||
openThemeDialog,
|
||||
openAuthDialog,
|
||||
@@ -483,6 +483,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
openPrivacyNotice,
|
||||
toggleVimEnabled,
|
||||
setIsProcessing,
|
||||
setGeminiMdFileCount,
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -495,7 +496,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
config.getGeminiClient(),
|
||||
history,
|
||||
addItem,
|
||||
setShowHelp,
|
||||
config,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
@@ -505,6 +505,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
refreshStatic,
|
||||
);
|
||||
|
||||
// Input handling
|
||||
@@ -603,7 +604,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
if (config) {
|
||||
setGeminiMdFileCount(config.getGeminiMdFileCount());
|
||||
}
|
||||
}, [config]);
|
||||
}, [config, config.getGeminiMdFileCount]);
|
||||
|
||||
const logger = useLogger();
|
||||
const [userMessages, setUserMessages] = useState<string[]>([]);
|
||||
@@ -802,6 +803,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
config={config}
|
||||
commands={slashCommands}
|
||||
/>
|
||||
)),
|
||||
]}
|
||||
@@ -829,8 +831,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
|
||||
{showHelp && <Help commands={slashCommands} />}
|
||||
|
||||
<Box flexDirection="column" ref={mainControlsRef}>
|
||||
{/* Move UpdateNotification to render update notification above input area */}
|
||||
{updateInfo && <UpdateNotification message={updateInfo.message} />}
|
||||
|
||||
@@ -40,11 +40,24 @@ describe('directoryCommand', () => {
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
addDirectoryContext: vi.fn(),
|
||||
}),
|
||||
getWorkingDir: () => '/test/dir',
|
||||
shouldLoadMemoryFromIncludeDirectories: () => false,
|
||||
getDebugMode: () => false,
|
||||
getFileService: () => ({}),
|
||||
getExtensionContextFilePaths: () => [],
|
||||
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
|
||||
setUserMemory: vi.fn(),
|
||||
setGeminiMdFileCount: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
mockContext = {
|
||||
services: {
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SlashCommand, CommandContext, CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { loadServerHierarchicalMemory } from '@google/gemini-cli-core';
|
||||
|
||||
export function expandHomeDir(p: string): string {
|
||||
if (!p) {
|
||||
@@ -16,7 +17,7 @@ export function expandHomeDir(p: string): string {
|
||||
let expandedPath = p;
|
||||
if (p.toLowerCase().startsWith('%userprofile%')) {
|
||||
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
|
||||
} else if (p.startsWith('~')) {
|
||||
} else if (p === '~' || p.startsWith('~/')) {
|
||||
expandedPath = os.homedir() + p.substring(1);
|
||||
}
|
||||
return path.normalize(expandedPath);
|
||||
@@ -90,6 +91,37 @@ export const directoryCommand: SlashCommand = {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
const { memoryContent, fileCount } =
|
||||
await loadServerHierarchicalMemory(
|
||||
config.getWorkingDir(),
|
||||
[
|
||||
...config.getWorkspaceContext().getDirectories(),
|
||||
...pathsToAdd,
|
||||
],
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionContextFilePaths(),
|
||||
context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
context.services.settings.merged.memoryDiscoveryMaxDirs,
|
||||
);
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
context.ui.setGeminiMdFileCount(fileCount);
|
||||
}
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(`Error refreshing memory: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
const gemini = config.getGeminiClient();
|
||||
if (gemini) {
|
||||
|
||||
@@ -4,37 +4,49 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { helpCommand } from './helpCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
|
||||
describe('helpCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {} as unknown as CommandContext;
|
||||
mockContext = createMockCommandContext({
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
});
|
||||
|
||||
it("should return a dialog action and log a debug message for '/help'", () => {
|
||||
const consoleDebugSpy = vi
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation(() => {});
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should add a help message to the UI history', async () => {
|
||||
if (!helpCommand.action) {
|
||||
throw new Error('Help command has no action');
|
||||
}
|
||||
const result = helpCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'help',
|
||||
});
|
||||
expect(consoleDebugSpy).toHaveBeenCalledWith('Opening help UI ...');
|
||||
await helpCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HELP,
|
||||
timestamp: expect.any(Date),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it("should also be triggered by its alternative name '?'", () => {
|
||||
// This test is more conceptual. The routing of altNames to the command
|
||||
// is handled by the slash command processor, but we can assert the
|
||||
// altNames is correctly defined on the command object itself.
|
||||
expect(helpCommand.altNames).toContain('?');
|
||||
it('should have the correct command properties', () => {
|
||||
expect(helpCommand.name).toBe('help');
|
||||
expect(helpCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
expect(helpCommand.description).toBe('for help on gemini-cli');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind, SlashCommand } from './types.js';
|
||||
import { MessageType, type HistoryItemHelp } from '../types.js';
|
||||
|
||||
export const helpCommand: SlashCommand = {
|
||||
name: 'help',
|
||||
altNames: ['?'],
|
||||
description: 'for help on gemini-cli',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => {
|
||||
console.debug('Opening help UI ...');
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'help',
|
||||
description: 'for help on gemini-cli',
|
||||
action: async (context) => {
|
||||
const helpItem: Omit<HistoryItemHelp, 'id'> = {
|
||||
type: MessageType.HELP,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
context.ui.addItem(helpItem, Date.now());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,9 +42,15 @@ describe('ideCommand', () => {
|
||||
mockConfig = {
|
||||
getIdeModeFeature: vi.fn(),
|
||||
getIdeMode: vi.fn(),
|
||||
getIdeClient: vi.fn(),
|
||||
getIdeClient: vi.fn(() => ({
|
||||
reconnect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getCurrentIde: vi.fn(),
|
||||
getDetectedIdeDisplayName: vi.fn(),
|
||||
getConnectionStatus: vi.fn(),
|
||||
})),
|
||||
setIdeModeAndSyncConnection: vi.fn(),
|
||||
setIdeMode: vi.fn(),
|
||||
setIdeClientDisconnected: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get');
|
||||
@@ -65,6 +71,7 @@ describe('ideCommand', () => {
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
|
||||
getCurrentIde: () => DetectedIde.VSCode,
|
||||
getDetectedIdeDisplayName: () => 'VS Code',
|
||||
} as ReturnType<Config['getIdeClient']>);
|
||||
const command = ideCommand(mockConfig);
|
||||
expect(command).not.toBeNull();
|
||||
@@ -82,6 +89,7 @@ describe('ideCommand', () => {
|
||||
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
|
||||
getConnectionStatus: mockGetConnectionStatus,
|
||||
getCurrentIde: () => DetectedIde.VSCode,
|
||||
getDetectedIdeDisplayName: () => 'VS Code',
|
||||
} as unknown as ReturnType<Config['getIdeClient']>);
|
||||
});
|
||||
|
||||
@@ -96,7 +104,7 @@ describe('ideCommand', () => {
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: '🟢 Connected',
|
||||
content: '🟢 Connected to VS Code',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +163,7 @@ describe('ideCommand', () => {
|
||||
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
|
||||
getCurrentIde: () => DetectedIde.VSCode,
|
||||
getConnectionStatus: vi.fn(),
|
||||
getDetectedIdeDisplayName: () => 'VS Code',
|
||||
} as unknown as ReturnType<Config['getIdeClient']>);
|
||||
vi.mocked(core.getIdeInstaller).mockReturnValue({
|
||||
install: mockInstall,
|
||||
@@ -180,7 +189,7 @@ describe('ideCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: `Installing IDE companion extension...`,
|
||||
text: `Installing IDE companion...`,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -210,7 +219,7 @@ describe('ideCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: `Installing IDE companion extension...`,
|
||||
text: `Installing IDE companion...`,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
|
||||
import {
|
||||
Config,
|
||||
DetectedIde,
|
||||
IDEConnectionStatus,
|
||||
getIdeDisplayName,
|
||||
getIdeInstaller,
|
||||
IdeClient,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
CommandContext,
|
||||
@@ -18,13 +20,57 @@ import {
|
||||
} from './types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
function getIdeStatusMessage(ideClient: IdeClient): {
|
||||
messageType: 'info' | 'error';
|
||||
content: string;
|
||||
} {
|
||||
const connection = ideClient.getConnectionStatus();
|
||||
switch (connection.status) {
|
||||
case IDEConnectionStatus.Connected:
|
||||
return {
|
||||
messageType: 'info',
|
||||
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
|
||||
};
|
||||
case IDEConnectionStatus.Connecting:
|
||||
return {
|
||||
messageType: 'info',
|
||||
content: `🟡 Connecting...`,
|
||||
};
|
||||
default: {
|
||||
let content = `🔴 Disconnected`;
|
||||
if (connection?.details) {
|
||||
content += `: ${connection.details}`;
|
||||
}
|
||||
return {
|
||||
messageType: 'error',
|
||||
content,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
if (!config?.getIdeModeFeature()) {
|
||||
if (!config || !config.getIdeModeFeature()) {
|
||||
return null;
|
||||
}
|
||||
const currentIDE = config.getIdeClient().getCurrentIde();
|
||||
if (!currentIDE) {
|
||||
return null;
|
||||
const ideClient = config.getIdeClient();
|
||||
const currentIDE = ideClient.getCurrentIde();
|
||||
if (!currentIDE || !ideClient.getDetectedIdeDisplayName()) {
|
||||
return {
|
||||
name: 'ide',
|
||||
description: 'manage IDE integration',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): SlashCommandActionReturn =>
|
||||
({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values(
|
||||
DetectedIde,
|
||||
)
|
||||
.map((ide) => getIdeDisplayName(ide))
|
||||
.join(', ')}`,
|
||||
}) as const,
|
||||
};
|
||||
}
|
||||
|
||||
const ideSlashCommand: SlashCommand = {
|
||||
@@ -38,39 +84,19 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
name: 'status',
|
||||
description: 'check status of IDE integration',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context: CommandContext): SlashCommandActionReturn => {
|
||||
const connection = config.getIdeClient().getConnectionStatus();
|
||||
switch (connection?.status) {
|
||||
case IDEConnectionStatus.Connected:
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `🟢 Connected`,
|
||||
} as const;
|
||||
case IDEConnectionStatus.Connecting:
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `🟡 Connecting...`,
|
||||
} as const;
|
||||
default: {
|
||||
let content = `🔴 Disconnected`;
|
||||
if (connection?.details) {
|
||||
content += `: ${connection.details}`;
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
action: (): SlashCommandActionReturn => {
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType,
|
||||
content,
|
||||
} as const;
|
||||
},
|
||||
};
|
||||
|
||||
const installCommand: SlashCommand = {
|
||||
name: 'install',
|
||||
description: `install required IDE companion ${getIdeDisplayName(currentIDE)} extension `,
|
||||
description: `install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`,
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const installer = getIdeInstaller(currentIDE);
|
||||
@@ -78,7 +104,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: 'No installer available for your configured IDE.',
|
||||
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the IDE companion manually from its marketplace.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -88,12 +114,16 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Installing IDE companion extension...`,
|
||||
text: `Installing IDE companion...`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
const result = await installer.install();
|
||||
if (result.success) {
|
||||
config.setIdeMode(true);
|
||||
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: result.success ? 'info' : 'error',
|
||||
@@ -110,8 +140,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
|
||||
config.setIdeMode(true);
|
||||
config.setIdeClientConnected();
|
||||
await config.setIdeModeAndSyncConnection(true);
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: messageType,
|
||||
text: content,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -121,8 +158,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
|
||||
config.setIdeMode(false);
|
||||
config.setIdeClientDisconnected();
|
||||
await config.setIdeModeAndSyncConnection(false);
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: messageType,
|
||||
text: content,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,15 +14,10 @@ import {
|
||||
getMCPDiscoveryState,
|
||||
DiscoveredMCPTool,
|
||||
} from '@google/gemini-cli-core';
|
||||
import open from 'open';
|
||||
|
||||
import { MessageActionReturn } from './types.js';
|
||||
import { Type, CallableTool } from '@google/genai';
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('open', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -144,30 +139,15 @@ describe('mcpCommand', () => {
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue({});
|
||||
});
|
||||
|
||||
it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => {
|
||||
process.env.SANDBOX = 'sandbox';
|
||||
|
||||
it('should display a message with a URL when no MCP servers are configured', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'No MCP servers configured. Please open the following URL in your browser to view documentation:\nhttps://goo.gle/gemini-cli-docs-mcp',
|
||||
'No MCP servers configured. Please view MCP documentation in your browser: https://goo.gle/gemini-cli-docs-mcp or use the cli /docs command',
|
||||
});
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display a message and open a URL when no MCP servers are configured outside a sandbox', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'No MCP servers configured. Opening documentation in your browser: https://goo.gle/gemini-cli-docs-mcp',
|
||||
});
|
||||
expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,9 +212,9 @@ describe('mcpCommand', () => {
|
||||
);
|
||||
expect(message).toContain('server2_tool1');
|
||||
|
||||
// Server 3 - Disconnected
|
||||
// Server 3 - Disconnected but with cached tools, so shows as Ready
|
||||
expect(message).toContain(
|
||||
'🔴 \u001b[1mserver3\u001b[0m - Disconnected (1 tools cached)',
|
||||
'🟢 \u001b[1mserver3\u001b[0m - Ready (1 tool)',
|
||||
);
|
||||
expect(message).toContain('server3_tool1');
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
mcpServerRequiresOAuth,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import open from 'open';
|
||||
|
||||
const COLOR_GREEN = '\u001b[32m';
|
||||
const COLOR_YELLOW = '\u001b[33m';
|
||||
@@ -60,21 +59,11 @@ const getMcpStatus = async (
|
||||
|
||||
if (serverNames.length === 0 && blockedMcpServers.length === 0) {
|
||||
const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp';
|
||||
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`,
|
||||
};
|
||||
} else {
|
||||
// Open the URL in the browser
|
||||
await open(docsUrl);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `No MCP servers configured. Please view MCP documentation in your browser: ${docsUrl} or use the cli /docs command`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if any servers are still connecting
|
||||
@@ -105,7 +94,15 @@ const getMcpStatus = async (
|
||||
const promptRegistry = await config.getPromptRegistry();
|
||||
const serverPrompts = promptRegistry.getPromptsByServer(serverName) || [];
|
||||
|
||||
const status = getMCPServerStatus(serverName);
|
||||
const originalStatus = getMCPServerStatus(serverName);
|
||||
const hasCachedItems = serverTools.length > 0 || serverPrompts.length > 0;
|
||||
|
||||
// If the server is "disconnected" but has prompts or cached tools, display it as Ready
|
||||
// by using CONNECTED as the display status.
|
||||
const status =
|
||||
originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems
|
||||
? MCPServerStatus.CONNECTED
|
||||
: originalStatus;
|
||||
|
||||
// Add status indicator with descriptive text
|
||||
let statusIndicator = '';
|
||||
@@ -271,11 +268,14 @@ const getMcpStatus = async (
|
||||
message += ' No tools or prompts available\n';
|
||||
} else if (serverTools.length === 0) {
|
||||
message += ' No tools available';
|
||||
if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
|
||||
if (originalStatus === MCPServerStatus.DISCONNECTED && needsAuthHint) {
|
||||
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`;
|
||||
}
|
||||
message += '\n';
|
||||
} else if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
|
||||
} else if (
|
||||
originalStatus === MCPServerStatus.DISCONNECTED &&
|
||||
needsAuthHint
|
||||
) {
|
||||
// This case is for when serverTools.length > 0
|
||||
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}\n`;
|
||||
}
|
||||
|
||||
@@ -161,6 +161,10 @@ describe('memoryCommand', () => {
|
||||
getDebugMode: () => false,
|
||||
getFileService: () => ({}) as FileDiscoveryService,
|
||||
getExtensionContextFilePaths: () => [],
|
||||
shouldLoadMemoryFromIncludeDirectories: () => false,
|
||||
getWorkspaceContext: () => ({
|
||||
getDirectories: () => [],
|
||||
}),
|
||||
getFileFilteringOptions: () => ({
|
||||
ignore: [],
|
||||
include: [],
|
||||
|
||||
@@ -89,6 +89,9 @@ export const memoryCommand: SlashCommand = {
|
||||
const { memoryContent, fileCount } =
|
||||
await loadServerHierarchicalMemory(
|
||||
config.getWorkingDir(),
|
||||
config.shouldLoadMemoryFromIncludeDirectories()
|
||||
? config.getWorkspaceContext().getDirectories()
|
||||
: [],
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionContextFilePaths(),
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('setupGithubCommand', () => {
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`,
|
||||
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/main/workflows/',
|
||||
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/v0/examples/workflows/',
|
||||
];
|
||||
|
||||
for (const substring of expectedSubstrings) {
|
||||
|
||||
@@ -27,9 +27,8 @@ export const setupGithubCommand: SlashCommand = {
|
||||
throw new Error('Unable to determine the Git root directory.');
|
||||
}
|
||||
|
||||
// TODO(#5198): pin workflow versions for release controls
|
||||
const version = 'main';
|
||||
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/workflows/`;
|
||||
const version = 'v0';
|
||||
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`;
|
||||
|
||||
const workflows = [
|
||||
'gemini-cli/gemini-cli.yml',
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface CommandContext {
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
setGeminiMdFileCount: (count: number) => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
@@ -98,7 +99,7 @@ export interface MessageActionReturn {
|
||||
*/
|
||||
export interface OpenDialogActionReturn {
|
||||
type: 'dialog';
|
||||
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy';
|
||||
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,8 @@ import { ModelStatsDisplay } from './ModelStatsDisplay.js';
|
||||
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
|
||||
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
import { Help } from './Help.js';
|
||||
import { SlashCommand } from '../commands/types.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
@@ -29,6 +31,7 @@ interface HistoryItemDisplayProps {
|
||||
isPending: boolean;
|
||||
config?: Config;
|
||||
isFocused?: boolean;
|
||||
commands?: readonly SlashCommand[];
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -37,6 +40,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
terminalWidth,
|
||||
isPending,
|
||||
config,
|
||||
commands,
|
||||
isFocused = true,
|
||||
}) => (
|
||||
<Box flexDirection="column" key={item.id}>
|
||||
@@ -71,6 +75,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
gcpProject={item.gcpProject}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'help' && commands && <Help commands={commands} />}
|
||||
{item.type === 'stats' && <StatsDisplay duration={item.duration} />}
|
||||
{item.type === 'model_stats' && <ModelStatsDisplay />}
|
||||
{item.type === 'tool_stats' && <ToolStatsDisplay />}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
@@ -19,7 +20,10 @@ import {
|
||||
useShellHistory,
|
||||
UseShellHistoryReturn,
|
||||
} from '../hooks/useShellHistory.js';
|
||||
import { useCompletion, UseCompletionReturn } from '../hooks/useCompletion.js';
|
||||
import {
|
||||
useCommandCompletion,
|
||||
UseCommandCompletionReturn,
|
||||
} from '../hooks/useCommandCompletion.js';
|
||||
import {
|
||||
useInputHistory,
|
||||
UseInputHistoryReturn,
|
||||
@@ -28,7 +32,7 @@ import * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
vi.mock('../hooks/useShellHistory.js');
|
||||
vi.mock('../hooks/useCompletion.js');
|
||||
vi.mock('../hooks/useCommandCompletion.js');
|
||||
vi.mock('../hooks/useInputHistory.js');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
|
||||
@@ -83,13 +87,13 @@ const mockSlashCommands: SlashCommand[] = [
|
||||
describe('InputPrompt', () => {
|
||||
let props: InputPromptProps;
|
||||
let mockShellHistory: UseShellHistoryReturn;
|
||||
let mockCompletion: UseCompletionReturn;
|
||||
let mockCommandCompletion: UseCommandCompletionReturn;
|
||||
let mockInputHistory: UseInputHistoryReturn;
|
||||
let mockBuffer: TextBuffer;
|
||||
let mockCommandContext: CommandContext;
|
||||
|
||||
const mockedUseShellHistory = vi.mocked(useShellHistory);
|
||||
const mockedUseCompletion = vi.mocked(useCompletion);
|
||||
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
|
||||
const mockedUseInputHistory = vi.mocked(useInputHistory);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -115,7 +119,9 @@ describe('InputPrompt', () => {
|
||||
visualScrollRow: 0,
|
||||
handleInput: vi.fn(),
|
||||
move: vi.fn(),
|
||||
moveToOffset: vi.fn(),
|
||||
moveToOffset: (offset: number) => {
|
||||
mockBuffer.cursor = [0, offset];
|
||||
},
|
||||
killLineRight: vi.fn(),
|
||||
killLineLeft: vi.fn(),
|
||||
openInExternalEditor: vi.fn(),
|
||||
@@ -133,6 +139,7 @@ describe('InputPrompt', () => {
|
||||
} as unknown as TextBuffer;
|
||||
|
||||
mockShellHistory = {
|
||||
history: [],
|
||||
addCommandToHistory: vi.fn(),
|
||||
getPreviousCommand: vi.fn().mockReturnValue(null),
|
||||
getNextCommand: vi.fn().mockReturnValue(null),
|
||||
@@ -140,7 +147,7 @@ describe('InputPrompt', () => {
|
||||
};
|
||||
mockedUseShellHistory.mockReturnValue(mockShellHistory);
|
||||
|
||||
mockCompletion = {
|
||||
mockCommandCompletion = {
|
||||
suggestions: [],
|
||||
activeSuggestionIndex: -1,
|
||||
isLoadingSuggestions: false,
|
||||
@@ -154,7 +161,7 @@ describe('InputPrompt', () => {
|
||||
setShowSuggestions: vi.fn(),
|
||||
handleAutocomplete: vi.fn(),
|
||||
};
|
||||
mockedUseCompletion.mockReturnValue(mockCompletion);
|
||||
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
|
||||
|
||||
mockInputHistory = {
|
||||
navigateUp: vi.fn(),
|
||||
@@ -265,8 +272,8 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'memory', value: 'memory' },
|
||||
@@ -285,15 +292,15 @@ describe('InputPrompt', () => {
|
||||
|
||||
stdin.write('\u0010'); // Ctrl+P
|
||||
await wait();
|
||||
expect(mockCompletion.navigateUp).toHaveBeenCalledTimes(2);
|
||||
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2);
|
||||
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'memory', value: 'memory' },
|
||||
@@ -311,15 +318,15 @@ describe('InputPrompt', () => {
|
||||
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
await wait();
|
||||
expect(mockCompletion.navigateDown).toHaveBeenCalledTimes(2);
|
||||
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2);
|
||||
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT call completion navigation when suggestions are not showing', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
});
|
||||
props.buffer.setText('some text');
|
||||
@@ -336,8 +343,8 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
await wait();
|
||||
|
||||
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -466,8 +473,8 @@ describe('InputPrompt', () => {
|
||||
|
||||
it('should complete a partial parent command', async () => {
|
||||
// SCENARIO: /mem -> Tab
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
|
||||
activeSuggestionIndex: 0,
|
||||
@@ -480,14 +487,14 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should append a sub-command when the parent command is already complete', async () => {
|
||||
// SCENARIO: /memory -> Tab (to accept 'add')
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'show', value: 'show' },
|
||||
@@ -503,14 +510,14 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle the "backspace" edge case correctly', async () => {
|
||||
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'show', value: 'show' },
|
||||
@@ -528,14 +535,14 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
// It should NOT become '/show'. It should correctly become '/memory show'.
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should complete a partial argument for a command', async () => {
|
||||
// SCENARIO: /chat resume fi- -> Tab
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
|
||||
activeSuggestionIndex: 0,
|
||||
@@ -548,13 +555,13 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'memory', value: 'memory' }],
|
||||
activeSuggestionIndex: 0,
|
||||
@@ -568,7 +575,7 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
// The app should autocomplete the text, NOT submit.
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
@@ -584,8 +591,8 @@ describe('InputPrompt', () => {
|
||||
},
|
||||
];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'help', value: 'help' }],
|
||||
activeSuggestionIndex: 0,
|
||||
@@ -598,7 +605,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab for autocomplete
|
||||
await wait();
|
||||
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -616,8 +623,8 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
it('should submit directly on Enter when isPerfectMatch is true', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
isPerfectMatch: true,
|
||||
});
|
||||
@@ -634,8 +641,8 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
isPerfectMatch: false, // Added explicit isPerfectMatch false
|
||||
});
|
||||
@@ -652,8 +659,8 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
it('should autocomplete an @-path on Enter without submitting', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'index.ts', value: 'index.ts' }],
|
||||
activeSuggestionIndex: 0,
|
||||
@@ -666,7 +673,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -698,7 +705,7 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -722,8 +729,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['@src/components'];
|
||||
mockBuffer.cursor = [0, 15];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }],
|
||||
});
|
||||
@@ -732,12 +739,13 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
// Verify useCompletion was called with correct signature
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -749,8 +757,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['/memory'];
|
||||
mockBuffer.cursor = [0, 7];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'show', value: 'show' }],
|
||||
});
|
||||
@@ -758,12 +766,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -775,8 +784,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['@src/file.ts hello'];
|
||||
mockBuffer.cursor = [0, 18];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
@@ -784,12 +793,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -801,8 +811,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['/memory add'];
|
||||
mockBuffer.cursor = [0, 11];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
@@ -810,12 +820,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -827,8 +838,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['hello world'];
|
||||
mockBuffer.cursor = [0, 5];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
@@ -836,12 +847,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -853,8 +865,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['first line', '/memory'];
|
||||
mockBuffer.cursor = [1, 7];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
@@ -863,12 +875,13 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
// Verify useCompletion was called with the buffer
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -880,8 +893,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['/memory'];
|
||||
mockBuffer.cursor = [0, 7];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'show', value: 'show' }],
|
||||
});
|
||||
@@ -889,12 +902,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -907,8 +921,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['@src/file👍.txt'];
|
||||
mockBuffer.cursor = [0, 14]; // After the emoji character
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }],
|
||||
});
|
||||
@@ -916,12 +930,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -934,8 +949,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['@src/file👍.txt hello'];
|
||||
mockBuffer.cursor = [0, 20]; // After the space
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
@@ -943,12 +958,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -961,8 +977,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['@src/my\\ file.txt'];
|
||||
mockBuffer.cursor = [0, 16]; // After the escaped space and filename
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'my file.txt', value: 'my file.txt' }],
|
||||
});
|
||||
@@ -970,12 +986,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -988,8 +1005,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['@path/my\\ file.txt hello'];
|
||||
mockBuffer.cursor = [0, 24]; // After "hello"
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
@@ -997,12 +1014,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -1015,8 +1033,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md'];
|
||||
mockBuffer.cursor = [0, 29]; // At the end
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'my long file name.md', value: 'my long file name.md' },
|
||||
@@ -1026,12 +1044,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -1044,8 +1063,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['/memory\\ test'];
|
||||
mockBuffer.cursor = [0, 13]; // At the end
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'test-command', value: 'test-command' }],
|
||||
});
|
||||
@@ -1053,12 +1072,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -1071,8 +1091,8 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')];
|
||||
mockBuffer.cursor = [0, 25]; // After the escaped space and emoji
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' },
|
||||
@@ -1082,12 +1102,13 @@ describe('InputPrompt', () => {
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
['/test/project/src'],
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -1169,4 +1190,98 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverse search', () => {
|
||||
beforeEach(async () => {
|
||||
props.shellModeActive = true;
|
||||
|
||||
vi.mocked(useShellHistory).mockReturnValue({
|
||||
history: ['echo hello', 'echo world', 'ls'],
|
||||
getPreviousCommand: vi.fn(),
|
||||
getNextCommand: vi.fn(),
|
||||
addCommandToHistory: vi.fn(),
|
||||
resetHistoryPosition: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('invokes reverse search on Ctrl+R', async () => {
|
||||
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x12');
|
||||
await wait();
|
||||
|
||||
const frame = stdout.lastFrame();
|
||||
expect(frame).toContain('(r:)');
|
||||
expect(frame).toContain('echo hello');
|
||||
expect(frame).toContain('echo world');
|
||||
expect(frame).toContain('ls');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('resets reverse search state on Escape', async () => {
|
||||
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x12');
|
||||
await wait();
|
||||
stdin.write('\x1B');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain('(r:)');
|
||||
});
|
||||
|
||||
expect(stdout.lastFrame()).not.toContain('echo hello');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('completes the highlighted entry on Tab and exits reverse-search', async () => {
|
||||
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
|
||||
stdin.write('\x12');
|
||||
await wait();
|
||||
stdin.write('\t');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain('(r:)');
|
||||
});
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
|
||||
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
|
||||
stdin.write('\x12');
|
||||
await wait();
|
||||
expect(stdout.lastFrame()).toContain('(r:)');
|
||||
stdin.write('\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain('(r:)');
|
||||
});
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('echo hello');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('text and cursor position should be restored after reverse search', async () => {
|
||||
props.buffer.setText('initial text');
|
||||
props.buffer.cursor = [0, 3];
|
||||
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
|
||||
stdin.write('\x12');
|
||||
await wait();
|
||||
expect(stdout.lastFrame()).toContain('(r:)');
|
||||
stdin.write('\x1B');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain('(r:)');
|
||||
});
|
||||
expect(props.buffer.text).toBe('initial text');
|
||||
expect(props.buffer.cursor).toEqual([0, 3]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,13 @@ import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { TextBuffer } from './shared/text-buffer.js';
|
||||
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
|
||||
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useCompletion } from '../hooks/useCompletion.js';
|
||||
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
||||
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
||||
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
@@ -69,18 +70,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setDirs(dirsChanged);
|
||||
}
|
||||
}, [dirs.length, dirsChanged]);
|
||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
|
||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
|
||||
0, 0,
|
||||
]);
|
||||
const shellHistory = useShellHistory(config.getProjectRoot());
|
||||
const historyData = shellHistory.history;
|
||||
|
||||
const completion = useCompletion(
|
||||
const completion = useCommandCompletion(
|
||||
buffer,
|
||||
dirs,
|
||||
config.getTargetDir(),
|
||||
slashCommands,
|
||||
commandContext,
|
||||
reverseSearchActive,
|
||||
config,
|
||||
);
|
||||
|
||||
const reverseSearchCompletion = useReverseSearchCompletion(
|
||||
buffer,
|
||||
historyData,
|
||||
reverseSearchActive,
|
||||
);
|
||||
const resetCompletionState = completion.resetCompletionState;
|
||||
const shellHistory = useShellHistory(config.getProjectRoot());
|
||||
const resetReverseSearchCompletionState =
|
||||
reverseSearchCompletion.resetCompletionState;
|
||||
|
||||
const handleSubmitAndClear = useCallback(
|
||||
(submittedValue: string) => {
|
||||
@@ -92,8 +107,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.setText('');
|
||||
onSubmit(submittedValue);
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
},
|
||||
[onSubmit, buffer, resetCompletionState, shellModeActive, shellHistory],
|
||||
[
|
||||
onSubmit,
|
||||
buffer,
|
||||
resetCompletionState,
|
||||
shellModeActive,
|
||||
shellHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
],
|
||||
);
|
||||
|
||||
const customSetTextAndResetCompletionSignal = useCallback(
|
||||
@@ -118,6 +141,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
useEffect(() => {
|
||||
if (justNavigatedHistory) {
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
setJustNavigatedHistory(false);
|
||||
}
|
||||
}, [
|
||||
@@ -125,6 +149,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.text,
|
||||
resetCompletionState,
|
||||
setJustNavigatedHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
]);
|
||||
|
||||
// Handle clipboard image pasting with Ctrl+V
|
||||
@@ -197,6 +222,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
if (reverseSearchActive) {
|
||||
setReverseSearchActive(false);
|
||||
reverseSearchCompletion.resetCompletionState();
|
||||
buffer.setText(textBeforeReverseSearch);
|
||||
const offset = logicalPosToOffset(
|
||||
buffer.lines,
|
||||
cursorPosition[0],
|
||||
cursorPosition[1],
|
||||
);
|
||||
buffer.moveToOffset(offset);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
return;
|
||||
@@ -208,11 +246,61 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (shellModeActive && key.ctrl && key.name === 'r') {
|
||||
setReverseSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
setCursorPosition(buffer.cursor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.ctrl && key.name === 'l') {
|
||||
onClearScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (reverseSearchActive) {
|
||||
const {
|
||||
activeSuggestionIndex,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
showSuggestions,
|
||||
suggestions,
|
||||
} = reverseSearchCompletion;
|
||||
|
||||
if (showSuggestions) {
|
||||
if (key.name === 'up') {
|
||||
navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
navigateDown();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
|
||||
reverseSearchCompletion.resetCompletionState();
|
||||
setReverseSearchActive(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === 'return' && !key.ctrl) {
|
||||
const textToSubmit =
|
||||
showSuggestions && activeSuggestionIndex > -1
|
||||
? suggestions[activeSuggestionIndex].value
|
||||
: buffer.text;
|
||||
handleSubmitAndClear(textToSubmit);
|
||||
reverseSearchCompletion.resetCompletionState();
|
||||
setReverseSearchActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent up/down from falling through to regular history navigation
|
||||
if (key.name === 'up' || key.name === 'down') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If the command is a perfect match, pressing enter should execute it.
|
||||
if (completion.isPerfectMatch && key.name === 'return') {
|
||||
handleSubmitAndClear(buffer.text);
|
||||
@@ -272,7 +360,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Shell History Navigation
|
||||
if (key.name === 'up') {
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||
@@ -284,7 +371,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
|
||||
if (buffer.text.trim()) {
|
||||
const [row, col] = buffer.cursor;
|
||||
@@ -362,9 +448,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
inputHistory,
|
||||
handleSubmitAndClear,
|
||||
shellHistory,
|
||||
reverseSearchCompletion,
|
||||
handleClipboardImage,
|
||||
resetCompletionState,
|
||||
vimHandleInput,
|
||||
reverseSearchActive,
|
||||
textBeforeReverseSearch,
|
||||
cursorPosition,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -385,7 +475,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
<Text
|
||||
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
|
||||
>
|
||||
{shellModeActive ? '! ' : '> '}
|
||||
{shellModeActive ? (
|
||||
reverseSearchActive ? (
|
||||
<Text color={Colors.AccentCyan}>(r:) </Text>
|
||||
) : (
|
||||
'! '
|
||||
)
|
||||
) : (
|
||||
'> '
|
||||
)}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
@@ -449,6 +547,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{reverseSearchActive && (
|
||||
<Box>
|
||||
<SuggestionsDisplay
|
||||
suggestions={reverseSearchCompletion.suggestions}
|
||||
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
|
||||
isLoading={reverseSearchCompletion.isLoadingSuggestions}
|
||||
width={suggestionsWidth}
|
||||
scrollOffset={reverseSearchCompletion.visibleStartIndex}
|
||||
userInput={buffer.text}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
|
||||
interface PrepareLabelProps {
|
||||
label: string;
|
||||
matchedIndex?: number;
|
||||
userInput: string;
|
||||
textColor: string;
|
||||
highlightColor?: string;
|
||||
}
|
||||
|
||||
export const PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
label,
|
||||
matchedIndex,
|
||||
userInput,
|
||||
textColor,
|
||||
highlightColor = Colors.AccentYellow,
|
||||
}) => {
|
||||
if (
|
||||
matchedIndex === undefined ||
|
||||
matchedIndex < 0 ||
|
||||
matchedIndex >= label.length ||
|
||||
userInput.length === 0
|
||||
) {
|
||||
return <Text color={textColor}>{label}</Text>;
|
||||
}
|
||||
|
||||
const start = label.slice(0, matchedIndex);
|
||||
const match = label.slice(matchedIndex, matchedIndex + userInput.length);
|
||||
const end = label.slice(matchedIndex + userInput.length);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color={textColor}>{start}</Text>
|
||||
<Text color="black" bold backgroundColor={highlightColor}>
|
||||
{match}
|
||||
</Text>
|
||||
<Text color={textColor}>{end}</Text>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -6,10 +6,12 @@
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { PrepareLabel } from './PrepareLabel.js';
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
matchedIndex?: number;
|
||||
}
|
||||
interface SuggestionsDisplayProps {
|
||||
suggestions: Suggestion[];
|
||||
@@ -58,18 +60,25 @@ export function SuggestionsDisplay({
|
||||
const originalIndex = startIndex + index;
|
||||
const isActive = originalIndex === activeIndex;
|
||||
const textColor = isActive ? Colors.AccentPurple : Colors.Gray;
|
||||
const labelElement = (
|
||||
<PrepareLabel
|
||||
label={suggestion.label}
|
||||
matchedIndex={suggestion.matchedIndex}
|
||||
userInput={userInput}
|
||||
textColor={textColor}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box key={`${suggestion}-${originalIndex}`} width={width}>
|
||||
<Box key={`${suggestion.value}-${originalIndex}`} width={width}>
|
||||
<Box flexDirection="row">
|
||||
{userInput.startsWith('/') ? (
|
||||
// only use box model for (/) command mode
|
||||
<Box width={20} flexShrink={0}>
|
||||
<Text color={textColor}>{suggestion.label}</Text>
|
||||
{labelElement}
|
||||
</Box>
|
||||
) : (
|
||||
// use regular text for other modes (@ context)
|
||||
<Text color={textColor}>{suggestion.label}</Text>
|
||||
labelElement
|
||||
)}
|
||||
{suggestion.description ? (
|
||||
<Box flexGrow={1}>
|
||||
|
||||
@@ -689,5 +689,397 @@ describe('handleAtCommand', () => {
|
||||
`Ignored 1 files:\nGemini-ignored: ${geminiIgnoredFile}`,
|
||||
);
|
||||
});
|
||||
// });
|
||||
|
||||
describe('punctuation termination in @ commands', () => {
|
||||
const punctuationTestCases = [
|
||||
{
|
||||
name: 'comma',
|
||||
fileName: 'test.txt',
|
||||
fileContent: 'File content here',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Look at @${filePath}, then explain it.`,
|
||||
messageId: 400,
|
||||
},
|
||||
{
|
||||
name: 'period',
|
||||
fileName: 'readme.md',
|
||||
fileContent: 'File content here',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Check @${filePath}. What does it say?`,
|
||||
messageId: 401,
|
||||
},
|
||||
{
|
||||
name: 'semicolon',
|
||||
fileName: 'example.js',
|
||||
fileContent: 'Code example',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Review @${filePath}; check for bugs.`,
|
||||
messageId: 402,
|
||||
},
|
||||
{
|
||||
name: 'exclamation mark',
|
||||
fileName: 'important.txt',
|
||||
fileContent: 'Important content',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Look at @${filePath}! This is critical.`,
|
||||
messageId: 403,
|
||||
},
|
||||
{
|
||||
name: 'question mark',
|
||||
fileName: 'config.json',
|
||||
fileContent: 'Config settings',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`What is in @${filePath}? Please explain.`,
|
||||
messageId: 404,
|
||||
},
|
||||
{
|
||||
name: 'opening parenthesis',
|
||||
fileName: 'func.ts',
|
||||
fileContent: 'Function definition',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Analyze @${filePath}(the main function).`,
|
||||
messageId: 405,
|
||||
},
|
||||
{
|
||||
name: 'closing parenthesis',
|
||||
fileName: 'data.json',
|
||||
fileContent: 'Test data',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Use data from @${filePath}) for testing.`,
|
||||
messageId: 406,
|
||||
},
|
||||
{
|
||||
name: 'opening square bracket',
|
||||
fileName: 'array.js',
|
||||
fileContent: 'Array data',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Check @${filePath}[0] for the first element.`,
|
||||
messageId: 407,
|
||||
},
|
||||
{
|
||||
name: 'closing square bracket',
|
||||
fileName: 'list.md',
|
||||
fileContent: 'List content',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Review item @${filePath}] from the list.`,
|
||||
messageId: 408,
|
||||
},
|
||||
{
|
||||
name: 'opening curly brace',
|
||||
fileName: 'object.ts',
|
||||
fileContent: 'Object definition',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Parse @${filePath}{prop1: value1}.`,
|
||||
messageId: 409,
|
||||
},
|
||||
{
|
||||
name: 'closing curly brace',
|
||||
fileName: 'config.yaml',
|
||||
fileContent: 'Configuration',
|
||||
queryTemplate: (filePath: string) =>
|
||||
`Use settings from @${filePath}} for deployment.`,
|
||||
messageId: 410,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(punctuationTestCases)(
|
||||
'should terminate @path at $name',
|
||||
async ({ fileName, fileContent, queryTemplate, messageId }) => {
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, fileName),
|
||||
fileContent,
|
||||
);
|
||||
const query = queryTemplate(filePath);
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: query },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle multiple @paths terminated by different punctuation', async () => {
|
||||
const content1 = 'First file';
|
||||
const file1Path = await createTestFile(
|
||||
path.join(testRootDir, 'first.txt'),
|
||||
content1,
|
||||
);
|
||||
const content2 = 'Second file';
|
||||
const file2Path = await createTestFile(
|
||||
path.join(testRootDir, 'second.txt'),
|
||||
content2,
|
||||
);
|
||||
const query = `Compare @${file1Path}, @${file2Path}; what's different?`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 411,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: `Compare @${file1Path}, @${file2Path}; what's different?` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${file1Path}:\n` },
|
||||
{ text: content1 },
|
||||
{ text: `\nContent from @${file2Path}:\n` },
|
||||
{ text: content2 },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should still handle escaped spaces in paths before punctuation', async () => {
|
||||
const fileContent = 'Spaced file content';
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, 'spaced file.txt'),
|
||||
fileContent,
|
||||
);
|
||||
const escapedPath = path.join(testRootDir, 'spaced\\ file.txt');
|
||||
const query = `Check @${escapedPath}, it has spaces.`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 412,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: `Check @${filePath}, it has spaces.` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not break file paths with periods in extensions', async () => {
|
||||
const fileContent = 'TypeScript content';
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, 'example.d.ts'),
|
||||
fileContent,
|
||||
);
|
||||
const query = `Analyze @${filePath} for type definitions.`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 413,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: `Analyze @${filePath} for type definitions.` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle file paths ending with period followed by space', async () => {
|
||||
const fileContent = 'Config content';
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, 'config.json'),
|
||||
fileContent,
|
||||
);
|
||||
const query = `Check @${filePath}. This file contains settings.`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 414,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: `Check @${filePath}. This file contains settings.` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle comma termination with complex file paths', async () => {
|
||||
const fileContent = 'Package info';
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, 'package.json'),
|
||||
fileContent,
|
||||
);
|
||||
const query = `Review @${filePath}, then check dependencies.`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 415,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: `Review @${filePath}, then check dependencies.` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not terminate at period within file name', async () => {
|
||||
const fileContent = 'Version info';
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, 'version.1.2.3.txt'),
|
||||
fileContent,
|
||||
);
|
||||
const query = `Check @${filePath} contains version information.`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 416,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: `Check @${filePath} contains version information.` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle end of string termination for period and comma', async () => {
|
||||
const fileContent = 'End file content';
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, 'end.txt'),
|
||||
fileContent,
|
||||
);
|
||||
const query = `Show me @${filePath}.`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 417,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: `Show me @${filePath}.` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle files with special characters in names', async () => {
|
||||
const fileContent = 'File with special chars content';
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, 'file$with&special#chars.txt'),
|
||||
fileContent,
|
||||
);
|
||||
const query = `Check @${filePath} for content.`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 418,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: `Check @${filePath} for content.` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle basic file names without special characters', async () => {
|
||||
const fileContent = 'Basic file content';
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, 'basicfile.txt'),
|
||||
fileContent,
|
||||
);
|
||||
const query = `Check @${filePath} please.`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 421,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
processedQuery: [
|
||||
{ text: `Check @${filePath} please.` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
],
|
||||
shouldProceed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,9 +87,17 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
inEscape = false;
|
||||
} else if (char === '\\') {
|
||||
inEscape = true;
|
||||
} else if (/\s/.test(char)) {
|
||||
// Path ends at first whitespace not escaped
|
||||
} else if (/[,\s;!?()[\]{}]/.test(char)) {
|
||||
// Path ends at first whitespace or punctuation not escaped
|
||||
break;
|
||||
} else if (char === '.') {
|
||||
// For . we need to be more careful - only terminate if followed by whitespace or end of string
|
||||
// This allows file extensions like .txt, .js but terminates at sentence endings like "file.txt. Next sentence"
|
||||
const nextChar =
|
||||
pathEndIndex + 1 < query.length ? query[pathEndIndex + 1] : '';
|
||||
if (nextChar === '' || /\s/.test(nextChar)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
pathEndIndex++;
|
||||
}
|
||||
@@ -320,8 +328,7 @@ export async function handleAtCommand({
|
||||
if (
|
||||
i > 0 &&
|
||||
initialQueryText.length > 0 &&
|
||||
!initialQueryText.endsWith(' ') &&
|
||||
resolvedSpec
|
||||
!initialQueryText.endsWith(' ')
|
||||
) {
|
||||
// Add space if previous part was text and didn't end with space, or if previous was @path
|
||||
const prevPart = commandParts[i - 1];
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
const mockAddItem = vi.fn();
|
||||
const mockClearItems = vi.fn();
|
||||
const mockLoadHistory = vi.fn();
|
||||
const mockSetShowHelp = vi.fn();
|
||||
const mockOpenThemeDialog = vi.fn();
|
||||
const mockOpenAuthDialog = vi.fn();
|
||||
const mockSetQuittingMessages = vi.fn();
|
||||
|
||||
@@ -132,9 +132,8 @@ describe('useSlashCommandProcessor', () => {
|
||||
mockClearItems,
|
||||
mockLoadHistory,
|
||||
vi.fn(), // refreshStatic
|
||||
mockSetShowHelp,
|
||||
vi.fn(), // onDebugMessage
|
||||
vi.fn(), // openThemeDialog
|
||||
mockOpenThemeDialog, // openThemeDialog
|
||||
mockOpenAuthDialog,
|
||||
vi.fn(), // openEditorDialog
|
||||
vi.fn(), // toggleCorgiMode
|
||||
@@ -334,19 +333,19 @@ describe('useSlashCommandProcessor', () => {
|
||||
});
|
||||
|
||||
describe('Action Result Handling', () => {
|
||||
it('should handle "dialog: help" action', async () => {
|
||||
it('should handle "dialog: theme" action', async () => {
|
||||
const command = createTestCommand({
|
||||
name: 'helpcmd',
|
||||
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'help' }),
|
||||
name: 'themecmd',
|
||||
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'theme' }),
|
||||
});
|
||||
const result = setupProcessorHook([command]);
|
||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/helpcmd');
|
||||
await result.current.handleSlashCommand('/themecmd');
|
||||
});
|
||||
|
||||
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
|
||||
expect(mockOpenThemeDialog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle "load_history" action', async () => {
|
||||
@@ -819,15 +818,15 @@ describe('useSlashCommandProcessor', () => {
|
||||
mockClearItems,
|
||||
mockLoadHistory,
|
||||
vi.fn(), // refreshStatic
|
||||
mockSetShowHelp,
|
||||
vi.fn(), // onDebugMessage
|
||||
vi.fn(), // openThemeDialog
|
||||
mockOpenAuthDialog,
|
||||
vi.fn(), // openEditorDialog,
|
||||
vi.fn(), // openEditorDialog
|
||||
vi.fn(), // toggleCorgiMode
|
||||
mockSetQuittingMessages,
|
||||
vi.fn(), // openPrivacyNotice
|
||||
vi.fn(), // toggleVimEnabled
|
||||
vi.fn().mockResolvedValue(false), // toggleVimEnabled
|
||||
vi.fn(), // setIsProcessing
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ export const useSlashCommandProcessor = (
|
||||
clearItems: UseHistoryManagerReturn['clearItems'],
|
||||
loadHistory: UseHistoryManagerReturn['loadHistory'],
|
||||
refreshStatic: () => void,
|
||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
onDebugMessage: (message: string) => void,
|
||||
openThemeDialog: () => void,
|
||||
openAuthDialog: () => void,
|
||||
@@ -52,6 +51,7 @@ export const useSlashCommandProcessor = (
|
||||
openPrivacyNotice: () => void,
|
||||
toggleVimEnabled: () => Promise<boolean>,
|
||||
setIsProcessing: (isProcessing: boolean) => void,
|
||||
setGeminiMdFileCount: (count: number) => void,
|
||||
) => {
|
||||
const session = useSessionStats();
|
||||
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
|
||||
@@ -105,6 +105,11 @@ export const useSlashCommandProcessor = (
|
||||
selectedAuthType: message.selectedAuthType,
|
||||
gcpProject: message.gcpProject,
|
||||
};
|
||||
} else if (message.type === MessageType.HELP) {
|
||||
historyItemContent = {
|
||||
type: 'help',
|
||||
timestamp: message.timestamp,
|
||||
};
|
||||
} else if (message.type === MessageType.STATS) {
|
||||
historyItemContent = {
|
||||
type: 'stats',
|
||||
@@ -138,7 +143,6 @@ export const useSlashCommandProcessor = (
|
||||
},
|
||||
[addItem],
|
||||
);
|
||||
|
||||
const commandContext = useMemo(
|
||||
(): CommandContext => ({
|
||||
services: {
|
||||
@@ -160,6 +164,7 @@ export const useSlashCommandProcessor = (
|
||||
setPendingItem: setPendingCompressionItem,
|
||||
toggleCorgiMode,
|
||||
toggleVimEnabled,
|
||||
setGeminiMdFileCount,
|
||||
},
|
||||
session: {
|
||||
stats: session.stats,
|
||||
@@ -182,6 +187,7 @@ export const useSlashCommandProcessor = (
|
||||
toggleCorgiMode,
|
||||
toggleVimEnabled,
|
||||
sessionShellAllowlist,
|
||||
setGeminiMdFileCount,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -333,9 +339,6 @@ export const useSlashCommandProcessor = (
|
||||
return { type: 'handled' };
|
||||
case 'dialog':
|
||||
switch (result.dialog) {
|
||||
case 'help':
|
||||
setShowHelp(true);
|
||||
return { type: 'handled' };
|
||||
case 'auth':
|
||||
openAuthDialog();
|
||||
return { type: 'handled' };
|
||||
@@ -462,7 +465,6 @@ export const useSlashCommandProcessor = (
|
||||
[
|
||||
config,
|
||||
addItem,
|
||||
setShowHelp,
|
||||
openAuthDialog,
|
||||
commands,
|
||||
commandContext,
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import { Config, FileSearch } from '@google/gemini-cli-core';
|
||||
import {
|
||||
createTmpDir,
|
||||
cleanupTmpDir,
|
||||
FileSystemStructure,
|
||||
} from '@google/gemini-cli-test-utils';
|
||||
import { useState } from 'react';
|
||||
import { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
|
||||
// Test harness to capture the state from the hook's callbacks.
|
||||
function useTestHarnessForAtCompletion(
|
||||
enabled: boolean,
|
||||
pattern: string,
|
||||
config: Config | undefined,
|
||||
cwd: string,
|
||||
) {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
|
||||
useAtCompletion({
|
||||
enabled,
|
||||
pattern,
|
||||
config,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
});
|
||||
|
||||
return { suggestions, isLoadingSuggestions };
|
||||
}
|
||||
|
||||
describe('useAtCompletion', () => {
|
||||
let testRootDir: string;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
getFileFilteringOptions: vi.fn(() => ({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
})),
|
||||
} as unknown as Config;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testRootDir) {
|
||||
await cleanupTmpDir(testRootDir);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('File Search Logic', () => {
|
||||
it('should perform a recursive search for an empty pattern', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'file.txt': '',
|
||||
src: {
|
||||
'index.js': '',
|
||||
components: ['Button.tsx', 'Button with spaces.tsx'],
|
||||
},
|
||||
};
|
||||
testRootDir = await createTmpDir(structure);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'src/',
|
||||
'src/components/',
|
||||
'file.txt',
|
||||
'src/components/Button\\ with\\ spaces.tsx',
|
||||
'src/components/Button.tsx',
|
||||
'src/index.js',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly filter the recursive list based on a pattern', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'file.txt': '',
|
||||
src: {
|
||||
'index.js': '',
|
||||
components: {
|
||||
'Button.tsx': '',
|
||||
},
|
||||
},
|
||||
};
|
||||
testRootDir = await createTmpDir(structure);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForAtCompletion(true, 'src/', mockConfig, testRootDir),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'src/',
|
||||
'src/components/',
|
||||
'src/components/Button.tsx',
|
||||
'src/index.js',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should append a trailing slash to directory paths in suggestions', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'file.txt': '',
|
||||
dir: {},
|
||||
};
|
||||
testRootDir = await createTmpDir(structure);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'dir/',
|
||||
'file.txt',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI State and Loading Behavior', () => {
|
||||
it('should be in a loading state during initial file system crawl', async () => {
|
||||
testRootDir = await createTmpDir({});
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
|
||||
);
|
||||
|
||||
// It's initially true because the effect runs synchronously.
|
||||
expect(result.current.isLoadingSuggestions).toBe(true);
|
||||
|
||||
// Wait for the loading to complete.
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT show a loading indicator for subsequent searches that complete under 100ms', async () => {
|
||||
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
|
||||
testRootDir = await createTmpDir(structure);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ pattern }) =>
|
||||
useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
|
||||
{ initialProps: { pattern: 'a' } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'a.txt',
|
||||
]);
|
||||
});
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
|
||||
rerender({ pattern: 'b' });
|
||||
|
||||
// Wait for the final result
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'b.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 100ms', async () => {
|
||||
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
|
||||
testRootDir = await createTmpDir(structure);
|
||||
|
||||
// Spy on the search method to introduce an artificial delay
|
||||
const originalSearch = FileSearch.prototype.search;
|
||||
vi.spyOn(FileSearch.prototype, 'search').mockImplementation(
|
||||
async function (...args) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return originalSearch.apply(this, args);
|
||||
},
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ pattern }) =>
|
||||
useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
|
||||
{ initialProps: { pattern: 'a' } },
|
||||
);
|
||||
|
||||
// Wait for the initial (slow) search to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'a.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
// Now, rerender to trigger the second search
|
||||
rerender({ pattern: 'b' });
|
||||
|
||||
// Wait for the loading indicator to appear
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
// Suggestions should be cleared while loading
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
|
||||
// Wait for the final (slow) search to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'b.txt',
|
||||
]);
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
); // Increase timeout for the slow search
|
||||
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should abort the previous search when a new one starts', async () => {
|
||||
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
|
||||
testRootDir = await createTmpDir(structure);
|
||||
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
|
||||
const searchSpy = vi
|
||||
.spyOn(FileSearch.prototype, 'search')
|
||||
.mockImplementation(async (...args) => {
|
||||
const delay = args[0] === 'a' ? 500 : 50;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return [args[0] as any];
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ pattern }) =>
|
||||
useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
|
||||
{ initialProps: { pattern: 'a' } },
|
||||
);
|
||||
|
||||
// Wait for the hook to be ready (initialization is complete)
|
||||
await waitFor(() => {
|
||||
expect(searchSpy).toHaveBeenCalledWith('a', expect.any(Object));
|
||||
});
|
||||
|
||||
// Now that the first search is in-flight, trigger the second one.
|
||||
act(() => {
|
||||
rerender({ pattern: 'b' });
|
||||
});
|
||||
|
||||
// The abort should have been called for the first search.
|
||||
expect(abortSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for the final result, which should be from the second, faster search.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual(['b']);
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
|
||||
// The search spy should have been called for both patterns.
|
||||
expect(searchSpy).toHaveBeenCalledWith('b', expect.any(Object));
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering and Configuration', () => {
|
||||
it('should respect .gitignore files', async () => {
|
||||
const gitignoreContent = ['dist/', '*.log'].join('\n');
|
||||
const structure: FileSystemStructure = {
|
||||
'.git': {},
|
||||
'.gitignore': gitignoreContent,
|
||||
dist: {},
|
||||
'test.log': '',
|
||||
src: {},
|
||||
};
|
||||
testRootDir = await createTmpDir(structure);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'src/',
|
||||
'.gitignore',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should work correctly when config is undefined', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
node_modules: {},
|
||||
src: {},
|
||||
};
|
||||
testRootDir = await createTmpDir(structure);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForAtCompletion(true, '', undefined, testRootDir),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'node_modules/',
|
||||
'src/',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reset and re-initialize when the cwd changes', async () => {
|
||||
const structure1: FileSystemStructure = { 'file1.txt': '' };
|
||||
const rootDir1 = await createTmpDir(structure1);
|
||||
const structure2: FileSystemStructure = { 'file2.txt': '' };
|
||||
const rootDir2 = await createTmpDir(structure2);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ cwd, pattern }) =>
|
||||
useTestHarnessForAtCompletion(true, pattern, mockConfig, cwd),
|
||||
{
|
||||
initialProps: {
|
||||
cwd: rootDir1,
|
||||
pattern: 'file',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for initial suggestions from the first directory
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'file1.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
// Change the CWD
|
||||
act(() => {
|
||||
rerender({ cwd: rootDir2, pattern: 'file' });
|
||||
});
|
||||
|
||||
// After CWD changes, suggestions should be cleared and it should load again.
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(true);
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
// Wait for the new suggestions from the second directory
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'file2.txt',
|
||||
]);
|
||||
});
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
|
||||
await cleanupTmpDir(rootDir1);
|
||||
await cleanupTmpDir(rootDir2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useReducer, useRef } from 'react';
|
||||
import { Config, FileSearch, escapePath } from '@google/gemini-cli-core';
|
||||
import {
|
||||
Suggestion,
|
||||
MAX_SUGGESTIONS_TO_SHOW,
|
||||
} from '../components/SuggestionsDisplay.js';
|
||||
|
||||
export enum AtCompletionStatus {
|
||||
IDLE = 'idle',
|
||||
INITIALIZING = 'initializing',
|
||||
READY = 'ready',
|
||||
SEARCHING = 'searching',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
interface AtCompletionState {
|
||||
status: AtCompletionStatus;
|
||||
suggestions: Suggestion[];
|
||||
isLoading: boolean;
|
||||
pattern: string | null;
|
||||
}
|
||||
|
||||
type AtCompletionAction =
|
||||
| { type: 'INITIALIZE' }
|
||||
| { type: 'INITIALIZE_SUCCESS' }
|
||||
| { type: 'SEARCH'; payload: string }
|
||||
| { type: 'SEARCH_SUCCESS'; payload: Suggestion[] }
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'ERROR' }
|
||||
| { type: 'RESET' };
|
||||
|
||||
const initialState: AtCompletionState = {
|
||||
status: AtCompletionStatus.IDLE,
|
||||
suggestions: [],
|
||||
isLoading: false,
|
||||
pattern: null,
|
||||
};
|
||||
|
||||
function atCompletionReducer(
|
||||
state: AtCompletionState,
|
||||
action: AtCompletionAction,
|
||||
): AtCompletionState {
|
||||
switch (action.type) {
|
||||
case 'INITIALIZE':
|
||||
return {
|
||||
...state,
|
||||
status: AtCompletionStatus.INITIALIZING,
|
||||
isLoading: true,
|
||||
};
|
||||
case 'INITIALIZE_SUCCESS':
|
||||
return { ...state, status: AtCompletionStatus.READY, isLoading: false };
|
||||
case 'SEARCH':
|
||||
// Keep old suggestions, don't set loading immediately
|
||||
return {
|
||||
...state,
|
||||
status: AtCompletionStatus.SEARCHING,
|
||||
pattern: action.payload,
|
||||
};
|
||||
case 'SEARCH_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
status: AtCompletionStatus.READY,
|
||||
suggestions: action.payload,
|
||||
isLoading: false,
|
||||
};
|
||||
case 'SET_LOADING':
|
||||
// Only show loading if we are still in a searching state
|
||||
if (state.status === AtCompletionStatus.SEARCHING) {
|
||||
return { ...state, isLoading: action.payload, suggestions: [] };
|
||||
}
|
||||
return state;
|
||||
case 'ERROR':
|
||||
return {
|
||||
...state,
|
||||
status: AtCompletionStatus.ERROR,
|
||||
isLoading: false,
|
||||
suggestions: [],
|
||||
};
|
||||
case 'RESET':
|
||||
return initialState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseAtCompletionProps {
|
||||
enabled: boolean;
|
||||
pattern: string;
|
||||
config: Config | undefined;
|
||||
cwd: string;
|
||||
setSuggestions: (suggestions: Suggestion[]) => void;
|
||||
setIsLoadingSuggestions: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
export function useAtCompletion(props: UseAtCompletionProps): void {
|
||||
const {
|
||||
enabled,
|
||||
pattern,
|
||||
config,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
} = props;
|
||||
const [state, dispatch] = useReducer(atCompletionReducer, initialState);
|
||||
const fileSearch = useRef<FileSearch | null>(null);
|
||||
const searchAbortController = useRef<AbortController | null>(null);
|
||||
const slowSearchTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSuggestions(state.suggestions);
|
||||
}, [state.suggestions, setSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingSuggestions(state.isLoading);
|
||||
}, [state.isLoading, setIsLoadingSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'RESET' });
|
||||
}, [cwd, config]);
|
||||
|
||||
// Reacts to user input (`pattern`) ONLY.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
// reset when first getting out of completion suggestions
|
||||
if (
|
||||
state.status === AtCompletionStatus.READY ||
|
||||
state.status === AtCompletionStatus.ERROR
|
||||
) {
|
||||
dispatch({ type: 'RESET' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pattern === null) {
|
||||
dispatch({ type: 'RESET' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === AtCompletionStatus.IDLE) {
|
||||
dispatch({ type: 'INITIALIZE' });
|
||||
} else if (
|
||||
(state.status === AtCompletionStatus.READY ||
|
||||
state.status === AtCompletionStatus.SEARCHING) &&
|
||||
pattern !== state.pattern // Only search if the pattern has changed
|
||||
) {
|
||||
dispatch({ type: 'SEARCH', payload: pattern });
|
||||
}
|
||||
}, [enabled, pattern, state.status, state.pattern]);
|
||||
|
||||
// The "Worker" that performs async operations based on status.
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
try {
|
||||
const searcher = new FileSearch({
|
||||
projectRoot: cwd,
|
||||
ignoreDirs: [],
|
||||
useGitignore:
|
||||
config?.getFileFilteringOptions()?.respectGitIgnore ?? true,
|
||||
useGeminiignore:
|
||||
config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true,
|
||||
cache: true,
|
||||
cacheTtl: 30, // 30 seconds
|
||||
});
|
||||
await searcher.initialize();
|
||||
fileSearch.current = searcher;
|
||||
dispatch({ type: 'INITIALIZE_SUCCESS' });
|
||||
if (state.pattern !== null) {
|
||||
dispatch({ type: 'SEARCH', payload: state.pattern });
|
||||
}
|
||||
} catch (_) {
|
||||
dispatch({ type: 'ERROR' });
|
||||
}
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
if (!fileSearch.current || state.pattern === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (slowSearchTimer.current) {
|
||||
clearTimeout(slowSearchTimer.current);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
searchAbortController.current = controller;
|
||||
|
||||
slowSearchTimer.current = setTimeout(() => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
const results = await fileSearch.current.search(state.pattern, {
|
||||
signal: controller.signal,
|
||||
maxResults: MAX_SUGGESTIONS_TO_SHOW * 3,
|
||||
});
|
||||
|
||||
if (slowSearchTimer.current) {
|
||||
clearTimeout(slowSearchTimer.current);
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = results.map((p) => ({
|
||||
label: p,
|
||||
value: escapePath(p),
|
||||
}));
|
||||
dispatch({ type: 'SEARCH_SUCCESS', payload: suggestions });
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.name === 'AbortError')) {
|
||||
dispatch({ type: 'ERROR' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (state.status === AtCompletionStatus.INITIALIZING) {
|
||||
initialize();
|
||||
} else if (state.status === AtCompletionStatus.SEARCHING) {
|
||||
search();
|
||||
}
|
||||
|
||||
return () => {
|
||||
searchAbortController.current?.abort();
|
||||
if (slowSearchTimer.current) {
|
||||
clearTimeout(slowSearchTimer.current);
|
||||
}
|
||||
};
|
||||
}, [state.status, state.pattern, config, cwd]);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useCommandCompletion } from './useCommandCompletion.js';
|
||||
import { CommandContext } from '../commands/types.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { useEffect } from 'react';
|
||||
import { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import { UseAtCompletionProps, useAtCompletion } from './useAtCompletion.js';
|
||||
import {
|
||||
UseSlashCompletionProps,
|
||||
useSlashCompletion,
|
||||
} from './useSlashCompletion.js';
|
||||
|
||||
vi.mock('./useAtCompletion', () => ({
|
||||
useAtCompletion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./useSlashCompletion', () => ({
|
||||
useSlashCompletion: vi.fn(() => ({
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Helper to set up mocks in a consistent way for both child hooks
|
||||
const setupMocks = ({
|
||||
atSuggestions = [],
|
||||
slashSuggestions = [],
|
||||
isLoading = false,
|
||||
isPerfectMatch = false,
|
||||
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
|
||||
}: {
|
||||
atSuggestions?: Suggestion[];
|
||||
slashSuggestions?: Suggestion[];
|
||||
isLoading?: boolean;
|
||||
isPerfectMatch?: boolean;
|
||||
slashCompletionRange?: { completionStart: number; completionEnd: number };
|
||||
}) => {
|
||||
// Mock for @-completions
|
||||
(useAtCompletion as vi.Mock).mockImplementation(
|
||||
({
|
||||
enabled,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
}: UseAtCompletionProps) => {
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
setIsLoadingSuggestions(isLoading);
|
||||
setSuggestions(atSuggestions);
|
||||
}
|
||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock for /-completions
|
||||
(useSlashCompletion as vi.Mock).mockImplementation(
|
||||
({
|
||||
enabled,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
}: UseSlashCompletionProps) => {
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
setIsLoadingSuggestions(isLoading);
|
||||
setSuggestions(slashSuggestions);
|
||||
setIsPerfectMatch(isPerfectMatch);
|
||||
}
|
||||
}, [enabled, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch]);
|
||||
// The hook returns a range, which we can mock simply
|
||||
return slashCompletionRange;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe('useCommandCompletion', () => {
|
||||
const mockCommandContext = {} as CommandContext;
|
||||
const mockConfig = {} as Config;
|
||||
const testDirs: string[] = [];
|
||||
const testRootDir = '/';
|
||||
|
||||
// Helper to create real TextBuffer objects within renderHook
|
||||
function useTextBufferForTest(text: string, cursorOffset?: number) {
|
||||
return useTextBuffer({
|
||||
initialText: text,
|
||||
initialCursorOffset: cursorOffset ?? text.length,
|
||||
viewport: { width: 80, height: 20 },
|
||||
isValidPath: () => false,
|
||||
onChange: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset to default mocks before each test
|
||||
setupMocks({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Core Hook Behavior', () => {
|
||||
describe('State Management', () => {
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest(''),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset state when completion mode becomes IDLE', async () => {
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest('@file');
|
||||
const completion = useCommandCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { completion, textBuffer };
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.completion.suggestions).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(result.current.completion.showSuggestions).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.textBuffer.replaceRangeByOffset(
|
||||
0,
|
||||
5,
|
||||
'just some text',
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.completion.showSuggestions).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset all state to default values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest('@files'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveSuggestionIndex(5);
|
||||
result.current.setShowSuggestions(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetCompletionState();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should call useAtCompletion with the correct query for an escaped space', async () => {
|
||||
const text = '@src/a\\ file.txt';
|
||||
renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest(text),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAtCompletion).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
pattern: 'src/a\\ file.txt',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly identify the completion context with multiple @ symbols', async () => {
|
||||
const text = '@file1 @file2';
|
||||
const cursorOffset = 3; // @fi|le1 @file2
|
||||
|
||||
renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest(text, cursorOffset),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAtCompletion).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
pattern: 'file1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
const mockSuggestions = [
|
||||
{ label: 'cmd1', value: 'cmd1' },
|
||||
{ label: 'cmd2', value: 'cmd2' },
|
||||
{ label: 'cmd3', value: 'cmd3' },
|
||||
{ label: 'cmd4', value: 'cmd4' },
|
||||
{ label: 'cmd5', value: 'cmd5' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
setupMocks({ slashSuggestions: mockSuggestions });
|
||||
});
|
||||
|
||||
it('should handle navigateUp with no suggestions', () => {
|
||||
setupMocks({ slashSuggestions: [] });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest('/'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle navigateDown with no suggestions', () => {
|
||||
setupMocks({ slashSuggestions: [] });
|
||||
const { result } = renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest('/'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should navigate up through suggestions with wrap-around', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest('/'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(5);
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(4);
|
||||
});
|
||||
|
||||
it('should navigate down through suggestions with wrap-around', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest('/'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(5);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveSuggestionIndex(4);
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(4);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle navigation with multiple suggestions', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest('/'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(5);
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => result.current.navigateDown());
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
|
||||
act(() => result.current.navigateDown());
|
||||
expect(result.current.activeSuggestionIndex).toBe(2);
|
||||
|
||||
act(() => result.current.navigateUp());
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
|
||||
act(() => result.current.navigateUp());
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => result.current.navigateUp());
|
||||
expect(result.current.activeSuggestionIndex).toBe(4);
|
||||
});
|
||||
|
||||
it('should automatically select the first item when suggestions are available', async () => {
|
||||
setupMocks({ slashSuggestions: mockSuggestions });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest('/'),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(
|
||||
mockSuggestions.length,
|
||||
);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAutocomplete', () => {
|
||||
it('should complete a partial command', async () => {
|
||||
setupMocks({
|
||||
slashSuggestions: [{ label: 'memory', value: 'memory' }],
|
||||
slashCompletionRange: { completionStart: 1, completionEnd: 4 },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest('/mem');
|
||||
const completion = useCommandCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('/memory ');
|
||||
});
|
||||
|
||||
it('should complete a file path', async () => {
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest('@src/fi');
|
||||
const completion = useCommandCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('@src/file1.txt ');
|
||||
});
|
||||
|
||||
it('should complete a file path when cursor is not at the end of the line', async () => {
|
||||
const text = '@src/fi is a good file';
|
||||
const cursorOffset = 7; // after "i"
|
||||
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest(text, cursorOffset);
|
||||
const completion = useCommandCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
expect(result.current.textBuffer.text).toBe(
|
||||
'@src/file1.txt is a good file',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useEffect } from 'react';
|
||||
import { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import {
|
||||
logicalPosToOffset,
|
||||
TextBuffer,
|
||||
} from '../components/shared/text-buffer.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
|
||||
export enum CompletionMode {
|
||||
IDLE = 'IDLE',
|
||||
AT = 'AT',
|
||||
SLASH = 'SLASH',
|
||||
}
|
||||
|
||||
export interface UseCommandCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
activeSuggestionIndex: number;
|
||||
visibleStartIndex: number;
|
||||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
isPerfectMatch: boolean;
|
||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
resetCompletionState: () => void;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
handleAutocomplete: (indexToUse: number) => void;
|
||||
}
|
||||
|
||||
export function useCommandCompletion(
|
||||
buffer: TextBuffer,
|
||||
dirs: readonly string[],
|
||||
cwd: string,
|
||||
slashCommands: readonly SlashCommand[],
|
||||
commandContext: CommandContext,
|
||||
reverseSearchActive: boolean = false,
|
||||
config?: Config,
|
||||
): UseCommandCompletionReturn {
|
||||
const {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
isPerfectMatch,
|
||||
|
||||
setSuggestions,
|
||||
setShowSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
setVisibleStartIndex,
|
||||
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
} = useCompletion();
|
||||
|
||||
const cursorRow = buffer.cursor[0];
|
||||
const cursorCol = buffer.cursor[1];
|
||||
|
||||
const { completionMode, query, completionStart, completionEnd } =
|
||||
useMemo(() => {
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
||||
return {
|
||||
completionMode: CompletionMode.SLASH,
|
||||
query: currentLine,
|
||||
completionStart: 0,
|
||||
completionEnd: currentLine.length,
|
||||
};
|
||||
}
|
||||
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
for (let i = cursorCol - 1; i >= 0; i--) {
|
||||
const char = codePoints[i];
|
||||
|
||||
if (char === ' ') {
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
if (backslashCount % 2 === 0) {
|
||||
return {
|
||||
completionMode: CompletionMode.IDLE,
|
||||
query: null,
|
||||
completionStart: -1,
|
||||
completionEnd: -1,
|
||||
};
|
||||
}
|
||||
} else if (char === '@') {
|
||||
let end = codePoints.length;
|
||||
for (let i = cursorCol; i < codePoints.length; i++) {
|
||||
if (codePoints[i] === ' ') {
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
|
||||
if (backslashCount % 2 === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const pathStart = i + 1;
|
||||
const partialPath = currentLine.substring(pathStart, end);
|
||||
return {
|
||||
completionMode: CompletionMode.AT,
|
||||
query: partialPath,
|
||||
completionStart: pathStart,
|
||||
completionEnd: end,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
completionMode: CompletionMode.IDLE,
|
||||
query: null,
|
||||
completionStart: -1,
|
||||
completionEnd: -1,
|
||||
};
|
||||
}, [cursorRow, cursorCol, buffer.lines]);
|
||||
|
||||
useAtCompletion({
|
||||
enabled: completionMode === CompletionMode.AT,
|
||||
pattern: query || '',
|
||||
config,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
});
|
||||
|
||||
const slashCompletionRange = useSlashCompletion({
|
||||
enabled: completionMode === CompletionMode.SLASH,
|
||||
query,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
|
||||
setVisibleStartIndex(0);
|
||||
}, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
// Show suggestions if we are loading OR if there are results to display.
|
||||
setShowSuggestions(isLoadingSuggestions || suggestions.length > 0);
|
||||
}, [
|
||||
completionMode,
|
||||
suggestions.length,
|
||||
isLoadingSuggestions,
|
||||
reverseSearchActive,
|
||||
resetCompletionState,
|
||||
setShowSuggestions,
|
||||
]);
|
||||
|
||||
const handleAutocomplete = useCallback(
|
||||
(indexToUse: number) => {
|
||||
if (indexToUse < 0 || indexToUse >= suggestions.length) {
|
||||
return;
|
||||
}
|
||||
const suggestion = suggestions[indexToUse].value;
|
||||
|
||||
let start = completionStart;
|
||||
let end = completionEnd;
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
start = slashCompletionRange.completionStart;
|
||||
end = slashCompletionRange.completionEnd;
|
||||
}
|
||||
|
||||
if (start === -1 || end === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let suggestionText = suggestion;
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
if (
|
||||
start === end &&
|
||||
start > 1 &&
|
||||
(buffer.lines[cursorRow] || '')[start - 1] !== ' '
|
||||
) {
|
||||
suggestionText = ' ' + suggestionText;
|
||||
}
|
||||
}
|
||||
|
||||
suggestionText += ' ';
|
||||
|
||||
buffer.replaceRangeByOffset(
|
||||
logicalPosToOffset(buffer.lines, cursorRow, start),
|
||||
logicalPosToOffset(buffer.lines, cursorRow, end),
|
||||
suggestionText,
|
||||
);
|
||||
},
|
||||
[
|
||||
cursorRow,
|
||||
buffer,
|
||||
suggestions,
|
||||
completionMode,
|
||||
completionStart,
|
||||
completionEnd,
|
||||
slashCompletionRange,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
isPerfectMatch,
|
||||
setActiveSuggestionIndex,
|
||||
setShowSuggestions,
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
handleAutocomplete,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,30 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { glob } from 'glob';
|
||||
import {
|
||||
isNodeError,
|
||||
escapePath,
|
||||
unescapePath,
|
||||
getErrorMessage,
|
||||
Config,
|
||||
FileDiscoveryService,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import {
|
||||
MAX_SUGGESTIONS_TO_SHOW,
|
||||
Suggestion,
|
||||
} from '../components/SuggestionsDisplay.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import {
|
||||
logicalPosToOffset,
|
||||
TextBuffer,
|
||||
} from '../components/shared/text-buffer.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
|
||||
export interface UseCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
@@ -36,22 +18,18 @@ export interface UseCompletionReturn {
|
||||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
isPerfectMatch: boolean;
|
||||
setSuggestions: React.Dispatch<React.SetStateAction<Suggestion[]>>;
|
||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setVisibleStartIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setIsLoadingSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsPerfectMatch: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
resetCompletionState: () => void;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
handleAutocomplete: (indexToUse: number) => void;
|
||||
}
|
||||
|
||||
export function useCompletion(
|
||||
buffer: TextBuffer,
|
||||
dirs: readonly string[],
|
||||
cwd: string,
|
||||
slashCommands: readonly SlashCommand[],
|
||||
commandContext: CommandContext,
|
||||
config?: Config,
|
||||
): UseCompletionReturn {
|
||||
export function useCompletion(): UseCompletionReturn {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [activeSuggestionIndex, setActiveSuggestionIndex] =
|
||||
useState<number>(-1);
|
||||
@@ -60,11 +38,6 @@ export function useCompletion(
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
||||
useState<boolean>(false);
|
||||
const [isPerfectMatch, setIsPerfectMatch] = useState<boolean>(false);
|
||||
const completionStart = useRef(-1);
|
||||
const completionEnd = useRef(-1);
|
||||
|
||||
const cursorRow = buffer.cursor[0];
|
||||
const cursorCol = buffer.cursor[1];
|
||||
|
||||
const resetCompletionState = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
@@ -133,560 +106,6 @@ export function useCompletion(
|
||||
return newActiveIndex;
|
||||
});
|
||||
}, [suggestions.length]);
|
||||
|
||||
// Check if cursor is after @ or / without unescaped spaces
|
||||
const commandIndex = useMemo(() => {
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
||||
return currentLine.indexOf('/');
|
||||
}
|
||||
|
||||
// For other completions like '@', we search backwards from the cursor.
|
||||
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
for (let i = cursorCol - 1; i >= 0; i--) {
|
||||
const char = codePoints[i];
|
||||
|
||||
if (char === ' ') {
|
||||
// Check for unescaped spaces.
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
if (backslashCount % 2 === 0) {
|
||||
return -1; // Inactive on unescaped space.
|
||||
}
|
||||
} else if (char === '@') {
|
||||
// Active if we find an '@' before any unescaped space.
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}, [cursorRow, cursorCol, buffer.lines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (commandIndex === -1) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
|
||||
if (codePoints[commandIndex] === '/') {
|
||||
// Always reset perfect match at the beginning of processing.
|
||||
setIsPerfectMatch(false);
|
||||
|
||||
const fullPath = currentLine.substring(commandIndex + 1);
|
||||
const hasTrailingSpace = currentLine.endsWith(' ');
|
||||
|
||||
// Get all non-empty parts of the command.
|
||||
const rawParts = fullPath.split(/\s+/).filter((p) => p);
|
||||
|
||||
let commandPathParts = rawParts;
|
||||
let partial = '';
|
||||
|
||||
// If there's no trailing space, the last part is potentially a partial segment.
|
||||
// We tentatively separate it.
|
||||
if (!hasTrailingSpace && rawParts.length > 0) {
|
||||
partial = rawParts[rawParts.length - 1];
|
||||
commandPathParts = rawParts.slice(0, -1);
|
||||
}
|
||||
|
||||
// Traverse the Command Tree using the tentative completed path
|
||||
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
|
||||
let leafCommand: SlashCommand | null = null;
|
||||
|
||||
for (const part of commandPathParts) {
|
||||
if (!currentLevel) {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
const found: SlashCommand | undefined = currentLevel.find(
|
||||
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
|
||||
);
|
||||
if (found) {
|
||||
leafCommand = found;
|
||||
currentLevel = found.subCommands as
|
||||
| readonly SlashCommand[]
|
||||
| undefined;
|
||||
} else {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let exactMatchAsParent: SlashCommand | undefined;
|
||||
// Handle the Ambiguous Case
|
||||
if (!hasTrailingSpace && currentLevel) {
|
||||
exactMatchAsParent = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.subCommands,
|
||||
);
|
||||
|
||||
if (exactMatchAsParent) {
|
||||
// It's a perfect match for a parent command. Override our initial guess.
|
||||
// Treat it as a completed command path.
|
||||
leafCommand = exactMatchAsParent;
|
||||
currentLevel = exactMatchAsParent.subCommands;
|
||||
partial = ''; // We now want to suggest ALL of its sub-commands.
|
||||
}
|
||||
}
|
||||
|
||||
// Check for perfect, executable match
|
||||
if (!hasTrailingSpace) {
|
||||
if (leafCommand && partial === '' && leafCommand.action) {
|
||||
// Case: /command<enter> - command has action, no sub-commands were suggested
|
||||
setIsPerfectMatch(true);
|
||||
} else if (currentLevel) {
|
||||
// Case: /command subcommand<enter>
|
||||
const perfectMatch = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.action,
|
||||
);
|
||||
if (perfectMatch) {
|
||||
setIsPerfectMatch(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const depth = commandPathParts.length;
|
||||
const isArgumentCompletion =
|
||||
leafCommand?.completion &&
|
||||
(hasTrailingSpace ||
|
||||
(rawParts.length > depth && depth > 0 && partial !== ''));
|
||||
|
||||
// Set completion range
|
||||
if (hasTrailingSpace || exactMatchAsParent) {
|
||||
completionStart.current = currentLine.length;
|
||||
completionEnd.current = currentLine.length;
|
||||
} else if (partial) {
|
||||
if (isArgumentCompletion) {
|
||||
const commandSoFar = `/${commandPathParts.join(' ')}`;
|
||||
const argStartIndex =
|
||||
commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
|
||||
completionStart.current = argStartIndex;
|
||||
} else {
|
||||
completionStart.current = currentLine.length - partial.length;
|
||||
}
|
||||
completionEnd.current = currentLine.length;
|
||||
} else {
|
||||
// e.g. /
|
||||
completionStart.current = commandIndex + 1;
|
||||
completionEnd.current = currentLine.length;
|
||||
}
|
||||
|
||||
// Provide Suggestions based on the now-corrected context
|
||||
if (isArgumentCompletion) {
|
||||
const fetchAndSetSuggestions = async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
const argString = rawParts.slice(depth).join(' ');
|
||||
const results =
|
||||
(await leafCommand!.completion!(commandContext, argString)) || [];
|
||||
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
|
||||
setSuggestions(finalSuggestions);
|
||||
setShowSuggestions(finalSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||
setIsLoadingSuggestions(false);
|
||||
};
|
||||
fetchAndSetSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Command/Sub-command Completion
|
||||
const commandsToSearch = currentLevel || [];
|
||||
if (commandsToSearch.length > 0) {
|
||||
let potentialSuggestions = commandsToSearch.filter(
|
||||
(cmd) =>
|
||||
cmd.description &&
|
||||
(cmd.name.startsWith(partial) ||
|
||||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
|
||||
);
|
||||
|
||||
// If a user's input is an exact match and it is a leaf command,
|
||||
// enter should submit immediately.
|
||||
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
|
||||
const perfectMatch = potentialSuggestions.find(
|
||||
(s) => s.name === partial || s.altNames?.includes(partial),
|
||||
);
|
||||
if (perfectMatch && perfectMatch.action) {
|
||||
potentialSuggestions = [];
|
||||
}
|
||||
}
|
||||
|
||||
const finalSuggestions = potentialSuggestions.map((cmd) => ({
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
setShowSuggestions(finalSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||
setIsLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we fall through, no suggestions are available.
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle At Command Completion
|
||||
completionEnd.current = codePoints.length;
|
||||
for (let i = cursorCol; i < codePoints.length; i++) {
|
||||
if (codePoints[i] === ' ') {
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
|
||||
if (backslashCount % 2 === 0) {
|
||||
completionEnd.current = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathStart = commandIndex + 1;
|
||||
const partialPath = currentLine.substring(pathStart, completionEnd.current);
|
||||
const lastSlashIndex = partialPath.lastIndexOf('/');
|
||||
completionStart.current =
|
||||
lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1;
|
||||
const baseDirRelative =
|
||||
lastSlashIndex === -1
|
||||
? '.'
|
||||
: partialPath.substring(0, lastSlashIndex + 1);
|
||||
const prefix = unescapePath(
|
||||
lastSlashIndex === -1
|
||||
? partialPath
|
||||
: partialPath.substring(lastSlashIndex + 1),
|
||||
);
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const findFilesRecursively = async (
|
||||
startDir: string,
|
||||
searchPrefix: string,
|
||||
fileDiscovery: FileDiscoveryService | null,
|
||||
filterOptions: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
currentRelativePath = '',
|
||||
depth = 0,
|
||||
maxDepth = 10, // Limit recursion depth
|
||||
maxResults = 50, // Limit number of results
|
||||
): Promise<Suggestion[]> => {
|
||||
if (depth > maxDepth) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerSearchPrefix = searchPrefix.toLowerCase();
|
||||
let foundSuggestions: Suggestion[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(startDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (foundSuggestions.length >= maxResults) break;
|
||||
|
||||
const entryPathRelative = path.join(currentRelativePath, entry.name);
|
||||
const entryPathFromRoot = path.relative(
|
||||
startDir,
|
||||
path.join(startDir, entry.name),
|
||||
);
|
||||
|
||||
// Conditionally ignore dotfiles
|
||||
if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this entry should be ignored by filtering options
|
||||
if (
|
||||
fileDiscovery &&
|
||||
fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) {
|
||||
foundSuggestions.push({
|
||||
label: entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
||||
value: escapePath(
|
||||
entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
entry.name !== 'node_modules' &&
|
||||
!entry.name.startsWith('.')
|
||||
) {
|
||||
if (foundSuggestions.length < maxResults) {
|
||||
foundSuggestions = foundSuggestions.concat(
|
||||
await findFilesRecursively(
|
||||
path.join(startDir, entry.name),
|
||||
searchPrefix, // Pass original searchPrefix for recursive calls
|
||||
fileDiscovery,
|
||||
filterOptions,
|
||||
entryPathRelative,
|
||||
depth + 1,
|
||||
maxDepth,
|
||||
maxResults - foundSuggestions.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore errors like permission denied or ENOENT during recursive search
|
||||
}
|
||||
return foundSuggestions.slice(0, maxResults);
|
||||
};
|
||||
|
||||
const findFilesWithGlob = async (
|
||||
searchPrefix: string,
|
||||
fileDiscoveryService: FileDiscoveryService,
|
||||
filterOptions: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
searchDir: string,
|
||||
maxResults = 50,
|
||||
): Promise<Suggestion[]> => {
|
||||
const globPattern = `**/${searchPrefix}*`;
|
||||
const files = await glob(globPattern, {
|
||||
cwd: searchDir,
|
||||
dot: searchPrefix.startsWith('.'),
|
||||
nocase: true,
|
||||
});
|
||||
|
||||
const suggestions: Suggestion[] = files
|
||||
.filter((file) => {
|
||||
if (fileDiscoveryService) {
|
||||
return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((file: string) => {
|
||||
const absolutePath = path.resolve(searchDir, file);
|
||||
const label = path.relative(cwd, absolutePath);
|
||||
return {
|
||||
label,
|
||||
value: escapePath(label),
|
||||
};
|
||||
})
|
||||
.slice(0, maxResults);
|
||||
|
||||
return suggestions;
|
||||
};
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
let fetchedSuggestions: Suggestion[] = [];
|
||||
|
||||
const fileDiscoveryService = config ? config.getFileService() : null;
|
||||
const enableRecursiveSearch =
|
||||
config?.getEnableRecursiveFileSearch() ?? true;
|
||||
const filterOptions =
|
||||
config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
||||
|
||||
try {
|
||||
// If there's no slash, or it's the root, do a recursive search from workspace directories
|
||||
for (const dir of dirs) {
|
||||
let fetchedSuggestionsPerDir: Suggestion[] = [];
|
||||
if (
|
||||
partialPath.indexOf('/') === -1 &&
|
||||
prefix &&
|
||||
enableRecursiveSearch
|
||||
) {
|
||||
if (fileDiscoveryService) {
|
||||
fetchedSuggestionsPerDir = await findFilesWithGlob(
|
||||
prefix,
|
||||
fileDiscoveryService,
|
||||
filterOptions,
|
||||
dir,
|
||||
);
|
||||
} else {
|
||||
fetchedSuggestionsPerDir = await findFilesRecursively(
|
||||
dir,
|
||||
prefix,
|
||||
null,
|
||||
filterOptions,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Original behavior: list files in the specific directory
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
const baseDirAbsolute = path.resolve(dir, baseDirRelative);
|
||||
const entries = await fs.readdir(baseDirAbsolute, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
// Filter entries using git-aware filtering
|
||||
const filteredEntries = [];
|
||||
for (const entry of entries) {
|
||||
// Conditionally ignore dotfiles
|
||||
if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
|
||||
|
||||
const relativePath = path.relative(
|
||||
dir,
|
||||
path.join(baseDirAbsolute, entry.name),
|
||||
);
|
||||
if (
|
||||
fileDiscoveryService &&
|
||||
fileDiscoveryService.shouldIgnoreFile(
|
||||
relativePath,
|
||||
filterOptions,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filteredEntries.push(entry);
|
||||
}
|
||||
|
||||
fetchedSuggestionsPerDir = filteredEntries.map((entry) => {
|
||||
const absolutePath = path.resolve(baseDirAbsolute, entry.name);
|
||||
const label =
|
||||
cwd === dir ? entry.name : path.relative(cwd, absolutePath);
|
||||
const suggestionLabel = entry.isDirectory() ? label + '/' : label;
|
||||
return {
|
||||
label: suggestionLabel,
|
||||
value: escapePath(suggestionLabel),
|
||||
};
|
||||
});
|
||||
}
|
||||
fetchedSuggestions = [
|
||||
...fetchedSuggestions,
|
||||
...fetchedSuggestionsPerDir,
|
||||
];
|
||||
}
|
||||
|
||||
// Like glob, we always return forwardslashes, even in windows.
|
||||
fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
|
||||
...suggestion,
|
||||
label: suggestion.label.replace(/\\/g, '/'),
|
||||
value: suggestion.value.replace(/\\/g, '/'),
|
||||
}));
|
||||
|
||||
// Sort by depth, then directories first, then alphabetically
|
||||
fetchedSuggestions.sort((a, b) => {
|
||||
const depthA = (a.label.match(/\//g) || []).length;
|
||||
const depthB = (b.label.match(/\//g) || []).length;
|
||||
|
||||
if (depthA !== depthB) {
|
||||
return depthA - depthB;
|
||||
}
|
||||
|
||||
const aIsDir = a.label.endsWith('/');
|
||||
const bIsDir = b.label.endsWith('/');
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
|
||||
// exclude extension when comparing
|
||||
const filenameA = a.label.substring(
|
||||
0,
|
||||
a.label.length - path.extname(a.label).length,
|
||||
);
|
||||
const filenameB = b.label.substring(
|
||||
0,
|
||||
b.label.length - path.extname(b.label).length,
|
||||
);
|
||||
|
||||
return (
|
||||
filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label)
|
||||
);
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setSuggestions(fetchedSuggestions);
|
||||
setShowSuggestions(fetchedSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1);
|
||||
setVisibleStartIndex(0);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
if (isMounted) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
if (isMounted) {
|
||||
resetCompletionState();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isMounted) {
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounceTimeout = setTimeout(fetchSuggestions, 100);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearTimeout(debounceTimeout);
|
||||
};
|
||||
}, [
|
||||
buffer.text,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
buffer.lines,
|
||||
dirs,
|
||||
cwd,
|
||||
commandIndex,
|
||||
resetCompletionState,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
config,
|
||||
]);
|
||||
|
||||
const handleAutocomplete = useCallback(
|
||||
(indexToUse: number) => {
|
||||
if (indexToUse < 0 || indexToUse >= suggestions.length) {
|
||||
return;
|
||||
}
|
||||
const suggestion = suggestions[indexToUse].value;
|
||||
|
||||
if (completionStart.current === -1 || completionEnd.current === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/';
|
||||
let suggestionText = suggestion;
|
||||
if (isSlash) {
|
||||
// If we are inserting (not replacing), and the preceding character is not a space, add one.
|
||||
if (
|
||||
completionStart.current === completionEnd.current &&
|
||||
completionStart.current > commandIndex + 1 &&
|
||||
(buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' '
|
||||
) {
|
||||
suggestionText = ' ' + suggestionText;
|
||||
}
|
||||
suggestionText += ' ';
|
||||
}
|
||||
|
||||
buffer.replaceRangeByOffset(
|
||||
logicalPosToOffset(buffer.lines, cursorRow, completionStart.current),
|
||||
logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current),
|
||||
suggestionText,
|
||||
);
|
||||
resetCompletionState();
|
||||
},
|
||||
[cursorRow, resetCompletionState, buffer, suggestions, commandIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
@@ -694,11 +113,16 @@ export function useCompletion(
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
isPerfectMatch,
|
||||
setActiveSuggestionIndex,
|
||||
|
||||
setSuggestions,
|
||||
setShowSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
setVisibleStartIndex,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
handleAutocomplete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
SlashCommandProcessorResult,
|
||||
StreamingState,
|
||||
} from '../types.js';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
// --- MOCKS ---
|
||||
@@ -257,7 +256,6 @@ describe('mergePartListUnions', () => {
|
||||
// --- Tests for useGeminiStream Hook ---
|
||||
describe('useGeminiStream', () => {
|
||||
let mockAddItem: Mock;
|
||||
let mockSetShowHelp: Mock;
|
||||
let mockConfig: Config;
|
||||
let mockOnDebugMessage: Mock;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
@@ -269,7 +267,6 @@ describe('useGeminiStream', () => {
|
||||
vi.clearAllMocks(); // Clear mocks before each test
|
||||
|
||||
mockAddItem = vi.fn();
|
||||
mockSetShowHelp = vi.fn();
|
||||
// Define the mock for getGeminiClient
|
||||
const mockGetGeminiClient = vi.fn().mockImplementation(() => {
|
||||
// MockedGeminiClientClass is defined in the module scope by the previous change.
|
||||
@@ -382,7 +379,6 @@ describe('useGeminiStream', () => {
|
||||
client: any;
|
||||
history: HistoryItem[];
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
setShowHelp: Dispatch<SetStateAction<boolean>>;
|
||||
config: Config;
|
||||
onDebugMessage: (message: string) => void;
|
||||
handleSlashCommand: (
|
||||
@@ -400,7 +396,6 @@ describe('useGeminiStream', () => {
|
||||
props.client,
|
||||
props.history,
|
||||
props.addItem,
|
||||
props.setShowHelp,
|
||||
props.config,
|
||||
props.onDebugMessage,
|
||||
props.handleSlashCommand,
|
||||
@@ -417,7 +412,6 @@ describe('useGeminiStream', () => {
|
||||
client,
|
||||
history: [],
|
||||
addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'],
|
||||
setShowHelp: mockSetShowHelp,
|
||||
config: mockConfig,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
handleSlashCommand: mockHandleSlashCommand as unknown as (
|
||||
@@ -542,7 +536,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -610,7 +603,6 @@ describe('useGeminiStream', () => {
|
||||
client,
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -707,7 +699,6 @@ describe('useGeminiStream', () => {
|
||||
client,
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -810,7 +801,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -1161,7 +1151,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -1213,7 +1202,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(testConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
testConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -1262,7 +1250,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -1309,7 +1296,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -1357,7 +1343,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -1445,7 +1430,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -1500,7 +1484,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -1577,7 +1560,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
@@ -1630,7 +1612,6 @@ describe('useGeminiStream', () => {
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
|
||||
@@ -82,7 +82,6 @@ export const useGeminiStream = (
|
||||
geminiClient: GeminiClient,
|
||||
history: HistoryItem[],
|
||||
addItem: UseHistoryManagerReturn['addItem'],
|
||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
config: Config,
|
||||
onDebugMessage: (message: string) => void,
|
||||
handleSlashCommand: (
|
||||
@@ -94,6 +93,7 @@ export const useGeminiStream = (
|
||||
performMemoryRefresh: () => Promise<void>,
|
||||
modelSwitchedFromQuotaError: boolean,
|
||||
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
onEditorClose: () => void,
|
||||
) => {
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
@@ -134,6 +134,7 @@ export const useGeminiStream = (
|
||||
config,
|
||||
setPendingHistoryItem,
|
||||
getPreferredEditor,
|
||||
onEditorClose,
|
||||
);
|
||||
|
||||
const pendingToolCallGroupDisplay = useMemo(
|
||||
@@ -610,7 +611,6 @@ export const useGeminiStream = (
|
||||
return;
|
||||
|
||||
const userMessageTimestamp = Date.now();
|
||||
setShowHelp(false);
|
||||
|
||||
// Reset quota error flag when starting a new query (not a continuation)
|
||||
if (!options?.isContinuation) {
|
||||
@@ -693,7 +693,6 @@ export const useGeminiStream = (
|
||||
},
|
||||
[
|
||||
streamingState,
|
||||
setShowHelp,
|
||||
setModelSwitchedFromQuotaError,
|
||||
prepareQueryForGemini,
|
||||
processGeminiStreamEvents,
|
||||
|
||||
@@ -38,7 +38,6 @@ export const WITTY_LOADING_PHRASES = [
|
||||
'Defragmenting memories... both RAM and personal...',
|
||||
'Rebooting the humor module...',
|
||||
'Caching the essentials (mostly cat memes)...',
|
||||
'Running sudo make me a sandwich...',
|
||||
'Optimizing for ludicrous speed',
|
||||
"Swapping bits... don't tell the bytes...",
|
||||
'Garbage collecting... be right back...',
|
||||
@@ -66,12 +65,10 @@ export const WITTY_LOADING_PHRASES = [
|
||||
"Just a moment, I'm tuning the algorithms...",
|
||||
'Warp speed engaged...',
|
||||
'Mining for more Dilithium crystals...',
|
||||
"I'm Giving Her all she's got Captain!",
|
||||
"Don't panic...",
|
||||
'Following the white rabbit...',
|
||||
'The truth is in here... somewhere...',
|
||||
'Blowing on the cartridge...',
|
||||
'Looking for the princess in another castle...',
|
||||
'Loading... Do a barrel roll!',
|
||||
'Waiting for the respawn...',
|
||||
'Finishing the Kessel Run in less than 12 parsecs...',
|
||||
|
||||
@@ -70,6 +70,7 @@ export function useReactToolScheduler(
|
||||
React.SetStateAction<HistoryItemWithoutId | null>
|
||||
>,
|
||||
getPreferredEditor: () => EditorType | undefined,
|
||||
onEditorClose: () => void,
|
||||
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
|
||||
const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
|
||||
TrackedToolCall[]
|
||||
@@ -140,6 +141,7 @@ export function useReactToolScheduler(
|
||||
onToolCallsUpdate: toolCallsUpdateHandler,
|
||||
getPreferredEditor,
|
||||
config,
|
||||
onEditorClose,
|
||||
}),
|
||||
[
|
||||
config,
|
||||
@@ -147,6 +149,7 @@ export function useReactToolScheduler(
|
||||
allToolCallsCompleteHandler,
|
||||
toolCallsUpdateHandler,
|
||||
getPreferredEditor,
|
||||
onEditorClose,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useReverseSearchCompletion } from './useReverseSearchCompletion.js';
|
||||
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
||||
|
||||
describe('useReverseSearchCompletion', () => {
|
||||
function useTextBufferForTest(text: string) {
|
||||
return useTextBuffer({
|
||||
initialText: text,
|
||||
initialCursorOffset: text.length,
|
||||
viewport: { width: 80, height: 20 },
|
||||
isValidPath: () => false,
|
||||
onChange: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
describe('Core Hook Behavior', () => {
|
||||
describe('State Management', () => {
|
||||
it('should initialize with default state', () => {
|
||||
const mockShellHistory = ['echo hello'];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest(''),
|
||||
mockShellHistory,
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset state when reverseSearchActive becomes false', () => {
|
||||
const mockShellHistory = ['echo hello'];
|
||||
const { result, rerender } = renderHook(
|
||||
({ text, active }) => {
|
||||
const textBuffer = useTextBufferForTest(text);
|
||||
return useReverseSearchCompletion(
|
||||
textBuffer,
|
||||
mockShellHistory,
|
||||
active,
|
||||
);
|
||||
},
|
||||
{ initialProps: { text: 'echo', active: true } },
|
||||
);
|
||||
|
||||
// Simulate reverseSearchActive becoming false
|
||||
rerender({ text: 'echo', active: false });
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should handle navigateUp with no suggestions', () => {
|
||||
const mockShellHistory = ['echo hello'];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('grep'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle navigateDown with no suggestions', () => {
|
||||
const mockShellHistory = ['echo hello'];
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('grep'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should navigate up through suggestions with wrap-around', () => {
|
||||
const mockShellHistory = [
|
||||
'ls -l',
|
||||
'ls -la',
|
||||
'cd /some/path',
|
||||
'git status',
|
||||
'echo "Hello, World!"',
|
||||
'echo Hi',
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('echo'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(2);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should navigate down through suggestions with wrap-around', () => {
|
||||
const mockShellHistory = [
|
||||
'ls -l',
|
||||
'ls -la',
|
||||
'cd /some/path',
|
||||
'git status',
|
||||
'echo "Hello, World!"',
|
||||
'echo Hi',
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('ls'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(2);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle navigation with multiple suggestions', () => {
|
||||
const mockShellHistory = [
|
||||
'ls -l',
|
||||
'ls -la',
|
||||
'cd /some/path/l',
|
||||
'git status',
|
||||
'echo "Hello, World!"',
|
||||
'echo "Hi all"',
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('l'),
|
||||
mockShellHistory,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(5);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle navigation with large suggestion lists and scrolling', () => {
|
||||
const largeMockCommands = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `echo ${i}`,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(
|
||||
useTextBufferForTest('echo'),
|
||||
largeMockCommands,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(15);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(14);
|
||||
expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering', () => {
|
||||
it('filters history by buffer.text and sets showSuggestions', () => {
|
||||
const history = ['foo', 'barfoo', 'baz'];
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(useTextBufferForTest('foo'), history, true),
|
||||
);
|
||||
|
||||
// should only return the two entries containing "foo"
|
||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||
'foo',
|
||||
'barfoo',
|
||||
]);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
it('hides suggestions when there are no matches', () => {
|
||||
const history = ['alpha', 'beta'];
|
||||
const { result } = renderHook(() =>
|
||||
useReverseSearchCompletion(useTextBufferForTest('γ'), history, true),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
import { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
|
||||
export interface UseReverseSearchCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
activeSuggestionIndex: number;
|
||||
visibleStartIndex: number;
|
||||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
handleAutocomplete: (i: number) => void;
|
||||
resetCompletionState: () => void;
|
||||
}
|
||||
|
||||
export function useReverseSearchCompletion(
|
||||
buffer: TextBuffer,
|
||||
shellHistory: readonly string[],
|
||||
reverseSearchActive: boolean,
|
||||
): UseReverseSearchCompletionReturn {
|
||||
const {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
|
||||
setSuggestions,
|
||||
setShowSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
} = useCompletion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!reverseSearchActive) {
|
||||
resetCompletionState();
|
||||
}
|
||||
}, [reverseSearchActive, resetCompletionState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reverseSearchActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const q = buffer.text.toLowerCase();
|
||||
const matches = shellHistory.reduce<Suggestion[]>((acc, cmd) => {
|
||||
const idx = cmd.toLowerCase().indexOf(q);
|
||||
if (idx !== -1) {
|
||||
acc.push({ label: cmd, value: cmd, matchedIndex: idx });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
setSuggestions(matches);
|
||||
setShowSuggestions(matches.length > 0);
|
||||
setActiveSuggestionIndex(matches.length > 0 ? 0 : -1);
|
||||
}, [
|
||||
buffer.text,
|
||||
shellHistory,
|
||||
reverseSearchActive,
|
||||
setActiveSuggestionIndex,
|
||||
setShowSuggestions,
|
||||
setSuggestions,
|
||||
]);
|
||||
|
||||
const handleAutocomplete = useCallback(
|
||||
(i: number) => {
|
||||
if (i < 0 || i >= suggestions.length) return;
|
||||
buffer.setText(suggestions[i].value);
|
||||
resetCompletionState();
|
||||
},
|
||||
[buffer, suggestions, resetCompletionState],
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
handleAutocomplete,
|
||||
resetCompletionState,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const HISTORY_FILE = 'shell_history';
|
||||
const MAX_HISTORY_LENGTH = 100;
|
||||
|
||||
export interface UseShellHistoryReturn {
|
||||
history: string[];
|
||||
addCommandToHistory: (command: string) => void;
|
||||
getPreviousCommand: () => string | null;
|
||||
getNextCommand: () => string | null;
|
||||
@@ -24,15 +25,32 @@ async function getHistoryFilePath(projectRoot: string): Promise<string> {
|
||||
return path.join(historyDir, HISTORY_FILE);
|
||||
}
|
||||
|
||||
// Handle multiline commands
|
||||
async function readHistoryFile(filePath: string): Promise<string[]> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return content.split('\n').filter(Boolean);
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return [];
|
||||
const text = await fs.readFile(filePath, 'utf-8');
|
||||
const result: string[] = [];
|
||||
let cur = '';
|
||||
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
if (!raw.trim()) continue;
|
||||
const line = raw;
|
||||
|
||||
const m = cur.match(/(\\+)$/);
|
||||
if (m && m[1].length % 2) {
|
||||
// odd number of trailing '\'
|
||||
cur = cur.slice(0, -1) + ' ' + line;
|
||||
} else {
|
||||
if (cur) result.push(cur);
|
||||
cur = line;
|
||||
}
|
||||
}
|
||||
console.error('Error reading shell history:', error);
|
||||
|
||||
if (cur) result.push(cur);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') return [];
|
||||
console.error('Error reading history:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -101,10 +119,15 @@ export function useShellHistory(projectRoot: string): UseShellHistoryReturn {
|
||||
return history[newIndex] ?? null;
|
||||
}, [history, historyIndex]);
|
||||
|
||||
const resetHistoryPosition = useCallback(() => {
|
||||
setHistoryIndex(-1);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
history,
|
||||
addCommandToHistory,
|
||||
getPreviousCommand,
|
||||
getNextCommand,
|
||||
resetHistoryPosition: () => setHistoryIndex(-1),
|
||||
resetHistoryPosition,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { useState } from 'react';
|
||||
import { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
|
||||
// Test harness to capture the state from the hook's callbacks.
|
||||
function useTestHarnessForSlashCompletion(
|
||||
enabled: boolean,
|
||||
query: string | null,
|
||||
slashCommands: readonly SlashCommand[],
|
||||
commandContext: CommandContext,
|
||||
) {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
const [isPerfectMatch, setIsPerfectMatch] = useState(false);
|
||||
|
||||
const { completionStart, completionEnd } = useSlashCompletion({
|
||||
enabled,
|
||||
query,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
});
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
isLoadingSuggestions,
|
||||
isPerfectMatch,
|
||||
completionStart,
|
||||
completionEnd,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useSlashCompletion', () => {
|
||||
// A minimal mock is sufficient for these tests.
|
||||
const mockCommandContext = {} as CommandContext;
|
||||
|
||||
describe('Top-Level Commands', () => {
|
||||
it('should suggest all top-level commands for the root slash', async () => {
|
||||
const slashCommands = [
|
||||
{ name: 'help', altNames: ['?'], description: 'Show help' },
|
||||
{
|
||||
name: 'stats',
|
||||
altNames: ['usage'],
|
||||
description: 'check session stats. Usage: /stats [model|tools]',
|
||||
},
|
||||
{ name: 'clear', description: 'Clear the screen' },
|
||||
{
|
||||
name: 'memory',
|
||||
description: 'Manage memory',
|
||||
subCommands: [{ name: 'show', description: 'Show memory' }],
|
||||
},
|
||||
{ name: 'chat', description: 'Manage chat history' },
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(slashCommands.length);
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter commands based on partial input', async () => {
|
||||
const slashCommands = [
|
||||
{ name: 'memory', description: 'Manage memory' },
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/mem',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'memory', value: 'memory', description: 'Manage memory' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should suggest commands based on partial altNames', async () => {
|
||||
const slashCommands = [
|
||||
{
|
||||
name: 'stats',
|
||||
altNames: ['usage'],
|
||||
description: 'check session stats. Usage: /stats [model|tools]',
|
||||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/usag',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{
|
||||
label: 'stats',
|
||||
value: 'stats',
|
||||
description: 'check session stats. Usage: /stats [model|tools]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
|
||||
const slashCommands = [
|
||||
{ name: 'clear', description: 'Clear the screen', action: vi.fn() },
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/clear',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([['/?'], ['/usage']])(
|
||||
'should not suggest commands when altNames is fully typed',
|
||||
async (query) => {
|
||||
const mockSlashCommands = [
|
||||
{
|
||||
name: 'help',
|
||||
altNames: ['?'],
|
||||
description: 'Show help',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'stats',
|
||||
altNames: ['usage'],
|
||||
description: 'check session stats. Usage: /stats [model|tools]',
|
||||
action: vi.fn(),
|
||||
},
|
||||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
query,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
|
||||
const slashCommands = [
|
||||
{ name: 'clear', description: 'Clear the screen' },
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/clear ',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not provide suggestions for an unknown command', async () => {
|
||||
const slashCommands = [
|
||||
{ name: 'help', description: 'Show help' },
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/unknown-command',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sub-Commands', () => {
|
||||
it('should suggest sub-commands for a parent command', async () => {
|
||||
const slashCommands = [
|
||||
{
|
||||
name: 'memory',
|
||||
description: 'Manage memory',
|
||||
subCommands: [
|
||||
{ name: 'show', description: 'Show memory' },
|
||||
{ name: 'add', description: 'Add to memory' },
|
||||
],
|
||||
},
|
||||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/memory',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: 'show', value: 'show', description: 'Show memory' },
|
||||
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
|
||||
const slashCommands = [
|
||||
{
|
||||
name: 'memory',
|
||||
description: 'Manage memory',
|
||||
subCommands: [
|
||||
{ name: 'show', description: 'Show memory' },
|
||||
{ name: 'add', description: 'Add to memory' },
|
||||
],
|
||||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/memory ',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: 'show', value: 'show', description: 'Show memory' },
|
||||
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter sub-commands by prefix', async () => {
|
||||
const slashCommands = [
|
||||
{
|
||||
name: 'memory',
|
||||
description: 'Manage memory',
|
||||
subCommands: [
|
||||
{ name: 'show', description: 'Show memory' },
|
||||
{ name: 'add', description: 'Add to memory' },
|
||||
],
|
||||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/memory a',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should provide no suggestions for an invalid sub-command', async () => {
|
||||
const slashCommands = [
|
||||
{
|
||||
name: 'memory',
|
||||
description: 'Manage memory',
|
||||
subCommands: [
|
||||
{ name: 'show', description: 'Show memory' },
|
||||
{ name: 'add', description: 'Add to memory' },
|
||||
],
|
||||
},
|
||||
] as unknown as SlashCommand[];
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/memory dothisnow',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Argument Completion', () => {
|
||||
it('should call the command.completion function for argument suggestions', async () => {
|
||||
const availableTags = [
|
||||
'my-chat-tag-1',
|
||||
'my-chat-tag-2',
|
||||
'another-channel',
|
||||
];
|
||||
const mockCompletionFn = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (_context: CommandContext, partialArg: string) =>
|
||||
availableTags.filter((tag) => tag.startsWith(partialArg)),
|
||||
);
|
||||
|
||||
const slashCommands = [
|
||||
{
|
||||
name: 'chat',
|
||||
description: 'Manage chat history',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a saved chat',
|
||||
completion: mockCompletionFn,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/chat resume my-ch',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCompletionFn).toHaveBeenCalledWith(
|
||||
mockCommandContext,
|
||||
'my-ch',
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
|
||||
{ label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call command.completion with an empty string when args start with a space', async () => {
|
||||
const mockCompletionFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
|
||||
|
||||
const slashCommands = [
|
||||
{
|
||||
name: 'chat',
|
||||
description: 'Manage chat history',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a saved chat',
|
||||
completion: mockCompletionFn,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/chat resume ',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle completion function that returns null', async () => {
|
||||
const completionFn = vi.fn().mockResolvedValue(null);
|
||||
const slashCommands = [
|
||||
{
|
||||
name: 'chat',
|
||||
description: 'Manage chat history',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a saved chat',
|
||||
completion: completionFn,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as SlashCommand[];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/chat resume ',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
|
||||
export interface UseSlashCompletionProps {
|
||||
enabled: boolean;
|
||||
query: string | null;
|
||||
slashCommands: readonly SlashCommand[];
|
||||
commandContext: CommandContext;
|
||||
setSuggestions: (suggestions: Suggestion[]) => void;
|
||||
setIsLoadingSuggestions: (isLoading: boolean) => void;
|
||||
setIsPerfectMatch: (isMatch: boolean) => void;
|
||||
}
|
||||
|
||||
export function useSlashCompletion(props: UseSlashCompletionProps): {
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
} {
|
||||
const {
|
||||
enabled,
|
||||
query,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
} = props;
|
||||
const [completionStart, setCompletionStart] = useState(-1);
|
||||
const [completionEnd, setCompletionEnd] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || query === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = query?.substring(1) || '';
|
||||
const hasTrailingSpace = !!query?.endsWith(' ');
|
||||
const rawParts = fullPath.split(/\s+/).filter((p) => p);
|
||||
let commandPathParts = rawParts;
|
||||
let partial = '';
|
||||
|
||||
if (!hasTrailingSpace && rawParts.length > 0) {
|
||||
partial = rawParts[rawParts.length - 1];
|
||||
commandPathParts = rawParts.slice(0, -1);
|
||||
}
|
||||
|
||||
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
|
||||
let leafCommand: SlashCommand | null = null;
|
||||
|
||||
for (const part of commandPathParts) {
|
||||
if (!currentLevel) {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
const found: SlashCommand | undefined = currentLevel.find(
|
||||
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
|
||||
);
|
||||
if (found) {
|
||||
leafCommand = found;
|
||||
currentLevel = found.subCommands as readonly SlashCommand[] | undefined;
|
||||
} else {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let exactMatchAsParent: SlashCommand | undefined;
|
||||
if (!hasTrailingSpace && currentLevel) {
|
||||
exactMatchAsParent = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.subCommands,
|
||||
);
|
||||
|
||||
if (exactMatchAsParent) {
|
||||
leafCommand = exactMatchAsParent;
|
||||
currentLevel = exactMatchAsParent.subCommands;
|
||||
partial = '';
|
||||
}
|
||||
}
|
||||
|
||||
setIsPerfectMatch(false);
|
||||
if (!hasTrailingSpace) {
|
||||
if (leafCommand && partial === '' && leafCommand.action) {
|
||||
setIsPerfectMatch(true);
|
||||
} else if (currentLevel) {
|
||||
const perfectMatch = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.action,
|
||||
);
|
||||
if (perfectMatch) {
|
||||
setIsPerfectMatch(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const depth = commandPathParts.length;
|
||||
const isArgumentCompletion =
|
||||
leafCommand?.completion &&
|
||||
(hasTrailingSpace ||
|
||||
(rawParts.length > depth && depth > 0 && partial !== ''));
|
||||
|
||||
if (hasTrailingSpace || exactMatchAsParent) {
|
||||
setCompletionStart(query.length);
|
||||
setCompletionEnd(query.length);
|
||||
} else if (partial) {
|
||||
if (isArgumentCompletion) {
|
||||
const commandSoFar = `/${commandPathParts.join(' ')}`;
|
||||
const argStartIndex =
|
||||
commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
|
||||
setCompletionStart(argStartIndex);
|
||||
} else {
|
||||
setCompletionStart(query.length - partial.length);
|
||||
}
|
||||
setCompletionEnd(query.length);
|
||||
} else {
|
||||
setCompletionStart(1);
|
||||
setCompletionEnd(query.length);
|
||||
}
|
||||
|
||||
if (isArgumentCompletion) {
|
||||
const fetchAndSetSuggestions = async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
const argString = rawParts.slice(depth).join(' ');
|
||||
const results =
|
||||
(await leafCommand!.completion!(commandContext, argString)) || [];
|
||||
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
|
||||
setSuggestions(finalSuggestions);
|
||||
setIsLoadingSuggestions(false);
|
||||
};
|
||||
fetchAndSetSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
const commandsToSearch = currentLevel || [];
|
||||
if (commandsToSearch.length > 0) {
|
||||
let potentialSuggestions = commandsToSearch.filter(
|
||||
(cmd) =>
|
||||
cmd.description &&
|
||||
(cmd.name.startsWith(partial) ||
|
||||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
|
||||
);
|
||||
|
||||
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
|
||||
const perfectMatch = potentialSuggestions.find(
|
||||
(s) => s.name === partial || s.altNames?.includes(partial),
|
||||
);
|
||||
if (perfectMatch && perfectMatch.action) {
|
||||
potentialSuggestions = [];
|
||||
}
|
||||
}
|
||||
|
||||
const finalSuggestions = potentialSuggestions.map((cmd) => ({
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuggestions([]);
|
||||
}, [
|
||||
enabled,
|
||||
query,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
setIsPerfectMatch,
|
||||
]);
|
||||
|
||||
return {
|
||||
completionStart,
|
||||
completionEnd,
|
||||
};
|
||||
}
|
||||
@@ -1203,7 +1203,9 @@ describe('useVim hook', () => {
|
||||
});
|
||||
|
||||
// Press escape to clear pending state
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput({ name: 'escape' });
|
||||
});
|
||||
|
||||
// Now 'w' should just move cursor, not delete
|
||||
act(() => {
|
||||
@@ -1215,6 +1217,69 @@ describe('useVim hook', () => {
|
||||
expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NORMAL mode escape behavior', () => {
|
||||
it('should pass escape through when no pending operator is active', () => {
|
||||
mockVimContext.vimMode = 'NORMAL';
|
||||
const { result } = renderVimHook();
|
||||
|
||||
const handled = result.current.handleInput({ name: 'escape' });
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle escape and clear pending operator', () => {
|
||||
mockVimContext.vimMode = 'NORMAL';
|
||||
const { result } = renderVimHook();
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput({ sequence: 'd' });
|
||||
});
|
||||
|
||||
let handled: boolean | undefined;
|
||||
act(() => {
|
||||
handled = result.current.handleInput({ name: 'escape' });
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell command pass-through', () => {
|
||||
it('should pass through ctrl+r in INSERT mode', () => {
|
||||
mockVimContext.vimMode = 'INSERT';
|
||||
const { result } = renderVimHook();
|
||||
|
||||
const handled = result.current.handleInput({ name: 'r', ctrl: true });
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass through ! in INSERT mode when buffer is empty', () => {
|
||||
mockVimContext.vimMode = 'INSERT';
|
||||
const emptyBuffer = createMockBuffer('');
|
||||
const { result } = renderVimHook(emptyBuffer);
|
||||
|
||||
const handled = result.current.handleInput({ sequence: '!' });
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle ! as input in INSERT mode when buffer is not empty', () => {
|
||||
mockVimContext.vimMode = 'INSERT';
|
||||
const nonEmptyBuffer = createMockBuffer('not empty');
|
||||
const { result } = renderVimHook(nonEmptyBuffer);
|
||||
const key = { sequence: '!', name: '!' };
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(key);
|
||||
});
|
||||
|
||||
expect(nonEmptyBuffer.handleInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining(key),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Line operations (dd, cc) are tested in text-buffer.test.ts
|
||||
|
||||
@@ -260,7 +260,8 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
normalizedKey.name === 'tab' ||
|
||||
(normalizedKey.name === 'return' && !normalizedKey.ctrl) ||
|
||||
normalizedKey.name === 'up' ||
|
||||
normalizedKey.name === 'down'
|
||||
normalizedKey.name === 'down' ||
|
||||
(normalizedKey.ctrl && normalizedKey.name === 'r')
|
||||
) {
|
||||
return false; // Let InputPrompt handle completion
|
||||
}
|
||||
@@ -270,6 +271,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
return false; // Let InputPrompt handle clipboard functionality
|
||||
}
|
||||
|
||||
// Let InputPrompt handle shell commands
|
||||
if (normalizedKey.sequence === '!' && buffer.text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Special handling for Enter key to allow command submission (lower priority than completion)
|
||||
if (
|
||||
normalizedKey.name === 'return' &&
|
||||
@@ -399,10 +405,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
|
||||
// Handle NORMAL mode
|
||||
if (state.mode === 'NORMAL') {
|
||||
// Handle Escape key in NORMAL mode - clear all pending states
|
||||
// If in NORMAL mode, allow escape to pass through to other handlers
|
||||
// if there's no pending operation.
|
||||
if (normalizedKey.name === 'escape') {
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
return true; // Handled by vim
|
||||
if (state.pendingOperator) {
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
return true; // Handled by vim
|
||||
}
|
||||
return false; // Pass through to other handlers
|
||||
}
|
||||
|
||||
// Handle count input (numbers 1-9, and 0 if count > 0)
|
||||
|
||||
@@ -97,6 +97,11 @@ export type HistoryItemAbout = HistoryItemBase & {
|
||||
gcpProject: string;
|
||||
};
|
||||
|
||||
export type HistoryItemHelp = HistoryItemBase & {
|
||||
type: 'help';
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
export type HistoryItemStats = HistoryItemBase & {
|
||||
type: 'stats';
|
||||
duration: string;
|
||||
@@ -142,6 +147,7 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemInfo
|
||||
| HistoryItemError
|
||||
| HistoryItemAbout
|
||||
| HistoryItemHelp
|
||||
| HistoryItemToolGroup
|
||||
| HistoryItemStats
|
||||
| HistoryItemModelStats
|
||||
@@ -157,6 +163,7 @@ export enum MessageType {
|
||||
ERROR = 'error',
|
||||
USER = 'user',
|
||||
ABOUT = 'about',
|
||||
HELP = 'help',
|
||||
STATS = 'stats',
|
||||
MODEL_STATS = 'model_stats',
|
||||
TOOL_STATS = 'tool_stats',
|
||||
@@ -183,6 +190,11 @@ export type Message =
|
||||
gcpProject: string;
|
||||
content?: string; // Optional content, not really used for ABOUT
|
||||
}
|
||||
| {
|
||||
type: MessageType.HELP;
|
||||
timestamp: Date;
|
||||
content?: string; // Optional content, not really used for HELP
|
||||
}
|
||||
| {
|
||||
type: MessageType.STATS;
|
||||
timestamp: Date;
|
||||
|
||||
@@ -8,8 +8,9 @@ import util from 'util';
|
||||
import { ConsoleMessageItem } from '../types.js';
|
||||
|
||||
interface ConsolePatcherParams {
|
||||
onNewMessage: (message: Omit<ConsoleMessageItem, 'id'>) => void;
|
||||
onNewMessage?: (message: Omit<ConsoleMessageItem, 'id'>) => void;
|
||||
debugMode: boolean;
|
||||
stderr?: boolean;
|
||||
}
|
||||
|
||||
export class ConsolePatcher {
|
||||
@@ -46,16 +47,22 @@ export class ConsolePatcher {
|
||||
originalMethod: (...args: unknown[]) => void,
|
||||
) =>
|
||||
(...args: unknown[]) => {
|
||||
if (this.params.debugMode) {
|
||||
originalMethod.apply(console, args);
|
||||
}
|
||||
if (this.params.stderr) {
|
||||
if (type !== 'debug' || this.params.debugMode) {
|
||||
this.originalConsoleError(this.formatArgs(args));
|
||||
}
|
||||
} else {
|
||||
if (this.params.debugMode) {
|
||||
originalMethod.apply(console, args);
|
||||
}
|
||||
|
||||
if (type !== 'debug' || this.params.debugMode) {
|
||||
this.params.onNewMessage({
|
||||
type,
|
||||
content: this.formatArgs(args),
|
||||
count: 1,
|
||||
});
|
||||
if (type !== 'debug' || this.params.debugMode) {
|
||||
this.params.onNewMessage?.({
|
||||
type,
|
||||
content: this.formatArgs(args),
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,6 +91,14 @@ describe('handleAutoUpdate', () => {
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing if update nag is disabled', () => {
|
||||
mockSettings.merged.disableUpdateNag = true;
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
expect(mockGetInstallationInfo).not.toHaveBeenCalled();
|
||||
expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit "update-received" but not update if auto-updates are disabled', () => {
|
||||
mockSettings.merged.disableAutoUpdate = true;
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
|
||||
@@ -22,6 +22,10 @@ export function handleAutoUpdate(
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.merged.disableUpdateNag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installationInfo = getInstallationInfo(
|
||||
projectRoot,
|
||||
settings.merged.disableAutoUpdate ?? false,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
export function resolvePath(p: string): string {
|
||||
if (!p) {
|
||||
return '';
|
||||
}
|
||||
let expandedPath = p;
|
||||
if (p.toLowerCase().startsWith('%userprofile%')) {
|
||||
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
|
||||
} else if (p === '~' || p.startsWith('~/')) {
|
||||
expandedPath = os.homedir() + p.substring(1);
|
||||
}
|
||||
return path.normalize(expandedPath);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.18",
|
||||
"description": "Gemini CLI Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,6 +34,7 @@
|
||||
"chardet": "^2.1.0",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fdir": "^6.4.6",
|
||||
"glob": "^10.4.5",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
@@ -42,6 +43,7 @@
|
||||
"marked": "^15.0.12",
|
||||
"micromatch": "^4.0.8",
|
||||
"open": "^10.1.2",
|
||||
"picomatch": "^4.0.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
@@ -49,10 +51,12 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/micromatch": "^4.0.8",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from '../core/contentGenerator.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
import { GitService } from '../services/gitService.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
@@ -120,7 +119,6 @@ describe('Server Config (config.ts)', () => {
|
||||
telemetry: TELEMETRY_SETTINGS,
|
||||
sessionId: SESSION_ID,
|
||||
model: MODEL,
|
||||
ideClient: IdeClient.getInstance(false),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -184,6 +182,87 @@ describe('Server Config (config.ts)', () => {
|
||||
// Verify that fallback mode is reset
|
||||
expect(config.isInFallbackMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve conversation history when refreshing auth', async () => {
|
||||
const config = new Config(baseParams);
|
||||
const authType = AuthType.USE_GEMINI;
|
||||
const mockContentConfig = {
|
||||
model: 'gemini-pro',
|
||||
apiKey: 'test-key',
|
||||
};
|
||||
|
||||
(createContentGeneratorConfig as Mock).mockReturnValue(mockContentConfig);
|
||||
|
||||
// Mock the existing client with some history
|
||||
const mockExistingHistory = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there!' }] },
|
||||
{ role: 'user', parts: [{ text: 'How are you?' }] },
|
||||
];
|
||||
|
||||
const mockExistingClient = {
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
getHistory: vi.fn().mockReturnValue(mockExistingHistory),
|
||||
};
|
||||
|
||||
const mockNewClient = {
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
setHistory: vi.fn(),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Set the existing client
|
||||
(
|
||||
config as unknown as { geminiClient: typeof mockExistingClient }
|
||||
).geminiClient = mockExistingClient;
|
||||
(GeminiClient as Mock).mockImplementation(() => mockNewClient);
|
||||
|
||||
await config.refreshAuth(authType);
|
||||
|
||||
// Verify that existing history was retrieved
|
||||
expect(mockExistingClient.getHistory).toHaveBeenCalled();
|
||||
|
||||
// Verify that new client was created and initialized
|
||||
expect(GeminiClient).toHaveBeenCalledWith(config);
|
||||
expect(mockNewClient.initialize).toHaveBeenCalledWith(mockContentConfig);
|
||||
|
||||
// Verify that history was restored to the new client
|
||||
expect(mockNewClient.setHistory).toHaveBeenCalledWith(
|
||||
mockExistingHistory,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle case when no existing client is initialized', async () => {
|
||||
const config = new Config(baseParams);
|
||||
const authType = AuthType.USE_GEMINI;
|
||||
const mockContentConfig = {
|
||||
model: 'gemini-pro',
|
||||
apiKey: 'test-key',
|
||||
};
|
||||
|
||||
(createContentGeneratorConfig as Mock).mockReturnValue(mockContentConfig);
|
||||
|
||||
const mockNewClient = {
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
setHistory: vi.fn(),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// No existing client
|
||||
(config as unknown as { geminiClient: null }).geminiClient = null;
|
||||
(GeminiClient as Mock).mockImplementation(() => mockNewClient);
|
||||
|
||||
await config.refreshAuth(authType);
|
||||
|
||||
// Verify that new client was created and initialized
|
||||
expect(GeminiClient).toHaveBeenCalledWith(config);
|
||||
expect(mockNewClient.initialize).toHaveBeenCalledWith(mockContentConfig);
|
||||
|
||||
// Verify that setHistory was not called since there was no existing history
|
||||
expect(mockNewClient.setHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('Config constructor should store userMemory correctly', () => {
|
||||
|
||||
@@ -47,6 +47,9 @@ import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'
|
||||
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||
import { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import { logIdeConnection } from '../telemetry/loggers.js';
|
||||
import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js';
|
||||
|
||||
// Re-export OAuth config type
|
||||
export type { MCPOAuthConfig };
|
||||
@@ -186,7 +189,7 @@ export interface ConfigParameters {
|
||||
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
|
||||
ideModeFeature?: boolean;
|
||||
ideMode?: boolean;
|
||||
ideClient: IdeClient;
|
||||
loadMemoryFromIncludeDirectories?: boolean;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -246,6 +249,7 @@ export class Config {
|
||||
| Record<string, SummarizeToolOutputSettings>
|
||||
| undefined;
|
||||
private readonly experimentalAcp: boolean = false;
|
||||
private readonly loadMemoryFromIncludeDirectories: boolean = false;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
@@ -301,8 +305,14 @@ export class Config {
|
||||
this.noBrowser = params.noBrowser ?? false;
|
||||
this.summarizeToolOutput = params.summarizeToolOutput;
|
||||
this.ideModeFeature = params.ideModeFeature ?? false;
|
||||
this.ideMode = params.ideMode ?? true;
|
||||
this.ideClient = params.ideClient;
|
||||
this.ideMode = params.ideMode ?? false;
|
||||
this.ideClient = IdeClient.getInstance();
|
||||
if (this.ideMode && this.ideModeFeature) {
|
||||
this.ideClient.connect();
|
||||
logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.START));
|
||||
}
|
||||
this.loadMemoryFromIncludeDirectories =
|
||||
params.loadMemoryFromIncludeDirectories ?? false;
|
||||
|
||||
if (params.contextFileName) {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
@@ -332,13 +342,30 @@ export class Config {
|
||||
}
|
||||
|
||||
async refreshAuth(authMethod: AuthType) {
|
||||
this.contentGeneratorConfig = createContentGeneratorConfig(
|
||||
// Save the current conversation history before creating a new client
|
||||
let existingHistory: Content[] = [];
|
||||
if (this.geminiClient && this.geminiClient.isInitialized()) {
|
||||
existingHistory = this.geminiClient.getHistory();
|
||||
}
|
||||
|
||||
// Create new content generator config
|
||||
const newContentGeneratorConfig = createContentGeneratorConfig(
|
||||
this,
|
||||
authMethod,
|
||||
);
|
||||
|
||||
this.geminiClient = new GeminiClient(this);
|
||||
await this.geminiClient.initialize(this.contentGeneratorConfig);
|
||||
// Create and initialize new client in local variable first
|
||||
const newGeminiClient = new GeminiClient(this);
|
||||
await newGeminiClient.initialize(newContentGeneratorConfig);
|
||||
|
||||
// Only assign to instance properties after successful initialization
|
||||
this.contentGeneratorConfig = newContentGeneratorConfig;
|
||||
this.geminiClient = newGeminiClient;
|
||||
|
||||
// Restore the conversation history to the new client
|
||||
if (existingHistory.length > 0) {
|
||||
this.geminiClient.setHistory(existingHistory);
|
||||
}
|
||||
|
||||
// Reset the session flag since we're explicitly changing auth and using default model
|
||||
this.inFallbackMode = false;
|
||||
@@ -348,6 +375,10 @@ export class Config {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
shouldLoadMemoryFromIncludeDirectories(): boolean {
|
||||
return this.loadMemoryFromIncludeDirectories;
|
||||
}
|
||||
|
||||
getContentGeneratorConfig(): ContentGeneratorConfig {
|
||||
return this.contentGeneratorConfig;
|
||||
}
|
||||
@@ -607,10 +638,6 @@ export class Config {
|
||||
return this.ideModeFeature;
|
||||
}
|
||||
|
||||
getIdeClient(): IdeClient {
|
||||
return this.ideClient;
|
||||
}
|
||||
|
||||
getIdeMode(): boolean {
|
||||
return this.ideMode;
|
||||
}
|
||||
@@ -619,12 +646,18 @@ export class Config {
|
||||
this.ideMode = value;
|
||||
}
|
||||
|
||||
setIdeClientDisconnected(): void {
|
||||
this.ideClient.setDisconnected();
|
||||
async setIdeModeAndSyncConnection(value: boolean): Promise<void> {
|
||||
this.ideMode = value;
|
||||
if (value) {
|
||||
await this.ideClient.connect();
|
||||
logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.SESSION));
|
||||
} else {
|
||||
this.ideClient.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
setIdeClientConnected(): void {
|
||||
this.ideClient.reconnect(this.ideMode && this.ideModeFeature);
|
||||
getIdeClient(): IdeClient {
|
||||
return this.ideClient;
|
||||
}
|
||||
|
||||
async getGitService(): Promise<GitService> {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Config } from './config.js';
|
||||
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
vi.mock('node:fs');
|
||||
@@ -26,7 +26,6 @@ describe('Flash Model Fallback Configuration', () => {
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
ideClient: IdeClient.getInstance(false),
|
||||
});
|
||||
|
||||
// Initialize contentGeneratorConfig for testing
|
||||
@@ -51,7 +50,6 @@ describe('Flash Model Fallback Configuration', () => {
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
ideClient: IdeClient.getInstance(false),
|
||||
});
|
||||
|
||||
// Should not crash when contentGeneratorConfig is undefined
|
||||
@@ -75,7 +73,6 @@ describe('Flash Model Fallback Configuration', () => {
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: 'custom-model',
|
||||
ideClient: IdeClient.getInstance(false),
|
||||
});
|
||||
|
||||
expect(newConfig.getModel()).toBe('custom-model');
|
||||
|
||||
@@ -136,6 +136,7 @@ describe('CoreToolScheduler', () => {
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -205,6 +206,7 @@ describe('CoreToolScheduler with payload', () => {
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -482,6 +484,7 @@ describe('CoreToolScheduler edit cancellation', () => {
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -571,6 +574,7 @@ describe('CoreToolScheduler YOLO mode', () => {
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -224,6 +224,7 @@ interface CoreToolSchedulerOptions {
|
||||
onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||
getPreferredEditor: () => EditorType | undefined;
|
||||
config: Config;
|
||||
onEditorClose: () => void;
|
||||
}
|
||||
|
||||
export class CoreToolScheduler {
|
||||
@@ -234,6 +235,7 @@ export class CoreToolScheduler {
|
||||
private onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||
private getPreferredEditor: () => EditorType | undefined;
|
||||
private config: Config;
|
||||
private onEditorClose: () => void;
|
||||
|
||||
constructor(options: CoreToolSchedulerOptions) {
|
||||
this.config = options.config;
|
||||
@@ -242,6 +244,7 @@ export class CoreToolScheduler {
|
||||
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
|
||||
this.onToolCallsUpdate = options.onToolCallsUpdate;
|
||||
this.getPreferredEditor = options.getPreferredEditor;
|
||||
this.onEditorClose = options.onEditorClose;
|
||||
}
|
||||
|
||||
private setStatusInternal(
|
||||
@@ -563,6 +566,7 @@ export class CoreToolScheduler {
|
||||
modifyContext as ModifyContext<typeof waitingToolCall.request.args>,
|
||||
editorType,
|
||||
signal,
|
||||
this.onEditorClose,
|
||||
);
|
||||
this.setArgsInternal(callId, updatedParams);
|
||||
this.setStatusInternal(callId, 'awaiting_approval', {
|
||||
|
||||
@@ -11,9 +11,12 @@ export enum DetectedIde {
|
||||
export function getIdeDisplayName(ide: DetectedIde): string {
|
||||
switch (ide) {
|
||||
case DetectedIde.VSCode:
|
||||
return 'VSCode';
|
||||
default:
|
||||
throw new Error(`Unsupported IDE: ${ide}`);
|
||||
return 'VS Code';
|
||||
default: {
|
||||
// This ensures that if a new IDE is added to the enum, we get a compile-time error.
|
||||
const exhaustiveCheck: never = ide;
|
||||
return exhaustiveCheck;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+133
-132
@@ -33,154 +33,38 @@ export enum IDEConnectionStatus {
|
||||
* Manages the connection to and interaction with the IDE server.
|
||||
*/
|
||||
export class IdeClient {
|
||||
client: Client | undefined = undefined;
|
||||
private static instance: IdeClient;
|
||||
private client: Client | undefined = undefined;
|
||||
private state: IDEConnectionState = {
|
||||
status: IDEConnectionStatus.Disconnected,
|
||||
details:
|
||||
'IDE integration is currently disabled. To enable it, run /ide enable.',
|
||||
};
|
||||
private static instance: IdeClient;
|
||||
private readonly currentIde: DetectedIde | undefined;
|
||||
private readonly currentIdeDisplayName: string | undefined;
|
||||
|
||||
constructor(ideMode: boolean) {
|
||||
private constructor() {
|
||||
this.currentIde = detectIde();
|
||||
if (this.currentIde) {
|
||||
this.currentIdeDisplayName = getIdeDisplayName(this.currentIde);
|
||||
}
|
||||
if (!ideMode) {
|
||||
return;
|
||||
}
|
||||
this.init().catch((err) => {
|
||||
logger.debug('Failed to initialize IdeClient:', err);
|
||||
});
|
||||
}
|
||||
|
||||
static getInstance(ideMode: boolean): IdeClient {
|
||||
static getInstance(): IdeClient {
|
||||
if (!IdeClient.instance) {
|
||||
IdeClient.instance = new IdeClient(ideMode);
|
||||
IdeClient.instance = new IdeClient();
|
||||
}
|
||||
return IdeClient.instance;
|
||||
}
|
||||
|
||||
getCurrentIde(): DetectedIde | undefined {
|
||||
return this.currentIde;
|
||||
}
|
||||
|
||||
getConnectionStatus(): IDEConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private setState(status: IDEConnectionStatus, details?: string) {
|
||||
this.state = { status, details };
|
||||
|
||||
if (status === IDEConnectionStatus.Disconnected) {
|
||||
logger.debug('IDE integration is disconnected. ', details);
|
||||
ideContext.clearIdeContext();
|
||||
}
|
||||
}
|
||||
|
||||
private getPortFromEnv(): string | undefined {
|
||||
const port = process.env['GEMINI_CLI_IDE_SERVER_PORT'];
|
||||
if (!port) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
'Gemini CLI Companion extension not found. Install via /ide install and restart the CLI in a fresh terminal window.',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
private validateWorkspacePath(): boolean {
|
||||
const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||
if (!ideWorkspacePath) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
'IDE integration requires a single workspace folder to be open in the IDE. Please ensure one folder is open and try again.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (ideWorkspacePath !== process.cwd()) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`Gemini CLI is running in a different directory (${process.cwd()}) from the IDE's open workspace (${ideWorkspacePath}). Please run Gemini CLI in the same directory.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private registerClientHandlers() {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.client.setNotificationHandler(
|
||||
IdeContextNotificationSchema,
|
||||
(notification) => {
|
||||
ideContext.setIdeContext(notification.params);
|
||||
},
|
||||
);
|
||||
|
||||
this.client.onerror = (_error) => {
|
||||
this.setState(IDEConnectionStatus.Disconnected, 'Client error.');
|
||||
};
|
||||
|
||||
this.client.onclose = () => {
|
||||
this.setState(IDEConnectionStatus.Disconnected, 'Connection closed.');
|
||||
};
|
||||
}
|
||||
|
||||
async reconnect(ideMode: boolean) {
|
||||
IdeClient.instance = new IdeClient(ideMode);
|
||||
}
|
||||
|
||||
private async establishConnection(port: string) {
|
||||
let transport: StreamableHTTPClientTransport | undefined;
|
||||
try {
|
||||
this.client = new Client({
|
||||
name: 'streamable-http-client',
|
||||
// TODO(#3487): use the CLI version here.
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://localhost:${port}/mcp`),
|
||||
);
|
||||
|
||||
this.registerClientHandlers();
|
||||
|
||||
await this.client.connect(transport);
|
||||
|
||||
this.setState(IDEConnectionStatus.Connected);
|
||||
} catch (error) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`Failed to connect to IDE server: ${error}`,
|
||||
);
|
||||
if (transport) {
|
||||
try {
|
||||
await transport.close();
|
||||
} catch (closeError) {
|
||||
logger.debug('Failed to close transport:', closeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.state.status === IDEConnectionStatus.Connected) {
|
||||
return;
|
||||
}
|
||||
if (!this.currentIde) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
'Not running in a supported IDE, skipping connection.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.setState(IDEConnectionStatus.Connecting);
|
||||
|
||||
if (!this.currentIde || !this.currentIdeDisplayName) {
|
||||
this.setState(IDEConnectionStatus.Disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateWorkspacePath()) {
|
||||
return;
|
||||
}
|
||||
@@ -193,15 +77,132 @@ export class IdeClient {
|
||||
await this.establishConnection(port);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
disconnect() {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
'IDE integration disabled. To enable it again, run /ide enable.',
|
||||
);
|
||||
this.client?.close();
|
||||
}
|
||||
|
||||
getCurrentIde(): DetectedIde | undefined {
|
||||
return this.currentIde;
|
||||
}
|
||||
|
||||
getConnectionStatus(): IDEConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getDetectedIdeDisplayName(): string | undefined {
|
||||
return this.currentIdeDisplayName;
|
||||
}
|
||||
|
||||
setDisconnected() {
|
||||
this.setState(IDEConnectionStatus.Disconnected);
|
||||
private setState(status: IDEConnectionStatus, details?: string) {
|
||||
const isAlreadyDisconnected =
|
||||
this.state.status === IDEConnectionStatus.Disconnected &&
|
||||
status === IDEConnectionStatus.Disconnected;
|
||||
|
||||
// Only update details if the state wasn't already disconnected, so that
|
||||
// the first detail message is preserved.
|
||||
if (!isAlreadyDisconnected) {
|
||||
this.state = { status, details };
|
||||
}
|
||||
|
||||
if (status === IDEConnectionStatus.Disconnected) {
|
||||
logger.debug('IDE integration disconnected:', details);
|
||||
ideContext.clearIdeContext();
|
||||
}
|
||||
}
|
||||
|
||||
private validateWorkspacePath(): boolean {
|
||||
const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||
if (ideWorkspacePath === undefined) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (ideWorkspacePath === '') {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`To use this feature, please open a single workspace folder in ${this.currentIdeDisplayName} and try again.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (ideWorkspacePath !== process.cwd()) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`Directory mismatch. Gemini CLI is running in a different location than the open workspace in ${this.currentIdeDisplayName}. Please run the CLI from the same directory as your project's root folder.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private getPortFromEnv(): string | undefined {
|
||||
const port = process.env['GEMINI_CLI_IDE_SERVER_PORT'];
|
||||
if (!port) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
private registerClientHandlers() {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.client.setNotificationHandler(
|
||||
IdeContextNotificationSchema,
|
||||
(notification) => {
|
||||
ideContext.setIdeContext(notification.params);
|
||||
},
|
||||
);
|
||||
this.client.onerror = (_error) => {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
|
||||
);
|
||||
};
|
||||
this.client.onclose = () => {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private async establishConnection(port: string) {
|
||||
let transport: StreamableHTTPClientTransport | undefined;
|
||||
try {
|
||||
this.client = new Client({
|
||||
name: 'streamable-http-client',
|
||||
// TODO(#3487): use the CLI version here.
|
||||
version: '1.0.0',
|
||||
});
|
||||
transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://localhost:${port}/mcp`),
|
||||
);
|
||||
await this.client.connect(transport);
|
||||
this.registerClientHandlers();
|
||||
this.setState(IDEConnectionStatus.Connected);
|
||||
} catch (_error) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`,
|
||||
);
|
||||
if (transport) {
|
||||
try {
|
||||
await transport.close();
|
||||
} catch (closeError) {
|
||||
logger.debug('Failed to close transport:', closeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,32 +45,6 @@ describe('ide-installer', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isInstalled', () => {
|
||||
it('should return true if command is in PATH', async () => {
|
||||
expect(await installer.isInstalled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if command is in a known location', async () => {
|
||||
vi.spyOn(child_process, 'execSync').mockImplementation(() => {
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
// Re-create the installer so it re-runs findVsCodeCommand
|
||||
installer = getIdeInstaller(DetectedIde.VSCode)!;
|
||||
expect(await installer.isInstalled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if command is not found', async () => {
|
||||
vi.spyOn(child_process, 'execSync').mockImplementation(() => {
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
// Re-create the installer so it re-runs findVsCodeCommand
|
||||
installer = getIdeInstaller(DetectedIde.VSCode)!;
|
||||
expect(await installer.isInstalled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install', () => {
|
||||
it('should return a failure message if VS Code is not installed', async () => {
|
||||
vi.spyOn(child_process, 'execSync').mockImplementation(() => {
|
||||
@@ -81,9 +55,7 @@ describe('ide-installer', () => {
|
||||
installer = getIdeInstaller(DetectedIde.VSCode)!;
|
||||
const result = await installer.install();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'not found in your PATH or common installation locations',
|
||||
);
|
||||
expect(result.message).toContain('VS Code CLI not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion';
|
||||
|
||||
export interface IdeInstaller {
|
||||
install(): Promise<InstallResult>;
|
||||
isInstalled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface InstallResult {
|
||||
@@ -95,16 +94,12 @@ class VsCodeInstaller implements IdeInstaller {
|
||||
this.vsCodeCommand = findVsCodeCommand();
|
||||
}
|
||||
|
||||
async isInstalled(): Promise<boolean> {
|
||||
return (await this.vsCodeCommand) !== null;
|
||||
}
|
||||
|
||||
async install(): Promise<InstallResult> {
|
||||
const commandPath = await this.vsCodeCommand;
|
||||
if (!commandPath) {
|
||||
return {
|
||||
success: false,
|
||||
message: `VS Code command-line tool not found in your PATH or common installation locations.`,
|
||||
message: `VS Code CLI not found. Please ensure 'code' is in your system's PATH. For help, see https://code.visualstudio.com/docs/configure/command-line#_code-is-not-recognized-as-an-internal-or-external-command. You can also install the companion extension manually from the VS Code marketplace.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,12 +136,12 @@ class VsCodeInstaller implements IdeInstaller {
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
|
||||
'VS Code companion extension was installed successfully. Please restart your terminal to complete the setup.',
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to install VS Code companion extension.',
|
||||
message: `Failed to install VS Code companion extension. Please try installing it manually from the VS Code marketplace.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -154,7 +149,7 @@ class VsCodeInstaller implements IdeInstaller {
|
||||
|
||||
export function getIdeInstaller(ide: DetectedIde): IdeInstaller | null {
|
||||
switch (ide) {
|
||||
case 'vscode':
|
||||
case DetectedIde.VSCode:
|
||||
return new VsCodeInstaller();
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -40,6 +40,7 @@ export * from './utils/shell-utils.js';
|
||||
export * from './utils/systemEncoding.js';
|
||||
export * from './utils/textUtils.js';
|
||||
export * from './utils/formatters.js';
|
||||
export * from './utils/filesearch/fileSearch.js';
|
||||
|
||||
// Export services
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
|
||||
@@ -4,7 +4,17 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock dependencies AT THE TOP
|
||||
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../utils/secure-browser-launcher.js', () => ({
|
||||
openBrowserSecurely: mockOpenBrowserSecurely,
|
||||
}));
|
||||
vi.mock('node:crypto');
|
||||
vi.mock('./oauth-token-storage.js');
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as http from 'node:http';
|
||||
import * as crypto from 'node:crypto';
|
||||
import {
|
||||
@@ -15,14 +25,6 @@ import {
|
||||
} from './oauth-provider.js';
|
||||
import { MCPOAuthTokenStorage, MCPOAuthToken } from './oauth-token-storage.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../utils/secure-browser-launcher.js', () => ({
|
||||
openBrowserSecurely: mockOpenBrowserSecurely,
|
||||
}));
|
||||
vi.mock('node:crypto');
|
||||
vi.mock('./oauth-token-storage.js');
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
@@ -46,6 +48,7 @@ describe('MCPOAuthProvider', () => {
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
scopes: ['read', 'write'],
|
||||
redirectUri: 'http://localhost:7777/oauth/callback',
|
||||
audiences: ['https://api.example.com'],
|
||||
};
|
||||
|
||||
const mockToken: MCPOAuthToken = {
|
||||
@@ -720,6 +723,105 @@ describe('MCPOAuthProvider', () => {
|
||||
expect(capturedUrl!).toContain('code_challenge_method=S256');
|
||||
expect(capturedUrl!).toContain('scope=read+write');
|
||||
expect(capturedUrl!).toContain('resource=https%3A%2F%2Fauth.example.com');
|
||||
expect(capturedUrl!).toContain('audience=https%3A%2F%2Fapi.example.com');
|
||||
});
|
||||
|
||||
it('should correctly append parameters to an authorization URL that already has query params', async () => {
|
||||
// Mock to capture the URL that would be opened
|
||||
let capturedUrl: string;
|
||||
mockOpenBrowserSecurely.mockImplementation((url: string) => {
|
||||
capturedUrl = url;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
let callbackHandler: unknown;
|
||||
vi.mocked(http.createServer).mockImplementation((handler) => {
|
||||
callbackHandler = handler;
|
||||
return mockHttpServer as unknown as http.Server;
|
||||
});
|
||||
|
||||
mockHttpServer.listen.mockImplementation((port, callback) => {
|
||||
callback?.();
|
||||
setTimeout(() => {
|
||||
const mockReq = {
|
||||
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
||||
};
|
||||
const mockRes = {
|
||||
writeHead: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
(callbackHandler as (req: unknown, res: unknown) => void)(
|
||||
mockReq,
|
||||
mockRes,
|
||||
);
|
||||
}, 10);
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse),
|
||||
});
|
||||
|
||||
const configWithParamsInUrl = {
|
||||
...mockConfig,
|
||||
authorizationUrl: 'https://auth.example.com/authorize?audience=1234',
|
||||
};
|
||||
|
||||
await MCPOAuthProvider.authenticate('test-server', configWithParamsInUrl);
|
||||
|
||||
const url = new URL(capturedUrl!);
|
||||
expect(url.searchParams.get('audience')).toBe('1234');
|
||||
expect(url.searchParams.get('client_id')).toBe('test-client-id');
|
||||
expect(url.search.startsWith('?audience=1234&')).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly append parameters to a URL with a fragment', async () => {
|
||||
// Mock to capture the URL that would be opened
|
||||
let capturedUrl: string;
|
||||
mockOpenBrowserSecurely.mockImplementation((url: string) => {
|
||||
capturedUrl = url;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
let callbackHandler: unknown;
|
||||
vi.mocked(http.createServer).mockImplementation((handler) => {
|
||||
callbackHandler = handler;
|
||||
return mockHttpServer as unknown as http.Server;
|
||||
});
|
||||
|
||||
mockHttpServer.listen.mockImplementation((port, callback) => {
|
||||
callback?.();
|
||||
setTimeout(() => {
|
||||
const mockReq = {
|
||||
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
||||
};
|
||||
const mockRes = {
|
||||
writeHead: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
(callbackHandler as (req: unknown, res: unknown) => void)(
|
||||
mockReq,
|
||||
mockRes,
|
||||
);
|
||||
}, 10);
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse),
|
||||
});
|
||||
|
||||
const configWithFragment = {
|
||||
...mockConfig,
|
||||
authorizationUrl: 'https://auth.example.com/authorize#login',
|
||||
};
|
||||
|
||||
await MCPOAuthProvider.authenticate('test-server', configWithFragment);
|
||||
|
||||
const url = new URL(capturedUrl!);
|
||||
expect(url.searchParams.get('client_id')).toBe('test-client-id');
|
||||
expect(url.hash).toBe('#login');
|
||||
expect(url.pathname).toBe('/authorize');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface MCPOAuthConfig {
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
scopes?: string[];
|
||||
audiences?: string[];
|
||||
redirectUri?: string;
|
||||
tokenParamName?: string; // For SSE connections, specifies the query parameter name for the token
|
||||
}
|
||||
@@ -297,6 +298,10 @@ export class MCPOAuthProvider {
|
||||
params.append('scope', config.scopes.join(' '));
|
||||
}
|
||||
|
||||
if (config.audiences && config.audiences.length > 0) {
|
||||
params.append('audience', config.audiences.join(' '));
|
||||
}
|
||||
|
||||
// Add resource parameter for MCP OAuth spec compliance
|
||||
// Use the MCP server URL if provided, otherwise fall back to authorization URL
|
||||
const resourceUrl = mcpServerUrl || config.authorizationUrl!;
|
||||
@@ -308,7 +313,11 @@ export class MCPOAuthProvider {
|
||||
);
|
||||
}
|
||||
|
||||
return `${config.authorizationUrl}?${params.toString()}`;
|
||||
const url = new URL(config.authorizationUrl!);
|
||||
params.forEach((value, key) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,6 +351,10 @@ export class MCPOAuthProvider {
|
||||
params.append('client_secret', config.clientSecret);
|
||||
}
|
||||
|
||||
if (config.audiences && config.audiences.length > 0) {
|
||||
params.append('audience', config.audiences.join(' '));
|
||||
}
|
||||
|
||||
// Add resource parameter for MCP OAuth spec compliance
|
||||
// Use the MCP server URL if provided, otherwise fall back to token URL
|
||||
const resourceUrl = mcpServerUrl || config.tokenUrl!;
|
||||
@@ -400,6 +413,10 @@ export class MCPOAuthProvider {
|
||||
params.append('scope', config.scopes.join(' '));
|
||||
}
|
||||
|
||||
if (config.audiences && config.audiences.length > 0) {
|
||||
params.append('audience', config.audiences.join(' '));
|
||||
}
|
||||
|
||||
// Add resource parameter for MCP OAuth spec compliance
|
||||
// Use the MCP server URL if provided, otherwise fall back to token URL
|
||||
const resourceUrl = mcpServerUrl || tokenUrl;
|
||||
|
||||
@@ -53,4 +53,22 @@ export class PromptRegistry {
|
||||
}
|
||||
return serverPrompts.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all the prompts from the registry.
|
||||
*/
|
||||
clear(): void {
|
||||
this.prompts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all prompts from a specific server.
|
||||
*/
|
||||
removePromptsByServer(serverName: string): void {
|
||||
for (const [name, prompt] of this.prompts.entries()) {
|
||||
if (prompt.serverName === serverName) {
|
||||
this.prompts.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
NextSpeakerCheckEvent,
|
||||
SlashCommandEvent,
|
||||
MalformedJsonResponseEvent,
|
||||
IdeConnectionEvent,
|
||||
} from '../types.js';
|
||||
import { EventMetadataKey } from './event-metadata-key.js';
|
||||
import { Config } from '../../config/config.js';
|
||||
@@ -44,6 +45,7 @@ const loop_detected_event_name = 'loop_detected';
|
||||
const next_speaker_check_event_name = 'next_speaker_check';
|
||||
const slash_command_event_name = 'slash_command';
|
||||
const malformed_json_response_event_name = 'malformed_json_response';
|
||||
const ide_connection_event_name = 'ide_connection';
|
||||
|
||||
export interface LogResponse {
|
||||
nextRequestWaitMs?: number;
|
||||
@@ -235,7 +237,11 @@ export class ClearcutLogger {
|
||||
}
|
||||
|
||||
logStartSessionEvent(event: StartSessionEvent): void {
|
||||
const surface = process.env.SURFACE || 'SURFACE_NOT_SET';
|
||||
const surface =
|
||||
process.env.CLOUD_SHELL === 'true'
|
||||
? 'CLOUD_SHELL'
|
||||
: process.env.SURFACE || 'SURFACE_NOT_SET';
|
||||
|
||||
const data = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_MODEL,
|
||||
@@ -574,6 +580,18 @@ export class ClearcutLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logIdeConnectionEvent(event: IdeConnectionEvent): void {
|
||||
const data = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_IDE_CONNECTION_TYPE,
|
||||
value: JSON.stringify(event.connection_type),
|
||||
},
|
||||
];
|
||||
|
||||
this.enqueueLogEvent(this.createLogEvent(ide_connection_event_name, data));
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logEndSessionEvent(event: EndSessionEvent): void {
|
||||
const data = [
|
||||
{
|
||||
|
||||
@@ -190,6 +190,13 @@ export enum EventMetadataKey {
|
||||
|
||||
// Logs the model that produced the malformed JSON response.
|
||||
GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL = 45,
|
||||
|
||||
// ==========================================================================
|
||||
// IDE Connection Event Keys
|
||||
// ===========================================================================
|
||||
|
||||
// Logs the type of the IDE connection.
|
||||
GEMINI_CLI_IDE_CONNECTION_TYPE = 46,
|
||||
}
|
||||
|
||||
export function getEventMetadataKey(
|
||||
|
||||
@@ -15,6 +15,7 @@ export const EVENT_CLI_CONFIG = 'gemini_cli.config';
|
||||
export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback';
|
||||
export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check';
|
||||
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
|
||||
export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection';
|
||||
|
||||
export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count';
|
||||
export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
EVENT_API_REQUEST,
|
||||
EVENT_API_RESPONSE,
|
||||
EVENT_CLI_CONFIG,
|
||||
EVENT_IDE_CONNECTION,
|
||||
EVENT_TOOL_CALL,
|
||||
EVENT_USER_PROMPT,
|
||||
EVENT_FLASH_FALLBACK,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
ApiErrorEvent,
|
||||
ApiRequestEvent,
|
||||
ApiResponseEvent,
|
||||
IdeConnectionEvent,
|
||||
StartSessionEvent,
|
||||
ToolCallEvent,
|
||||
UserPromptEvent,
|
||||
@@ -355,3 +357,24 @@ export function logSlashCommand(
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logIdeConnection(
|
||||
config: Config,
|
||||
event: IdeConnectionEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logIdeConnectionEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
...event,
|
||||
'event.name': EVENT_IDE_CONNECTION,
|
||||
};
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: `Ide connection. Type: ${event.connection_type}.`,
|
||||
attributes,
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from './sdk.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
|
||||
vi.mock('@opentelemetry/sdk-node');
|
||||
vi.mock('../config/config.js');
|
||||
@@ -30,7 +29,6 @@ describe('telemetry', () => {
|
||||
targetDir: '/test/dir',
|
||||
debugMode: false,
|
||||
cwd: '/test/dir',
|
||||
ideClient: IdeClient.getInstance(false),
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
|
||||
|
||||
@@ -308,6 +308,23 @@ export class MalformedJsonResponseEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export enum IdeConnectionType {
|
||||
START = 'start',
|
||||
SESSION = 'session',
|
||||
}
|
||||
|
||||
export class IdeConnectionEvent {
|
||||
'event.name': 'ide_connection';
|
||||
'event.timestamp': string; // ISO 8601
|
||||
connection_type: IdeConnectionType;
|
||||
|
||||
constructor(connection_type: IdeConnectionType) {
|
||||
this['event.name'] = 'ide_connection';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.connection_type = connection_type;
|
||||
}
|
||||
}
|
||||
|
||||
export type TelemetryEvent =
|
||||
| StartSessionEvent
|
||||
| EndSessionEvent
|
||||
@@ -320,4 +337,5 @@ export type TelemetryEvent =
|
||||
| LoopDetectedEvent
|
||||
| NextSpeakerCheckEvent
|
||||
| SlashCommandEvent
|
||||
| MalformedJsonResponseEvent;
|
||||
| MalformedJsonResponseEvent
|
||||
| IdeConnectionEvent;
|
||||
|
||||
@@ -58,9 +58,7 @@ describe('mcp-client', () => {
|
||||
const mockedClient = {} as unknown as ClientLib.Client;
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {
|
||||
// no-op
|
||||
});
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const testError = new Error('Invalid tool name');
|
||||
vi.mocked(DiscoveredMCPTool).mockImplementation(
|
||||
@@ -113,12 +111,17 @@ describe('mcp-client', () => {
|
||||
{ name: 'prompt2' },
|
||||
],
|
||||
});
|
||||
const mockGetServerCapabilities = vi.fn().mockReturnValue({
|
||||
prompts: {},
|
||||
});
|
||||
const mockedClient = {
|
||||
getServerCapabilities: mockGetServerCapabilities,
|
||||
request: mockRequest,
|
||||
} as unknown as ClientLib.Client;
|
||||
|
||||
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
|
||||
|
||||
expect(mockGetServerCapabilities).toHaveBeenCalledOnce();
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
{ method: 'prompts/list', params: {} },
|
||||
expect.anything(),
|
||||
@@ -129,37 +132,67 @@ describe('mcp-client', () => {
|
||||
const mockRequest = vi.fn().mockResolvedValue({
|
||||
prompts: [],
|
||||
});
|
||||
const mockGetServerCapabilities = vi.fn().mockReturnValue({
|
||||
prompts: {},
|
||||
});
|
||||
|
||||
const mockedClient = {
|
||||
getServerCapabilities: mockGetServerCapabilities,
|
||||
request: mockRequest,
|
||||
} as unknown as ClientLib.Client;
|
||||
|
||||
const consoleLogSpy = vi
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation(() => {
|
||||
// no-op
|
||||
});
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
|
||||
|
||||
expect(mockGetServerCapabilities).toHaveBeenCalledOnce();
|
||||
expect(mockRequest).toHaveBeenCalledOnce();
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should do nothing if the server has no prompt support', async () => {
|
||||
const mockRequest = vi.fn().mockResolvedValue({
|
||||
prompts: [],
|
||||
});
|
||||
const mockGetServerCapabilities = vi.fn().mockReturnValue({});
|
||||
|
||||
const mockedClient = {
|
||||
getServerCapabilities: mockGetServerCapabilities,
|
||||
request: mockRequest,
|
||||
} as unknown as ClientLib.Client;
|
||||
|
||||
const consoleLogSpy = vi
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
|
||||
|
||||
expect(mockGetServerCapabilities).toHaveBeenCalledOnce();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should log an error if discovery fails', async () => {
|
||||
const testError = new Error('test error');
|
||||
testError.message = 'test error';
|
||||
const mockRequest = vi.fn().mockRejectedValue(testError);
|
||||
const mockGetServerCapabilities = vi.fn().mockReturnValue({
|
||||
prompts: {},
|
||||
});
|
||||
const mockedClient = {
|
||||
getServerCapabilities: mockGetServerCapabilities,
|
||||
request: mockRequest,
|
||||
} as unknown as ClientLib.Client;
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {
|
||||
// no-op
|
||||
});
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
|
||||
|
||||
|
||||
@@ -366,33 +366,47 @@ export async function connectAndDiscover(
|
||||
): Promise<void> {
|
||||
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING);
|
||||
|
||||
let mcpClient: Client | undefined;
|
||||
try {
|
||||
const mcpClient = await connectToMcpServer(
|
||||
mcpClient = await connectToMcpServer(
|
||||
mcpServerName,
|
||||
mcpServerConfig,
|
||||
debugMode,
|
||||
);
|
||||
try {
|
||||
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTED);
|
||||
mcpClient.onerror = (error) => {
|
||||
console.error(`MCP ERROR (${mcpServerName}):`, error.toString());
|
||||
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
|
||||
};
|
||||
await discoverPrompts(mcpServerName, mcpClient, promptRegistry);
|
||||
|
||||
const tools = await discoverTools(
|
||||
mcpServerName,
|
||||
mcpServerConfig,
|
||||
mcpClient,
|
||||
);
|
||||
for (const tool of tools) {
|
||||
toolRegistry.registerTool(tool);
|
||||
}
|
||||
} catch (error) {
|
||||
mcpClient.close();
|
||||
throw error;
|
||||
mcpClient.onerror = (error) => {
|
||||
console.error(`MCP ERROR (${mcpServerName}):`, error.toString());
|
||||
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
|
||||
};
|
||||
|
||||
// Attempt to discover both prompts and tools
|
||||
const prompts = await discoverPrompts(
|
||||
mcpServerName,
|
||||
mcpClient,
|
||||
promptRegistry,
|
||||
);
|
||||
const tools = await discoverTools(
|
||||
mcpServerName,
|
||||
mcpServerConfig,
|
||||
mcpClient,
|
||||
);
|
||||
|
||||
// If we have neither prompts nor tools, it's a failed discovery
|
||||
if (prompts.length === 0 && tools.length === 0) {
|
||||
throw new Error('No prompts or tools found on the server.');
|
||||
}
|
||||
|
||||
// If we found anything, the server is connected
|
||||
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTED);
|
||||
|
||||
// Register any discovered tools
|
||||
for (const tool of tools) {
|
||||
toolRegistry.registerTool(tool);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mcpClient) {
|
||||
mcpClient.close();
|
||||
}
|
||||
console.error(
|
||||
`Error connecting to MCP server '${mcpServerName}': ${getErrorMessage(
|
||||
error,
|
||||
@@ -423,7 +437,8 @@ export async function discoverTools(
|
||||
const tool = await mcpCallableTool.tool();
|
||||
|
||||
if (!Array.isArray(tool.functionDeclarations)) {
|
||||
throw new Error(`Server did not return valid function declarations.`);
|
||||
// This is a valid case for a prompt-only server
|
||||
return [];
|
||||
}
|
||||
|
||||
const discoveredTools: DiscoveredMCPTool[] = [];
|
||||
@@ -454,7 +469,17 @@ export async function discoverTools(
|
||||
}
|
||||
return discoveredTools;
|
||||
} catch (error) {
|
||||
throw new Error(`Error discovering tools: ${error}`);
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message?.includes('Method not found')
|
||||
) {
|
||||
console.error(
|
||||
`Error discovering tools from ${mcpServerName}: ${getErrorMessage(
|
||||
error,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,8 +494,11 @@ export async function discoverPrompts(
|
||||
mcpServerName: string,
|
||||
mcpClient: Client,
|
||||
promptRegistry: PromptRegistry,
|
||||
): Promise<void> {
|
||||
): Promise<Prompt[]> {
|
||||
try {
|
||||
// Only request prompts if the server supports them.
|
||||
if (mcpClient.getServerCapabilities()?.prompts == null) return [];
|
||||
|
||||
const response = await mcpClient.request(
|
||||
{ method: 'prompts/list', params: {} },
|
||||
ListPromptsResultSchema,
|
||||
@@ -484,6 +512,7 @@ export async function discoverPrompts(
|
||||
invokeMcpPrompt(mcpServerName, mcpClient, prompt.name, params),
|
||||
});
|
||||
}
|
||||
return response.prompts;
|
||||
} catch (error) {
|
||||
// It's okay if this fails, not all servers will have prompts.
|
||||
// Don't log an error if the method is not found, which is a common case.
|
||||
@@ -497,6 +526,7 @@ export async function discoverPrompts(
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,8 +131,11 @@ describe('DiscoveredMCPTool', () => {
|
||||
success: true,
|
||||
details: 'executed',
|
||||
};
|
||||
const mockFunctionResponseContent: Part[] = [
|
||||
{ text: JSON.stringify(mockToolSuccessResultObject) },
|
||||
const mockFunctionResponseContent = [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(mockToolSuccessResultObject),
|
||||
},
|
||||
];
|
||||
const mockMcpToolResponseParts: Part[] = [
|
||||
{
|
||||
@@ -149,11 +152,13 @@ describe('DiscoveredMCPTool', () => {
|
||||
expect(mockCallTool).toHaveBeenCalledWith([
|
||||
{ name: serverToolName, args: params },
|
||||
]);
|
||||
expect(toolResult.llmContent).toEqual(mockMcpToolResponseParts);
|
||||
|
||||
const stringifiedResponseContent = JSON.stringify(
|
||||
mockToolSuccessResultObject,
|
||||
);
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: stringifiedResponseContent },
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(stringifiedResponseContent);
|
||||
});
|
||||
|
||||
@@ -170,6 +175,9 @@ describe('DiscoveredMCPTool', () => {
|
||||
mockCallTool.mockResolvedValue(mockMcpToolResponsePartsEmpty);
|
||||
const toolResult: ToolResult = await tool.execute(params);
|
||||
expect(toolResult.returnDisplay).toBe('```json\n[]\n```');
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: '[Error: Could not parse tool response]' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should propagate rejection if mcpTool.callTool rejects', async () => {
|
||||
@@ -186,6 +194,361 @@ describe('DiscoveredMCPTool', () => {
|
||||
|
||||
await expect(tool.execute(params)).rejects.toThrow(expectedError);
|
||||
});
|
||||
|
||||
it('should handle a simple text response correctly', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { query: 'test' };
|
||||
const successMessage = 'This is a success message.';
|
||||
|
||||
// Simulate the response from the GenAI SDK, which wraps the MCP
|
||||
// response in a functionResponse Part.
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
// The `content` array contains MCP ContentBlocks.
|
||||
content: [{ type: 'text', text: successMessage }],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
// 1. Assert that the llmContent sent to the scheduler is a clean Part array.
|
||||
expect(toolResult.llmContent).toEqual([{ text: successMessage }]);
|
||||
|
||||
// 2. Assert that the display output is the simple text message.
|
||||
expect(toolResult.returnDisplay).toBe(successMessage);
|
||||
|
||||
// 3. Verify that the underlying callTool was made correctly.
|
||||
expect(mockCallTool).toHaveBeenCalledWith([
|
||||
{ name: serverToolName, args: params },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle an AudioBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'play' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{
|
||||
type: 'audio',
|
||||
data: 'BASE64_AUDIO_DATA',
|
||||
mimeType: 'audio/mp3',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{
|
||||
text: `[Tool '${serverToolName}' provided the following audio data with mime-type: audio/mp3]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'audio/mp3',
|
||||
data: 'BASE64_AUDIO_DATA',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe('[Audio: audio/mp3]');
|
||||
});
|
||||
|
||||
it('should handle a ResourceLinkBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { resource: 'get' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///path/to/thing',
|
||||
name: 'resource-name',
|
||||
title: 'My Resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{
|
||||
text: 'Resource Link: My Resource at file:///path/to/thing',
|
||||
},
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'[Link to My Resource: file:///path/to/thing]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an embedded text ResourceBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { resource: 'get' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///path/to/text.txt',
|
||||
text: 'This is the text content.',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: 'This is the text content.' },
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe('This is the text content.');
|
||||
});
|
||||
|
||||
it('should handle an embedded binary ResourceBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { resource: 'get' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///path/to/data.bin',
|
||||
blob: 'BASE64_BINARY_DATA',
|
||||
mimeType: 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{
|
||||
text: `[Tool '${serverToolName}' provided the following embedded resource with mime-type: application/octet-stream]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'application/octet-stream',
|
||||
data: 'BASE64_BINARY_DATA',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'[Embedded Resource: application/octet-stream]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a mix of content block types', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'complex' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{ type: 'text', text: 'First part.' },
|
||||
{
|
||||
type: 'image',
|
||||
data: 'BASE64_IMAGE_DATA',
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
{ type: 'text', text: 'Second part.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: 'First part.' },
|
||||
{
|
||||
text: `[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'BASE64_IMAGE_DATA',
|
||||
},
|
||||
},
|
||||
{ text: 'Second part.' },
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'First part.\n[Image: image/jpeg]\nSecond part.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore unknown content block types', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'test' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Valid part.' },
|
||||
{ type: 'future_block', data: 'some-data' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([{ text: 'Valid part.' }]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'Valid part.\n[Unknown content type: future_block]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a complex mix of content block types', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'super-complex' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: serverToolName,
|
||||
response: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Here is a resource.' },
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///path/to/resource',
|
||||
name: 'resource-name',
|
||||
title: 'My Resource',
|
||||
},
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///path/to/text.txt',
|
||||
text: 'Embedded text content.',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
data: 'BASE64_IMAGE_DATA',
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: 'Here is a resource.' },
|
||||
{
|
||||
text: 'Resource Link: My Resource at file:///path/to/resource',
|
||||
},
|
||||
{ text: 'Embedded text content.' },
|
||||
{
|
||||
text: `[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'BASE64_IMAGE_DATA',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
'Here is a resource.\n[Link to My Resource: file:///path/to/resource]\nEmbedded text content.\n[Image: image/jpeg]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldConfirmExecute', () => {
|
||||
|
||||
@@ -22,6 +22,40 @@ import {
|
||||
|
||||
type ToolParams = Record<string, unknown>;
|
||||
|
||||
// Discriminated union for MCP Content Blocks to ensure type safety.
|
||||
type McpTextBlock = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
type McpMediaBlock = {
|
||||
type: 'image' | 'audio';
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
type McpResourceBlock = {
|
||||
type: 'resource';
|
||||
resource: {
|
||||
text?: string;
|
||||
blob?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type McpResourceLinkBlock = {
|
||||
type: 'resource_link';
|
||||
uri: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type McpContentBlock =
|
||||
| McpTextBlock
|
||||
| McpMediaBlock
|
||||
| McpResourceBlock
|
||||
| McpResourceLinkBlock;
|
||||
|
||||
export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
|
||||
private static readonly allowlist: Set<string> = new Set();
|
||||
|
||||
@@ -114,70 +148,145 @@ export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
|
||||
},
|
||||
];
|
||||
|
||||
const responseParts: Part[] = await this.mcpTool.callTool(functionCalls);
|
||||
const rawResponseParts = await this.mcpTool.callTool(functionCalls);
|
||||
const transformedParts = transformMcpContentToParts(rawResponseParts);
|
||||
|
||||
return {
|
||||
llmContent: responseParts,
|
||||
returnDisplay: getStringifiedResultForDisplay(responseParts),
|
||||
llmContent: transformedParts,
|
||||
returnDisplay: getStringifiedResultForDisplay(rawResponseParts),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an array of `Part` objects, primarily from a tool's execution result,
|
||||
* to generate a user-friendly string representation, typically for display in a CLI.
|
||||
*
|
||||
* The `result` array can contain various types of `Part` objects:
|
||||
* 1. `FunctionResponse` parts:
|
||||
* - If the `response.content` of a `FunctionResponse` is an array consisting solely
|
||||
* of `TextPart` objects, their text content is concatenated into a single string.
|
||||
* This is to present simple textual outputs directly.
|
||||
* - If `response.content` is an array but contains other types of `Part` objects (or a mix),
|
||||
* the `content` array itself is preserved. This handles structured data like JSON objects or arrays
|
||||
* returned by a tool.
|
||||
* - If `response.content` is not an array or is missing, the entire `functionResponse`
|
||||
* object is preserved.
|
||||
* 2. Other `Part` types (e.g., `TextPart` directly in the `result` array):
|
||||
* - These are preserved as is.
|
||||
*
|
||||
* All processed parts are then collected into an array, which is JSON.stringify-ed
|
||||
* with indentation and wrapped in a markdown JSON code block.
|
||||
*/
|
||||
function getStringifiedResultForDisplay(result: Part[]) {
|
||||
if (!result || result.length === 0) {
|
||||
return '```json\n[]\n```';
|
||||
function transformTextBlock(block: McpTextBlock): Part {
|
||||
return { text: block.text };
|
||||
}
|
||||
|
||||
function transformImageAudioBlock(
|
||||
block: McpMediaBlock,
|
||||
toolName: string,
|
||||
): Part[] {
|
||||
return [
|
||||
{
|
||||
text: `[Tool '${toolName}' provided the following ${
|
||||
block.type
|
||||
} data with mime-type: ${block.mimeType}]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: block.mimeType,
|
||||
data: block.data,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function transformResourceBlock(
|
||||
block: McpResourceBlock,
|
||||
toolName: string,
|
||||
): Part | Part[] | null {
|
||||
const resource = block.resource;
|
||||
if (resource?.text) {
|
||||
return { text: resource.text };
|
||||
}
|
||||
if (resource?.blob) {
|
||||
const mimeType = resource.mimeType || 'application/octet-stream';
|
||||
return [
|
||||
{
|
||||
text: `[Tool '${toolName}' provided the following embedded resource with mime-type: ${mimeType}]`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType,
|
||||
data: resource.blob,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const processFunctionResponse = (part: Part) => {
|
||||
if (part.functionResponse) {
|
||||
const responseContent = part.functionResponse.response?.content;
|
||||
if (responseContent && Array.isArray(responseContent)) {
|
||||
// Check if all parts in responseContent are simple TextParts
|
||||
const allTextParts = responseContent.every(
|
||||
(p: Part) => p.text !== undefined,
|
||||
);
|
||||
if (allTextParts) {
|
||||
return responseContent.map((p: Part) => p.text).join('');
|
||||
}
|
||||
// If not all simple text parts, return the array of these content parts for JSON stringification
|
||||
return responseContent;
|
||||
}
|
||||
|
||||
// If no content, or not an array, or not a functionResponse, stringify the whole functionResponse part for inspection
|
||||
return part.functionResponse;
|
||||
}
|
||||
return part; // Fallback for unexpected structure or non-FunctionResponsePart
|
||||
function transformResourceLinkBlock(block: McpResourceLinkBlock): Part {
|
||||
return {
|
||||
text: `Resource Link: ${block.title || block.name} at ${block.uri}`,
|
||||
};
|
||||
}
|
||||
|
||||
const processedResults =
|
||||
result.length === 1
|
||||
? processFunctionResponse(result[0])
|
||||
: result.map(processFunctionResponse);
|
||||
if (typeof processedResults === 'string') {
|
||||
return processedResults;
|
||||
/**
|
||||
* Transforms the raw MCP content blocks from the SDK response into a
|
||||
* standard GenAI Part array.
|
||||
* @param sdkResponse The raw Part[] array from `mcpTool.callTool()`.
|
||||
* @returns A clean Part[] array ready for the scheduler.
|
||||
*/
|
||||
function transformMcpContentToParts(sdkResponse: Part[]): Part[] {
|
||||
const funcResponse = sdkResponse?.[0]?.functionResponse;
|
||||
const mcpContent = funcResponse?.response?.content as McpContentBlock[];
|
||||
const toolName = funcResponse?.name || 'unknown tool';
|
||||
|
||||
if (!Array.isArray(mcpContent)) {
|
||||
return [{ text: '[Error: Could not parse tool response]' }];
|
||||
}
|
||||
|
||||
return '```json\n' + JSON.stringify(processedResults, null, 2) + '\n```';
|
||||
const transformed = mcpContent.flatMap(
|
||||
(block: McpContentBlock): Part | Part[] | null => {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return transformTextBlock(block);
|
||||
case 'image':
|
||||
case 'audio':
|
||||
return transformImageAudioBlock(block, toolName);
|
||||
case 'resource':
|
||||
return transformResourceBlock(block, toolName);
|
||||
case 'resource_link':
|
||||
return transformResourceLinkBlock(block);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return transformed.filter((part): part is Part => part !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the raw response from the MCP tool to generate a clean,
|
||||
* human-readable string for display in the CLI. It summarizes non-text
|
||||
* content and presents text directly.
|
||||
*
|
||||
* @param rawResponse The raw Part[] array from the GenAI SDK.
|
||||
* @returns A formatted string representing the tool's output.
|
||||
*/
|
||||
function getStringifiedResultForDisplay(rawResponse: Part[]): string {
|
||||
const mcpContent = rawResponse?.[0]?.functionResponse?.response
|
||||
?.content as McpContentBlock[];
|
||||
|
||||
if (!Array.isArray(mcpContent)) {
|
||||
return '```json\n' + JSON.stringify(rawResponse, null, 2) + '\n```';
|
||||
}
|
||||
|
||||
const displayParts = mcpContent.map((block: McpContentBlock): string => {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return block.text;
|
||||
case 'image':
|
||||
return `[Image: ${block.mimeType}]`;
|
||||
case 'audio':
|
||||
return `[Audio: ${block.mimeType}]`;
|
||||
case 'resource_link':
|
||||
return `[Link to ${block.title || block.name}: ${block.uri}]`;
|
||||
case 'resource':
|
||||
if (block.resource?.text) {
|
||||
return block.resource.text;
|
||||
}
|
||||
return `[Embedded Resource: ${
|
||||
block.resource?.mimeType || 'unknown type'
|
||||
}]`;
|
||||
default:
|
||||
return `[Unknown content type: ${(block as { type: string }).type}]`;
|
||||
}
|
||||
});
|
||||
|
||||
return displayParts.join('\n');
|
||||
}
|
||||
|
||||
/** Visible for testing */
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith(
|
||||
@@ -148,6 +149,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
const stats = await fsp.stat(diffDir);
|
||||
@@ -165,6 +167,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mkdirSpy).not.toHaveBeenCalled();
|
||||
@@ -183,6 +186,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockCreatePatch).toHaveBeenCalledWith(
|
||||
@@ -211,6 +215,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockCreatePatch).toHaveBeenCalledWith(
|
||||
@@ -241,6 +246,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
),
|
||||
).rejects.toThrow('Editor failed to open');
|
||||
|
||||
@@ -267,6 +273,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
@@ -290,6 +297,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockOpenDiff).toHaveBeenCalledOnce();
|
||||
@@ -311,6 +319,7 @@ describe('modifyWithEditor', () => {
|
||||
mockModifyContext,
|
||||
'vscode' as EditorType,
|
||||
abortSignal,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mockOpenDiff).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -138,6 +138,7 @@ export async function modifyWithEditor<ToolParams>(
|
||||
modifyContext: ModifyContext<ToolParams>,
|
||||
editorType: EditorType,
|
||||
_abortSignal: AbortSignal,
|
||||
onEditorClose: () => void,
|
||||
): Promise<ModifyResult<ToolParams>> {
|
||||
const currentContent = await modifyContext.getCurrentContent(originalParams);
|
||||
const proposedContent =
|
||||
@@ -150,7 +151,7 @@ export async function modifyWithEditor<ToolParams>(
|
||||
);
|
||||
|
||||
try {
|
||||
await openDiff(oldPath, newPath, editorType);
|
||||
await openDiff(oldPath, newPath, editorType, onEditorClose);
|
||||
const result = getUpdatedParams(
|
||||
oldPath,
|
||||
newPath,
|
||||
|
||||
@@ -477,4 +477,139 @@ describe('ReadManyFilesTool', () => {
|
||||
fs.rmSync(tempDir2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Processing', () => {
|
||||
const createMultipleFiles = (count: number, contentPrefix = 'Content') => {
|
||||
const files: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const fileName = `file${i}.txt`;
|
||||
createFile(fileName, `${contentPrefix} ${i}`);
|
||||
files.push(fileName);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const createFile = (filePath: string, content = '') => {
|
||||
const fullPath = path.join(tempRootDir, filePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
};
|
||||
|
||||
it('should process files in parallel for performance', async () => {
|
||||
// Mock detectFileType to add artificial delay to simulate I/O
|
||||
const detectFileTypeSpy = vi.spyOn(
|
||||
await import('../utils/fileUtils.js'),
|
||||
'detectFileType',
|
||||
);
|
||||
|
||||
// Create files
|
||||
const fileCount = 4;
|
||||
const files = createMultipleFiles(fileCount, 'Batch test');
|
||||
|
||||
// Mock with 100ms delay per file to simulate I/O operations
|
||||
detectFileTypeSpy.mockImplementation(async (_filePath: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return 'text';
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const params = { paths: files };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const endTime = Date.now();
|
||||
|
||||
const processingTime = endTime - startTime;
|
||||
|
||||
console.log(
|
||||
`Processing time: ${processingTime}ms for ${fileCount} files`,
|
||||
);
|
||||
|
||||
// Verify parallel processing performance improvement
|
||||
// Parallel processing should complete in ~100ms (single file time)
|
||||
// Sequential would take ~400ms (4 files × 100ms each)
|
||||
expect(processingTime).toBeLessThan(200); // Should PASS with parallel implementation
|
||||
|
||||
// Verify all files were processed
|
||||
const content = result.llmContent as string[];
|
||||
expect(content).toHaveLength(fileCount);
|
||||
|
||||
// Cleanup mock
|
||||
detectFileTypeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle batch processing errors gracefully', async () => {
|
||||
// Create mix of valid and problematic files
|
||||
createFile('valid1.txt', 'Valid content 1');
|
||||
createFile('valid2.txt', 'Valid content 2');
|
||||
createFile('valid3.txt', 'Valid content 3');
|
||||
|
||||
const params = {
|
||||
paths: [
|
||||
'valid1.txt',
|
||||
'valid2.txt',
|
||||
'nonexistent-file.txt', // This will fail
|
||||
'valid3.txt',
|
||||
],
|
||||
};
|
||||
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
|
||||
// Should successfully process valid files despite one failure
|
||||
expect(content.length).toBeGreaterThanOrEqual(3);
|
||||
expect(result.returnDisplay).toContain('Successfully read');
|
||||
|
||||
// Verify valid files were processed
|
||||
const expectedPath1 = path.join(tempRootDir, 'valid1.txt');
|
||||
const expectedPath3 = path.join(tempRootDir, 'valid3.txt');
|
||||
expect(content.some((c) => c.includes(expectedPath1))).toBe(true);
|
||||
expect(content.some((c) => c.includes(expectedPath3))).toBe(true);
|
||||
});
|
||||
|
||||
it('should execute file operations concurrently', async () => {
|
||||
// Track execution order to verify concurrency
|
||||
const executionOrder: string[] = [];
|
||||
const detectFileTypeSpy = vi.spyOn(
|
||||
await import('../utils/fileUtils.js'),
|
||||
'detectFileType',
|
||||
);
|
||||
|
||||
const files = ['file1.txt', 'file2.txt', 'file3.txt'];
|
||||
files.forEach((file) => createFile(file, 'test content'));
|
||||
|
||||
// Mock to track concurrent vs sequential execution
|
||||
detectFileTypeSpy.mockImplementation(async (filePath: string) => {
|
||||
const fileName = filePath.split('/').pop() || '';
|
||||
executionOrder.push(`start:${fileName}`);
|
||||
|
||||
// Add delay to make timing differences visible
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
executionOrder.push(`end:${fileName}`);
|
||||
return 'text';
|
||||
});
|
||||
|
||||
await tool.execute({ paths: files }, new AbortController().signal);
|
||||
|
||||
console.log('Execution order:', executionOrder);
|
||||
|
||||
// Verify concurrent execution pattern
|
||||
// In parallel execution: all "start:" events should come before all "end:" events
|
||||
// In sequential execution: "start:file1", "end:file1", "start:file2", "end:file2", etc.
|
||||
|
||||
const startEvents = executionOrder.filter((e) =>
|
||||
e.startsWith('start:'),
|
||||
).length;
|
||||
const firstEndIndex = executionOrder.findIndex((e) =>
|
||||
e.startsWith('end:'),
|
||||
);
|
||||
const startsBeforeFirstEnd = executionOrder
|
||||
.slice(0, firstEndIndex)
|
||||
.filter((e) => e.startsWith('start:')).length;
|
||||
|
||||
// For parallel processing, ALL start events should happen before the first end event
|
||||
expect(startsBeforeFirstEnd).toBe(startEvents); // Should PASS with parallel implementation
|
||||
|
||||
detectFileTypeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,27 @@ export interface ReadManyFilesParams {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for file processing operations
|
||||
*/
|
||||
type FileProcessingResult =
|
||||
| {
|
||||
success: true;
|
||||
filePath: string;
|
||||
relativePathForDisplay: string;
|
||||
fileReadResult: NonNullable<
|
||||
Awaited<ReturnType<typeof processSingleFileContent>>
|
||||
>;
|
||||
reason?: undefined;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
filePath: string;
|
||||
relativePathForDisplay: string;
|
||||
fileReadResult?: undefined;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default exclusion patterns for commonly ignored directories and binary file types.
|
||||
* These are compatible with glob ignore patterns.
|
||||
@@ -413,66 +434,124 @@ Use this tool when the user's query implies needing the content of several files
|
||||
|
||||
const sortedFiles = Array.from(filesToConsider).sort();
|
||||
|
||||
for (const filePath of sortedFiles) {
|
||||
const relativePathForDisplay = path
|
||||
.relative(this.config.getTargetDir(), filePath)
|
||||
.replace(/\\/g, '/');
|
||||
const fileProcessingPromises = sortedFiles.map(
|
||||
async (filePath): Promise<FileProcessingResult> => {
|
||||
try {
|
||||
const relativePathForDisplay = path
|
||||
.relative(this.config.getTargetDir(), filePath)
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
const fileType = await detectFileType(filePath);
|
||||
const fileType = await detectFileType(filePath);
|
||||
|
||||
if (fileType === 'image' || fileType === 'pdf') {
|
||||
const fileExtension = path.extname(filePath).toLowerCase();
|
||||
const fileNameWithoutExtension = path.basename(filePath, fileExtension);
|
||||
const requestedExplicitly = inputPatterns.some(
|
||||
(pattern: string) =>
|
||||
pattern.toLowerCase().includes(fileExtension) ||
|
||||
pattern.includes(fileNameWithoutExtension),
|
||||
);
|
||||
if (fileType === 'image' || fileType === 'pdf') {
|
||||
const fileExtension = path.extname(filePath).toLowerCase();
|
||||
const fileNameWithoutExtension = path.basename(
|
||||
filePath,
|
||||
fileExtension,
|
||||
);
|
||||
const requestedExplicitly = inputPatterns.some(
|
||||
(pattern: string) =>
|
||||
pattern.toLowerCase().includes(fileExtension) ||
|
||||
pattern.includes(fileNameWithoutExtension),
|
||||
);
|
||||
|
||||
if (!requestedExplicitly) {
|
||||
skippedFiles.push({
|
||||
path: relativePathForDisplay,
|
||||
reason:
|
||||
'asset file (image/pdf) was not explicitly requested by name or extension',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!requestedExplicitly) {
|
||||
return {
|
||||
success: false,
|
||||
filePath,
|
||||
relativePathForDisplay,
|
||||
reason:
|
||||
'asset file (image/pdf) was not explicitly requested by name or extension',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use processSingleFileContent for all file types now
|
||||
const fileReadResult = await processSingleFileContent(
|
||||
filePath,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
|
||||
if (fileReadResult.error) {
|
||||
skippedFiles.push({
|
||||
path: relativePathForDisplay,
|
||||
reason: `Read error: ${fileReadResult.error}`,
|
||||
});
|
||||
} else {
|
||||
if (typeof fileReadResult.llmContent === 'string') {
|
||||
const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace(
|
||||
'{filePath}',
|
||||
// Use processSingleFileContent for all file types now
|
||||
const fileReadResult = await processSingleFileContent(
|
||||
filePath,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
contentParts.push(`${separator}\n\n${fileReadResult.llmContent}\n\n`);
|
||||
} else {
|
||||
contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf
|
||||
|
||||
if (fileReadResult.error) {
|
||||
return {
|
||||
success: false,
|
||||
filePath,
|
||||
relativePathForDisplay,
|
||||
reason: `Read error: ${fileReadResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
relativePathForDisplay,
|
||||
fileReadResult,
|
||||
};
|
||||
} catch (error) {
|
||||
const relativePathForDisplay = path
|
||||
.relative(this.config.getTargetDir(), filePath)
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
return {
|
||||
success: false,
|
||||
filePath,
|
||||
relativePathForDisplay,
|
||||
reason: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
processedFilesRelativePaths.push(relativePathForDisplay);
|
||||
const lines =
|
||||
typeof fileReadResult.llmContent === 'string'
|
||||
? fileReadResult.llmContent.split('\n').length
|
||||
: undefined;
|
||||
const mimetype = getSpecificMimeType(filePath);
|
||||
recordFileOperationMetric(
|
||||
this.config,
|
||||
FileOperation.READ,
|
||||
lines,
|
||||
mimetype,
|
||||
path.extname(filePath),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(fileProcessingPromises);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const fileResult = result.value;
|
||||
|
||||
if (!fileResult.success) {
|
||||
// Handle skipped files (images/PDFs not requested or read errors)
|
||||
skippedFiles.push({
|
||||
path: fileResult.relativePathForDisplay,
|
||||
reason: fileResult.reason,
|
||||
});
|
||||
} else {
|
||||
// Handle successfully processed files
|
||||
const { filePath, relativePathForDisplay, fileReadResult } =
|
||||
fileResult;
|
||||
|
||||
if (typeof fileReadResult.llmContent === 'string') {
|
||||
const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace(
|
||||
'{filePath}',
|
||||
filePath,
|
||||
);
|
||||
contentParts.push(
|
||||
`${separator}\n\n${fileReadResult.llmContent}\n\n`,
|
||||
);
|
||||
} else {
|
||||
contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf
|
||||
}
|
||||
|
||||
processedFilesRelativePaths.push(relativePathForDisplay);
|
||||
|
||||
const lines =
|
||||
typeof fileReadResult.llmContent === 'string'
|
||||
? fileReadResult.llmContent.split('\n').length
|
||||
: undefined;
|
||||
const mimetype = getSpecificMimeType(filePath);
|
||||
recordFileOperationMetric(
|
||||
this.config,
|
||||
FileOperation.READ,
|
||||
lines,
|
||||
mimetype,
|
||||
path.extname(filePath),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Handle Promise rejection (unexpected errors)
|
||||
skippedFiles.push({
|
||||
path: 'unknown',
|
||||
reason: `Unexpected error: ${result.reason}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
Schema,
|
||||
} from '@google/genai';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
vi.mock('node:fs');
|
||||
@@ -140,7 +140,6 @@ const baseConfigParams: ConfigParameters = {
|
||||
geminiMdFileCount: 0,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
sessionId: 'test-session-id',
|
||||
ideClient: IdeClient.getInstance(false),
|
||||
};
|
||||
|
||||
describe('ToolRegistry', () => {
|
||||
@@ -172,6 +171,10 @@ describe('ToolRegistry', () => {
|
||||
);
|
||||
vi.spyOn(config, 'getMcpServers');
|
||||
vi.spyOn(config, 'getMcpServerCommand');
|
||||
vi.spyOn(config, 'getPromptRegistry').mockReturnValue({
|
||||
clear: vi.fn(),
|
||||
removePromptsByServer: vi.fn(),
|
||||
} as any);
|
||||
mockDiscoverMcpTools.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
@@ -353,7 +356,7 @@ describe('ToolRegistry', () => {
|
||||
mcpServerConfigVal,
|
||||
undefined,
|
||||
toolRegistry,
|
||||
undefined,
|
||||
config.getPromptRegistry(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -376,7 +379,7 @@ describe('ToolRegistry', () => {
|
||||
mcpServerConfigVal,
|
||||
undefined,
|
||||
toolRegistry,
|
||||
undefined,
|
||||
config.getPromptRegistry(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -150,6 +150,14 @@ export class ToolRegistry {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
private removeDiscoveredTools(): void {
|
||||
for (const tool of this.tools.values()) {
|
||||
if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) {
|
||||
this.tools.delete(tool.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers tools from project (if available and configured).
|
||||
* Can be called multiple times to update discovered tools.
|
||||
@@ -157,11 +165,9 @@ export class ToolRegistry {
|
||||
*/
|
||||
async discoverAllTools(): Promise<void> {
|
||||
// remove any previously discovered tools
|
||||
for (const tool of this.tools.values()) {
|
||||
if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) {
|
||||
this.tools.delete(tool.name);
|
||||
}
|
||||
}
|
||||
this.removeDiscoveredTools();
|
||||
|
||||
this.config.getPromptRegistry().clear();
|
||||
|
||||
await this.discoverAndRegisterToolsFromCommand();
|
||||
|
||||
@@ -182,11 +188,9 @@ export class ToolRegistry {
|
||||
*/
|
||||
async discoverMcpTools(): Promise<void> {
|
||||
// remove any previously discovered tools
|
||||
for (const tool of this.tools.values()) {
|
||||
if (tool instanceof DiscoveredMCPTool) {
|
||||
this.tools.delete(tool.name);
|
||||
}
|
||||
}
|
||||
this.removeDiscoveredTools();
|
||||
|
||||
this.config.getPromptRegistry().clear();
|
||||
|
||||
// discover tools using MCP servers, if configured
|
||||
await discoverMcpTools(
|
||||
@@ -210,6 +214,8 @@ export class ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
this.config.getPromptRegistry().removePromptsByServer(serverName);
|
||||
|
||||
const mcpServers = this.config.getMcpServers() ?? {};
|
||||
const serverConfig = mcpServers[serverName];
|
||||
if (serverConfig) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
vi,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { WriteFileTool } from './write-file.js';
|
||||
import { WriteFileTool, WriteFileToolParams } from './write-file.js';
|
||||
import {
|
||||
FileDiff,
|
||||
ToolConfirmationOutcome,
|
||||
@@ -202,6 +202,32 @@ describe('WriteFileTool', () => {
|
||||
`Path is a directory, not a file: ${dirAsFilePath}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if the content is null', () => {
|
||||
const dirAsFilePath = path.join(rootDir, 'a_directory');
|
||||
fs.mkdirSync(dirAsFilePath);
|
||||
const params = {
|
||||
file_path: dirAsFilePath,
|
||||
content: null,
|
||||
} as unknown as WriteFileToolParams; // Intentionally non-conforming
|
||||
expect(tool.validateToolParams(params)).toMatch(
|
||||
`params/content must be string`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
it('should return error if the file_path is empty', () => {
|
||||
const dirAsFilePath = path.join(rootDir, 'a_directory');
|
||||
fs.mkdirSync(dirAsFilePath);
|
||||
const params = {
|
||||
file_path: '',
|
||||
content: '',
|
||||
};
|
||||
expect(tool.getDescription(params)).toMatch(
|
||||
`Model did not provide valid parameters for write file tool, missing or empty "file_path"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_getCorrectedFileContent', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user