diff --git a/ROADMAP.md b/ROADMAP.md index 1c3f9e0e..b19b1577 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 62993998..9af6e1eb 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -27,6 +27,9 @@ Slash commands provide meta-level control over the CLI itself. - **Usage:** `/chat resume ` - **`list`** - **Description:** Lists available tags for chat state resumption. + - **`delete`** + - **Description:** Deletes a saved conversation checkpoint. + - **Usage:** `/chat delete ` - **`/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. @@ -49,6 +52,17 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** Display all directories added by `/directory add` and `--include-directories`. - **Usage:** `/directory show` +- **`/directory`** (or **`/dir`**) + - **Description:** Manage workspace directories for multi-directory support. + - **Sub-commands:** + - **`add`**: + - **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well. + - **Usage:** `/directory add ,` + - **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 `/directory add` and `--include-directories`. + - **Usage:** `/directory show` + - **`/editor`** - **Description:** Open a dialog for selecting supported editors. @@ -253,7 +267,7 @@ Please generate a Conventional Commit message based on the following git diff: ```diff !{git diff --staged} -```` +``` """ @@ -274,7 +288,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:** diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index c77574d2..f1ab7ba4 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -248,6 +248,26 @@ In addition to a project settings file, a project's `.gemini` directory can cont "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`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. + - **Default:** `false` + - **Example:** + ```json + "loadMemoryFromIncludeDirectories": true + ``` + ### Example `settings.json`: ```json @@ -280,7 +300,9 @@ In addition to a project settings file, a project's `.gemini` directory can cont "tokenBudget": 100 } }, - "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] + "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"], + "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], + "loadMemoryFromIncludeDirectories": true } ``` diff --git a/docs/core/tools-api.md b/docs/core/tools-api.md index e10333d2..0b52191c 100644 --- a/docs/core/tools-api.md +++ b/docs/core/tools-api.md @@ -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: diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 050e10e8..850c228e 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -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. diff --git a/docs/tools/multi-file.md b/docs/tools/multi-file.md index 1bc495f6..68c1a3ae 100644 --- a/docs/tools/multi-file.md +++ b/docs/tools/multi-file.md @@ -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) diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 3e2a00e4..253e0218 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8c500445..dde2a8ef 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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,26 +41,27 @@ 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** @@ -72,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! diff --git a/package-lock.json b/package-lock.json index 1170fb58..26e6ba21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,30 @@ "node": ">=14.13.1" } }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -103,6 +127,12 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -123,13 +153,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -139,9 +169,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", - "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "dev": true, "license": "MIT", "engines": { @@ -149,9 +179,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -288,9 +318,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", + "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", "cpu": [ "ppc64" ], @@ -305,9 +335,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz", + "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", "cpu": [ "arm" ], @@ -322,9 +352,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz", + "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", "cpu": [ "arm64" ], @@ -339,9 +369,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz", + "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", "cpu": [ "x64" ], @@ -356,9 +386,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", + "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", "cpu": [ "arm64" ], @@ -373,9 +403,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz", + "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", "cpu": [ "x64" ], @@ -390,9 +420,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz", + "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", "cpu": [ "arm64" ], @@ -407,9 +437,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz", + "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", "cpu": [ "x64" ], @@ -424,9 +454,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz", + "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", "cpu": [ "arm" ], @@ -441,9 +471,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz", + "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", "cpu": [ "arm64" ], @@ -458,9 +488,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz", + "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", "cpu": [ "ia32" ], @@ -475,9 +505,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz", + "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", "cpu": [ "loong64" ], @@ -492,9 +522,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz", + "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", "cpu": [ "mips64el" ], @@ -509,9 +539,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz", + "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", "cpu": [ "ppc64" ], @@ -526,9 +556,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz", + "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", "cpu": [ "riscv64" ], @@ -543,9 +573,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz", + "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", "cpu": [ "s390x" ], @@ -560,9 +590,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz", + "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", "cpu": [ "x64" ], @@ -577,9 +607,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz", + "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", "cpu": [ "arm64" ], @@ -594,9 +624,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz", + "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", "cpu": [ "x64" ], @@ -611,9 +641,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz", + "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", "cpu": [ "arm64" ], @@ -628,9 +658,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz", + "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", "cpu": [ "x64" ], @@ -645,9 +675,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz", + "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", "cpu": [ "arm64" ], @@ -662,9 +692,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz", + "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", "cpu": [ "x64" ], @@ -679,9 +709,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz", + "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", "cpu": [ "arm64" ], @@ -696,9 +726,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz", + "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", "cpu": [ "ia32" ], @@ -713,9 +743,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz", + "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", "cpu": [ "x64" ], @@ -748,6 +778,19 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint-community/regexpp": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", @@ -759,9 +802,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", + "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -773,34 +816,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", + "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -808,9 +827,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -844,17 +863,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -868,33 +876,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", - "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", "dev": true, "license": "MIT", "engines": { @@ -915,9 +900,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -928,25 +913,17 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@google/genai": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.9.0.tgz", - "integrity": "sha512-w9P93OXKPMs9H1mfAx9+p3zJqQGrWBGdvK/SVc7cLZEXNHr/3+vW2eif7ZShA6wU24rNLn9z9MK2vQFUvNRI2Q==", + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "google-auth-library": "^9.14.2", - "ws": "^8.18.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.11.0" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@grpc/grpc-js": { @@ -1069,29 +1046,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -1103,9 +1057,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", + "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", "dev": true, "license": "MIT", "dependencies": { @@ -1116,14 +1070,18 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1136,17 +1094,27 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1182,9 +1150,9 @@ } }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.4.0.tgz", - "integrity": "sha512-Akn8XZqN3xO9YGcgvIiTauBBXTP92QSvw6EcGha+p5nm7brhbwvev5gw4fi+ouLGrBpfPpb72+S5pxl4mkMIGQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", + "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1205,9 +1173,9 @@ } }, "node_modules/@jsonjoy.com/util": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.8.0.tgz", - "integrity": "sha512-HeR0JQNEdBozt+FrfyM5T0X3R+fIN0D+BRDkxPP5o41fTWzHfeZEqtK16aTW8haU+h+SG7XYq9PP5kobvOmkSA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz", + "integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1237,9 +1205,9 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.1.tgz", - "integrity": "sha512-CPle1OQehbWqd25La9Ack5B07StKIxh4+Bf19qnpZKJC1oI22Y0czZHbifjw1UoczIfKBwBDAp/dFxvHG13B5A==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.1.tgz", + "integrity": "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -1519,6 +1487,30 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@opentelemetry/instrumentation/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.52.1", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.52.1.tgz", @@ -1718,6 +1710,18 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.25.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", @@ -1850,10 +1854,14 @@ "resolved": "packages/core", "link": true }, + "node_modules/@qwen-code/qwen-code-test-utils": { + "resolved": "packages/test-utils", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", - "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz", + "integrity": "sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==", "cpu": [ "arm" ], @@ -1865,9 +1873,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", - "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.0.tgz", + "integrity": "sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw==", "cpu": [ "arm64" ], @@ -1879,9 +1887,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", - "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", "cpu": [ "arm64" ], @@ -1893,9 +1901,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", - "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", "cpu": [ "x64" ], @@ -1907,9 +1915,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", - "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.0.tgz", + "integrity": "sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ==", "cpu": [ "arm64" ], @@ -1921,9 +1929,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", - "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.0.tgz", + "integrity": "sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g==", "cpu": [ "x64" ], @@ -1935,9 +1943,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", - "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.0.tgz", + "integrity": "sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ==", "cpu": [ "arm" ], @@ -1949,9 +1957,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", - "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.0.tgz", + "integrity": "sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg==", "cpu": [ "arm" ], @@ -1963,9 +1971,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", - "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", "cpu": [ "arm64" ], @@ -1977,9 +1985,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", - "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", "cpu": [ "arm64" ], @@ -1991,9 +1999,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", - "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.0.tgz", + "integrity": "sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg==", "cpu": [ "loong64" ], @@ -2004,10 +2012,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", - "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.0.tgz", + "integrity": "sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ==", "cpu": [ "ppc64" ], @@ -2019,9 +2027,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", - "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.0.tgz", + "integrity": "sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA==", "cpu": [ "riscv64" ], @@ -2033,9 +2041,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", - "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.0.tgz", + "integrity": "sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q==", "cpu": [ "riscv64" ], @@ -2047,9 +2055,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", - "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.0.tgz", + "integrity": "sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA==", "cpu": [ "s390x" ], @@ -2061,9 +2069,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", - "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", "cpu": [ "x64" ], @@ -2075,9 +2083,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", - "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", "cpu": [ "x64" ], @@ -2089,9 +2097,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", - "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", "cpu": [ "arm64" ], @@ -2103,9 +2111,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", - "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.0.tgz", + "integrity": "sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==", "cpu": [ "ia32" ], @@ -2117,9 +2125,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", - "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", "cpu": [ "x64" ], @@ -2151,110 +2159,12 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.38", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", - "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "version": "0.34.37", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", + "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", "dev": true, "license": "MIT" }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/dom/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2474,12 +2384,12 @@ } }, "node_modules/@types/node": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", - "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "version": "20.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", + "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==", "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/normalize-package-data": { @@ -2488,6 +2398,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/qrcode-terminal": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", @@ -2510,9 +2427,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", - "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2520,9 +2437,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.1.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", - "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2636,17 +2553,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", - "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", + "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/type-utils": "8.38.0", - "@typescript-eslint/utils": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/type-utils": "8.35.0", + "@typescript-eslint/utils": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2660,22 +2577,32 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.38.0", + "@typescript-eslint/parser": "^8.35.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", - "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", + "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", "debug": "^4.3.4" }, "engines": { @@ -2691,14 +2618,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", - "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", + "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.38.0", - "@typescript-eslint/types": "^8.38.0", + "@typescript-eslint/tsconfig-utils": "^8.35.0", + "@typescript-eslint/types": "^8.35.0", "debug": "^4.3.4" }, "engines": { @@ -2713,14 +2640,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", - "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", + "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0" + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2731,9 +2658,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", - "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", + "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", "dev": true, "license": "MIT", "engines": { @@ -2748,15 +2675,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", - "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", + "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/utils": "8.35.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2773,9 +2699,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", - "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", + "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", "dev": true, "license": "MIT", "engines": { @@ -2787,16 +2713,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", - "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", + "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.38.0", - "@typescript-eslint/tsconfig-utils": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/project-service": "8.35.0", + "@typescript-eslint/tsconfig-utils": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2815,17 +2741,56 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", - "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", + "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0" + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2840,13 +2805,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", - "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", + "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/types": "8.35.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2857,19 +2822,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -3064,9 +3016,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", "engines": { "node": ">= 14" @@ -3112,15 +3064,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/ansi-align/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-align/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3175,12 +3118,15 @@ } }, "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -3193,17 +3139,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -3386,13 +3321,6 @@ "js-tokens": "^9.0.1" } }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3467,9 +3395,9 @@ "license": "MIT" }, "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", + "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", "license": "MIT", "engines": { "node": "*" @@ -3517,36 +3445,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/boxen/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/boxen/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/boxen/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, "engines": { - "node": ">=12" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -3688,9 +3607,9 @@ } }, "node_modules/chai": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", - "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { @@ -3701,21 +3620,37 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chardet": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", @@ -3793,6 +3728,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-truncate/node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -3809,6 +3774,23 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3832,36 +3814,12 @@ "node": ">=8" } }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3974,52 +3932,6 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/concurrently/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -4255,9 +4167,9 @@ } }, "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "dev": true, "license": "MIT" }, @@ -4528,9 +4440,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", - "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz", + "integrity": "sha512-tG9VUTJTuju6GcXgbdsOuRhupE8cb4mRgY5JLRCh4MtGoVo3/gfGUtOMwmProM6d0ba2mCFvv+WrpYJV6qgJXQ==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4575,9 +4487,9 @@ "license": "MIT" }, "node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, "node_modules/encodeurl": { @@ -4805,9 +4717,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.39.8", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.8.tgz", - "integrity": "sha512-A8QO9TfF+rltS8BXpdu8OS+rpGgEdnRhqIVxO/ZmNvnXBYgOdSsxukT55ELyP94gZIntWJ+Li9QRrT2u1Kitpg==", + "version": "1.39.5", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz", + "integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ==", "license": "MIT", "workspaces": [ "docs", @@ -4815,9 +4727,9 @@ ] }, "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", + "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4828,32 +4740,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@esbuild/aix-ppc64": "0.25.6", + "@esbuild/android-arm": "0.25.6", + "@esbuild/android-arm64": "0.25.6", + "@esbuild/android-x64": "0.25.6", + "@esbuild/darwin-arm64": "0.25.6", + "@esbuild/darwin-x64": "0.25.6", + "@esbuild/freebsd-arm64": "0.25.6", + "@esbuild/freebsd-x64": "0.25.6", + "@esbuild/linux-arm": "0.25.6", + "@esbuild/linux-arm64": "0.25.6", + "@esbuild/linux-ia32": "0.25.6", + "@esbuild/linux-loong64": "0.25.6", + "@esbuild/linux-mips64el": "0.25.6", + "@esbuild/linux-ppc64": "0.25.6", + "@esbuild/linux-riscv64": "0.25.6", + "@esbuild/linux-s390x": "0.25.6", + "@esbuild/linux-x64": "0.25.6", + "@esbuild/netbsd-arm64": "0.25.6", + "@esbuild/netbsd-x64": "0.25.6", + "@esbuild/openbsd-arm64": "0.25.6", + "@esbuild/openbsd-x64": "0.25.6", + "@esbuild/openharmony-arm64": "0.25.6", + "@esbuild/sunos-x64": "0.25.6", + "@esbuild/win32-arm64": "0.25.6", + "@esbuild/win32-ia32": "0.25.6", + "@esbuild/win32-x64": "0.25.6" } }, "node_modules/escalade": { @@ -4897,20 +4809,20 @@ } }, "node_modules/eslint": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", - "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", + "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", + "@eslint/config-array": "^0.20.1", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.32.0", - "@eslint/plugin-kit": "^0.3.4", + "@eslint/js": "9.29.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4958,9 +4870,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", "bin": { @@ -5057,17 +4969,6 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, - "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -5078,29 +4979,6 @@ "ms": "^2.1.1" } }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-license-header": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/eslint-plugin-license-header/-/eslint-plugin-license-header-0.8.0.tgz", @@ -5157,30 +5035,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/eslint-plugin-react/node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -5199,16 +5053,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -5227,63 +5071,6 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", @@ -5296,42 +5083,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -5350,19 +5101,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -5450,9 +5188,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5809,7 +5547,21 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", @@ -5825,18 +5577,46 @@ "node": ">=14" } }, - "node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "license": "Apache-2.0", + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" + "whatwg-url": "^5.0.0" }, "engines": { - "node": ">=14" + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/gcp-metadata/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/gcp-metadata/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/gcp-metadata/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, "node_modules/get-caller-file": { @@ -5948,6 +5728,30 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -6010,6 +5814,64 @@ "node": ">=14" } }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/google-auth-library/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/google-auth-library/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/google-auth-library/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/google-logging-utils": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", @@ -6050,49 +5912,6 @@ "node": ">=10" } }, - "node_modules/gradient-string/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/gradient-string/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/gradient-string/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -6113,6 +5932,64 @@ "node": ">=14.0.0" } }, + "node_modules/gtoken/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gtoken/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/gtoken/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/gtoken/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/gtoken/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6214,11 +6091,16 @@ } }, "node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true, - "license": "ISC" + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", @@ -6350,9 +6232,10 @@ } }, "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -6437,9 +6320,9 @@ } }, "node_modules/ink": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ink/-/ink-6.1.0.tgz", - "integrity": "sha512-YQ+lbMD79y3FBAJXXZnuRajLEgaMFp102361eY5NrBIEVCi9oFo7gNZU4z2LBWlcjZFiTt7jetlkIbKCCH4KJA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-6.0.1.tgz", + "integrity": "sha512-vhhFrCodTHZAPPSdMYzLEbeI0Ug37R9j6yA0kLKok9kSK53lQtj/RJhEQJUjq6OwT4N33nxqSRd/7yXhEhVPIw==", "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", @@ -6595,12 +6478,59 @@ } } }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, "node_modules/ink/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/ink/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ink/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -6895,15 +6825,12 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/is-generator-function": { @@ -7374,9 +7301,10 @@ } }, "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -7553,9 +7481,9 @@ } }, "node_modules/ky": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.8.2.tgz", - "integrity": "sha512-XybQJ3d4Ea1kI27DoelE5ZCT3bSJlibYTtQuMsyzKox3TMyayw1asgQdl54WroAm+fIA3ZCr8zXW2RpR7qWVpA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.8.1.tgz", + "integrity": "sha512-7Bp3TpsE+L+TARSnnDpk3xg8Idi8RwSLdj6CMbNWoOARIrGrbuLGusV0dYwbZOm4bB3jHNxSw8Wk/ByDqJEnDw==", "license": "MIT", "engines": { "node": ">=18" @@ -7618,6 +7546,20 @@ "node": ">=4" } }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7671,10 +7613,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/loupe": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.0.tgz", - "integrity": "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", + "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==", "dev": true, "license": "MIT" }, @@ -7792,9 +7740,9 @@ } }, "node_modules/memfs": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.28.1.tgz", - "integrity": "sha512-moZpQdp7bzWXO6H08h4vpKZ4Cymd2G6AuND7UG7ErBxr5pDntycGpECJB7N8ZIF8PA8HrKno8k1Rr0VOfRbMcA==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", + "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -7886,18 +7834,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/minimist": { @@ -7982,69 +7928,30 @@ "dev": true, "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", "license": "BSD-2-Clause", "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" } }, "node_modules/normalize-package-data/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { - "semver": "bin/semver" + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/npm-run-all": { @@ -8086,17 +7993,6 @@ "node": ">=4" } }, - "node_modules/npm-run-all/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/npm-run-all/node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -8166,17 +8062,24 @@ "node": ">=4" } }, - "node_modules/npm-run-all/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/npm-run-all/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true, - "license": "ISC", + "license": "ISC" + }, + "node_modules/npm-run-all/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" } }, "node_modules/npm-run-all/node_modules/path-key": { @@ -8189,6 +8092,21 @@ "node": ">=4" } }, + "node_modules/npm-run-all/node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/npm-run-all/node_modules/semver": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", @@ -8249,9 +8167,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", - "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", "dev": true, "license": "MIT" }, @@ -8413,15 +8331,15 @@ } }, "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", "license": "MIT", "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" + "is-wsl": "^3.1.0" }, "engines": { "node": ">=18" @@ -8430,27 +8348,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openai": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-5.11.0.tgz", - "integrity": "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8543,6 +8440,18 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/package-json/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8557,17 +8466,32 @@ } }, "node_modules/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", - "dev": true, + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "license": "MIT", "dependencies": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/parse5": { @@ -8816,9 +8740,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.1.tgz", + "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", "bin": { @@ -8832,13 +8756,13 @@ } }, "node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.0.1", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" }, @@ -8859,6 +8783,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8870,12 +8801,6 @@ "react-is": "^16.13.1" } }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -9046,9 +8971,9 @@ } }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9088,16 +9013,16 @@ } }, "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.1.0" } }, "node_modules/react-dom/node_modules/scheduler": { @@ -9108,10 +9033,9 @@ "license": "MIT" }, "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/react-reconciler": { @@ -9152,50 +9076,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, + "node_modules/read-package-up/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/read-package-up/node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/read-package-up/node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/read-pkg": { + "node_modules/read-pkg": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", @@ -9214,7 +9107,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/type-fest": { + "node_modules/read-pkg/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", @@ -9226,21 +9119,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", - "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-json-file": "^4.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -9418,9 +9296,9 @@ } }, "node_modules/rollup": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", - "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", + "integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==", "dev": true, "license": "MIT", "dependencies": { @@ -9434,26 +9312,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", + "@rollup/rollup-android-arm-eabi": "4.44.0", + "@rollup/rollup-android-arm64": "4.44.0", + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-freebsd-arm64": "4.44.0", + "@rollup/rollup-freebsd-x64": "4.44.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.0", + "@rollup/rollup-linux-arm-musleabihf": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.0", + "@rollup/rollup-linux-riscv64-gnu": "4.44.0", + "@rollup/rollup-linux-riscv64-musl": "4.44.0", + "@rollup/rollup-linux-s390x-gnu": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-ia32-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0", "fsevents": "~2.3.2" } }, @@ -9642,15 +9520,13 @@ } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/send": { @@ -9906,6 +9782,18 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", @@ -10022,17 +9910,17 @@ } }, "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10068,15 +9956,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10278,13 +10157,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/stubborn-fs": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", @@ -10407,6 +10279,32 @@ "node": ">=18" } }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thingies": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", @@ -10421,9 +10319,9 @@ } }, "node_modules/tiktoken": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.21.tgz", - "integrity": "sha512-/kqtlepLMptX0OgbYD9aMYbM7EFrMZCL7EoHM8Psmg2FuhXoo/bH64KqOiZGGwa6oS9TPdSEDKBnV2LuB8+5vQ==", + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz", + "integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==", "license": "MIT" }, "node_modules/tinybench": { @@ -10479,9 +10377,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", "engines": { @@ -10802,16 +10700,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", - "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz", + "integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.38.0", - "@typescript-eslint/parser": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0" + "@typescript-eslint/eslint-plugin": "8.35.0", + "@typescript-eslint/parser": "8.35.0", + "@typescript-eslint/utils": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10845,18 +10742,18 @@ } }, "node_modules/undici": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.13.0.tgz", - "integrity": "sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", + "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/unicorn-magic": { @@ -10904,6 +10801,18 @@ "url": "https://github.com/yeoman/update-notifier?sponsor=1" } }, + "node_modules/update-notifier/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/update-notifier/node_modules/boxen": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", @@ -10938,6 +10847,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/update-notifier/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/update-notifier/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/update-notifier/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -11024,15 +10980,15 @@ } }, "node_modules/vite": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", - "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", - "picomatch": "^4.0.3", + "picomatch": "^4.0.2", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" @@ -11137,9 +11093,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", "engines": { @@ -11223,9 +11179,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", "engines": { @@ -11437,29 +11393,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/widest-line/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/widest-line/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/window-size": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/window-size/-/window-size-1.1.1.tgz", @@ -11542,36 +11475,12 @@ "node": ">=8" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -11598,27 +11507,16 @@ "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/wrappy": { @@ -11648,21 +11546,6 @@ } } }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/xdg-basedir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", @@ -11743,15 +11626,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -11854,6 +11728,7 @@ }, "devDependencies": { "@babel/runtime": "^7.27.6", + "@qwen-code/qwen-code-test-utils": "file:../test-utils", "@testing-library/react": "^16.3.0", "@types/command-exists": "^1.2.3", "@types/diff": "^7.0.2", @@ -11875,23 +11750,149 @@ "node": ">=20" } }, - "packages/cli/node_modules/@types/node": { - "version": "20.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", - "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", + "packages/cli/node_modules/@google/genai": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.9.0.tgz", + "integrity": "sha512-w9P93OXKPMs9H1mfAx9+p3zJqQGrWBGdvK/SVc7cLZEXNHr/3+vW2eif7ZShA6wU24rNLn9z9MK2vQFUvNRI2Q==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.14.2", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.11.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "packages/cli/node_modules/@testing-library/dom": { + "version": "10.4.0", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "packages/cli/node_modules/@testing-library/react": { + "version": "16.3.0", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "packages/cli/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "packages/cli/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "packages/cli/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/cli/node_modules/aria-query": { + "version": "5.3.0", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "packages/cli/node_modules/emoji-regex": { + "version": "10.4.0", "license": "MIT" }, + "packages/cli/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "packages/cli/node_modules/string-width": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/core": { "name": "@qwen-code/qwen-code-core", "version": "0.0.5", @@ -11909,6 +11910,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", @@ -11917,7 +11919,8 @@ "marked": "^15.0.12", "micromatch": "^4.0.8", "open": "^10.1.2", - "openai": "^5.7.0", + "openai": "5.11.0", + "picomatch": "^4.0.1", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", @@ -11926,10 +11929,12 @@ "ws": "^8.18.0" }, "devDependencies": { + "@qwen-code/qwen-code-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" @@ -11938,6 +11943,27 @@ "node": ">=20" } }, + "packages/core/node_modules/@google/genai": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.9.0.tgz", + "integrity": "sha512-w9P93OXKPMs9H1mfAx9+p3zJqQGrWBGdvK/SVc7cLZEXNHr/3+vW2eif7ZShA6wU24rNLn9z9MK2vQFUvNRI2Q==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.14.2", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.11.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "packages/core/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -11954,12 +11980,77 @@ "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", + "engines": { + "node": ">= 4" + } + }, "packages/core/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "packages/core/node_modules/openai": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.11.0.tgz", + "integrity": "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "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": "@qwen-code/qwen-code-test-utils", + "version": "0.1.18", + "license": "Apache-2.0", + "devDependencies": { + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", "version": "0.0.5", @@ -11986,23 +12077,6 @@ "engines": { "vscode": "^1.101.0" } - }, - "packages/vscode-ide-companion/node_modules/@types/node": { - "version": "20.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", - "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "packages/vscode-ide-companion/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index ce6be0e1..c2f57378 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -75,7 +75,8 @@ "pretty-format": "^30.0.2", "react-dom": "^19.1.0", "typescript": "^5.3.3", - "vitest": "^3.1.1" + "vitest": "^3.1.1", + "@qwen-code/qwen-code-test-utils": "file:../test-utils" }, "engines": { "node": ">=20" diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0f9d2e2e..2774f621 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -6,7 +6,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'os'; -import { loadCliConfig, parseArguments, CliArgs } from './config.js'; +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'; import * as ServerConfig from '@qwen-code/qwen-code-core'; @@ -44,7 +46,7 @@ vi.mock('@qwen-code/qwen-code-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, @@ -499,6 +501,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), [ @@ -1078,14 +1081,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('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, + ); }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 77095633..4fa2a857 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -22,13 +22,13 @@ import { FileDiscoveryService, TelemetryTarget, FileFilteringOptions, - IdeClient, } from '@qwen-code/qwen-code-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 = { @@ -68,6 +68,7 @@ export interface CliArgs { openaiBaseUrl: string | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; + loadMemoryFromIncludeDirectories: boolean | undefined; } export async function parseArguments(): Promise { @@ -228,6 +229,12 @@ export async function parseArguments(): Promise { // 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, QWEN.md files should be loaded from all directories that are added. If false, QWEN.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() @@ -255,6 +262,7 @@ export async function parseArguments(): Promise { // 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, @@ -280,6 +288,7 @@ export async function loadHierarchicalGeminiMemory( // Directly call the server function with the corrected path. return loadServerHierarchicalMemory( effectiveCwd, + includeDirectoriesToReadGemini, debugMode, fileService, extensionContextFilePaths, @@ -302,13 +311,10 @@ export async function loadCliConfig( ) || false; const memoryImportFormat = settings.memoryImportFormat || 'tree'; + 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, @@ -350,9 +356,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, @@ -419,7 +430,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, @@ -480,7 +495,6 @@ export async function loadCliConfig( summarizeToolOutput: settings.summarizeToolOutput, ideMode, ideModeFeature, - ideClient, enableOpenAILogging: (typeof argv.openaiLogging === 'undefined' ? settings.enableOpenAILogging diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 4099e778..d0266720 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -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: [], }); }); @@ -616,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'; @@ -654,6 +694,7 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({ customThemes: {}, mcpServers: {}, + includeDirectories: [], }); // Check that error objects are populated in settings.errors @@ -1090,6 +1131,7 @@ describe('Settings Loading and Merging', () => { ...systemSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 684637a7..c3e5eb7b 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -132,6 +132,7 @@ export interface Settings { // Environment variables to exclude from project .env files excludedProjectEnvVars?: string[]; dnsResolutionOrder?: DnsResolutionOrder; + sampling_params?: Record; systemPromptMappings?: Array<{ baseUrls: string[]; @@ -142,6 +143,10 @@ export interface Settings { timeout?: number; maxRetries?: number; }; + + includeDirectories?: string[]; + + loadMemoryFromIncludeDirectories?: boolean; } export interface SettingsError { @@ -197,6 +202,11 @@ export class LoadedSettings { ...(workspace.mcpServers || {}), ...(system.mcpServers || {}), }, + includeDirectories: [ + ...(system.includeDirectories || []), + ...(user.includeDirectories || []), + ...(workspace.includeDirectories || []), + ], }; } @@ -387,7 +397,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings { 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()); @@ -442,7 +452,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings { }); } - // This comparison is now much more reliable. if (realWorkspaceDir !== realHomeDir) { // Load workspace settings try { diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 288b743a..66991381 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -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; }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8826835f..44bba009 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -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 { - 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(); } diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 9771f156..9482a1bb 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -308,6 +308,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, @@ -512,6 +515,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { openPrivacyNotice, toggleVimEnabled, setIsProcessing, + setGeminiMdFileCount, ); const { @@ -533,6 +537,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { performMemoryRefresh, modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError, + refreshStatic, ); // Input handling @@ -631,7 +636,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if (config) { setGeminiMdFileCount(config.getGeminiMdFileCount()); } - }, [config]); + }, [config, config.getGeminiMdFileCount]); const logger = useLogger(); const [userMessages, setUserMessages] = useState([]); diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 14b826ea..b7126ecd 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -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(), diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 18f7e78f..8aedefd6 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -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 '@qwen-code/qwen-code-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) { diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 81238d91..73b9b13c 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -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'); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 55177d16..15a099dc 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -8,6 +8,7 @@ import { Config, DetectedIde, IDEConnectionStatus, + IdeClient, getIdeDisplayName, getIdeInstaller, } from '@qwen-code/qwen-code-core'; @@ -19,6 +20,35 @@ 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 || !config.getIdeModeFeature()) { return null; @@ -54,33 +84,13 @@ 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 = ideClient.getConnectionStatus(); - switch (connection.status) { - case IDEConnectionStatus.Connected: - return { - type: 'message', - messageType: 'info', - content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`, - } 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; }, }; @@ -110,6 +120,10 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { ); 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', @@ -126,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(), + ); }, }; @@ -137,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(), + ); }, }; diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index c673b735..9ee33e69 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -161,6 +161,10 @@ describe('memoryCommand', () => { getDebugMode: () => false, getFileService: () => ({}) as FileDiscoveryService, getExtensionContextFilePaths: () => [], + shouldLoadMemoryFromIncludeDirectories: () => false, + getWorkspaceContext: () => ({ + getDirectories: () => [], + }), getFileFilteringOptions: () => ({ ignore: [], include: [], diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index fd557e09..dd34d92c 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -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(), diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 7c654149..891c84e7 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -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/v0/examples/workflows/', + 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/v0/examples/workflows/', ]; for (const substring of expectedSubstrings) { diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 9dd12292..e330cfab 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -28,7 +28,7 @@ export const setupGithubCommand: SlashCommand = { } const version = 'v0'; - const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/examples/workflows/`; + const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`; const workflows = [ 'gemini-cli/gemini-cli.yml', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index b546c637..dde92b87 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -59,6 +59,7 @@ export interface CommandContext { /** Toggles a special display mode. */ toggleCorgiMode: () => void; toggleVimEnabled: () => Promise; + setGeminiMdFileCount: (count: number) => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index de088378..23a4f46b 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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 '@qwen-code/qwen-code-core'; @@ -1226,11 +1227,12 @@ describe('InputPrompt', () => { stdin.write('\x12'); await wait(); stdin.write('\x1B'); - await wait(); - const frame = stdout.lastFrame(); - expect(frame).not.toContain('(r:)'); - expect(frame).not.toContain('echo hello'); + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('(r:)'); + }); + + expect(stdout.lastFrame()).not.toContain('echo hello'); unmount(); }); @@ -1240,9 +1242,11 @@ describe('InputPrompt', () => { stdin.write('\x12'); await wait(); stdin.write('\t'); - await wait(); - expect(stdout.lastFrame()).not.toContain('(r:)'); + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('(r:)'); + }); + expect(props.buffer.setText).toHaveBeenCalledWith('echo hello'); unmount(); }); @@ -1253,9 +1257,11 @@ describe('InputPrompt', () => { await wait(); expect(stdout.lastFrame()).toContain('(r:)'); stdin.write('\r'); - await wait(); - expect(stdout.lastFrame()).not.toContain('(r:)'); + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('(r:)'); + }); + expect(props.onSubmit).toHaveBeenCalledWith('echo hello'); unmount(); }); @@ -1268,9 +1274,10 @@ describe('InputPrompt', () => { await wait(); expect(stdout.lastFrame()).toContain('(r:)'); stdin.write('\x1B'); - await wait(); - expect(stdout.lastFrame()).not.toContain('(r:)'); + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('(r:)'); + }); expect(props.buffer.text).toBe('initial text'); expect(props.buffer.cursor).toEqual([0, 3]); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 13305ff4..88577479 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -51,6 +51,7 @@ export const useSlashCommandProcessor = ( openPrivacyNotice: () => void, toggleVimEnabled: () => Promise, setIsProcessing: (isProcessing: boolean) => void, + setGeminiMdFileCount: (count: number) => void, ) => { const session = useSessionStats(); const [commands, setCommands] = useState([]); @@ -163,6 +164,7 @@ export const useSlashCommandProcessor = ( setPendingItem: setPendingCompressionItem, toggleCorgiMode, toggleVimEnabled, + setGeminiMdFileCount, }, session: { stats: session.stats, @@ -187,6 +189,7 @@ export const useSlashCommandProcessor = ( toggleCorgiMode, toggleVimEnabled, sessionShellAllowlist, + setGeminiMdFileCount, ], ); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts new file mode 100644 index 00000000..ce79c4df --- /dev/null +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -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 '@qwen-code/qwen-code-core'; +import { + createTmpDir, + cleanupTmpDir, + FileSystemStructure, +} from '@qwen-code/qwen-code-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([]); + 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); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts new file mode 100644 index 00000000..71768690 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -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 '@qwen-code/qwen-code-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(null); + const searchAbortController = useRef(null); + const slowSearchTimer = useRef(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]); +} diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index 1f6d9a06..ac5aca9d 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -9,33 +9,84 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useCommandCompletion } from './useCommandCompletion.js'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import * as os from 'os'; -import { CommandContext, SlashCommand } from '../commands/types.js'; -import { Config, FileDiscoveryService } from '@qwen-code/qwen-code-core'; +import { CommandContext } from '../commands/types.js'; +import { Config } from '@qwen-code/qwen-code-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', () => { - let testRootDir: string; - let mockConfig: Config; - - // A minimal mock is sufficient for these tests. const mockCommandContext = {} as CommandContext; - let testDirs: string[]; - - async function createEmptyDir(...pathSegments: string[]) { - const fullPath = path.join(testRootDir, ...pathSegments); - await fs.mkdir(fullPath, { recursive: true }); - return fullPath; - } - - async function createTestFile(content: string, ...pathSegments: string[]) { - const fullPath = path.join(testRootDir, ...pathSegments); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, content); - return fullPath; - } + const mockConfig = {} as Config; + const testDirs: string[] = []; + const testRootDir = '/'; // Helper to create real TextBuffer objects within renderHook function useTextBufferForTest(text: string, cursorOffset?: number) { @@ -48,45 +99,25 @@ describe('useCommandCompletion', () => { }); } - beforeEach(async () => { - testRootDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'slash-completion-unit-test-'), - ); - testDirs = [testRootDir]; - mockConfig = { - getTargetDir: () => testRootDir, - getWorkspaceContext: () => ({ - getDirectories: () => testDirs, - }), - getProjectRoot: () => testRootDir, - getFileFilteringOptions: vi.fn(() => ({ - respectGitIgnore: true, - respectGeminiIgnore: true, - })), - getEnableRecursiveFileSearch: vi.fn(() => true), - getFileService: vi.fn(() => new FileDiscoveryService(testRootDir)), - } as unknown as Config; - + beforeEach(() => { vi.clearAllMocks(); + // Reset to default mocks before each test + setupMocks({}); }); - afterEach(async () => { + afterEach(() => { vi.restoreAllMocks(); - await fs.rm(testRootDir, { recursive: true, force: true }); }); describe('Core Hook Behavior', () => { describe('State Management', () => { it('should initialize with default state', () => { - const slashCommands = [ - { name: 'dummy', description: 'dummy' }, - ] as unknown as SlashCommand[]; const { result } = renderHook(() => useCommandCompletion( useTextBufferForTest(''), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, mockConfig, @@ -100,56 +131,51 @@ describe('useCommandCompletion', () => { expect(result.current.isLoadingSuggestions).toBe(false); }); - it('should reset state when isActive becomes false', () => { - const slashCommands = [ - { - name: 'help', - altNames: ['?'], - description: 'Show help', - action: vi.fn(), - }, - ] as unknown as SlashCommand[]; + it('should reset state when completion mode becomes IDLE', async () => { + setupMocks({ + atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], + }); - const { result, rerender } = renderHook( - ({ text }) => { - const textBuffer = useTextBufferForTest(text); - return useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - mockConfig, - ); - }, - { initialProps: { text: '/help' } }, - ); - - // Inactive because of the leading space - rerender({ text: ' /help' }); - - 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 all state to default values', async () => { - const slashCommands = [ - { - name: 'help', - description: 'Show help', - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/help'), + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@file'); + const completion = useCommandCompletion( + textBuffer, testDirs, testRootDir, - slashCommands, + [], + 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, @@ -165,30 +191,84 @@ describe('useCommandCompletion', () => { result.current.resetCompletionState(); }); - // Wait for async suggestions clearing - await waitFor(() => { - expect(result.current.suggestions).toEqual([]); - }); - - 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 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', () => { - const slashCommands = [ - { name: 'dummy', description: 'dummy' }, - ] as unknown as SlashCommand[]; + setupMocks({ slashSuggestions: [] }); + const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest(''), + useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, mockConfig, @@ -203,18 +283,15 @@ describe('useCommandCompletion', () => { }); it('should handle navigateDown with no suggestions', () => { - const slashCommands = [ - { name: 'dummy', description: 'dummy' }, - ] as unknown as SlashCommand[]; + setupMocks({ slashSuggestions: [] }); const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest(''), + useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, - mockConfig, ), ); @@ -226,930 +303,127 @@ describe('useCommandCompletion', () => { expect(result.current.activeSuggestionIndex).toBe(-1); }); - it('should navigate up through suggestions with wrap-around', () => { - const slashCommands = [ - { - name: 'help', - description: 'Show help', - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/h'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ), - ); - - expect(result.current.suggestions.length).toBe(1); - expect(result.current.activeSuggestionIndex).toBe(0); - - act(() => { - result.current.navigateUp(); - }); - - expect(result.current.activeSuggestionIndex).toBe(0); - }); - - it('should navigate down through suggestions with wrap-around', () => { - const slashCommands = [ - { - name: 'help', - description: 'Show help', - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/h'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ), - ); - - expect(result.current.suggestions.length).toBe(1); - expect(result.current.activeSuggestionIndex).toBe(0); - - act(() => { - result.current.navigateDown(); - }); - - expect(result.current.activeSuggestionIndex).toBe(0); - }); - - it('should handle navigation with multiple suggestions', () => { - const slashCommands = [ - { name: 'help', description: 'Show help' }, - { name: 'stats', description: 'Show stats' }, - { name: 'clear', description: 'Clear screen' }, - { name: 'memory', description: 'Manage memory' }, - { name: 'chat', description: 'Manage chat' }, - ] as unknown as SlashCommand[]; + it('should navigate up through suggestions with wrap-around', async () => { const { result } = renderHook(() => useCommandCompletion( useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, - mockConfig, ), ); - expect(result.current.suggestions.length).toBe(5); - expect(result.current.activeSuggestionIndex).toBe(0); - - act(() => { - result.current.navigateDown(); + await waitFor(() => { + expect(result.current.suggestions.length).toBe(5); }); - 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) => ({ - name: `command${i}`, - description: `Command ${i}`, - })) as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/command'), - testDirs, - testRootDir, - largeMockCommands, - mockCommandContext, - false, - - mockConfig, - ), - ); - - 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('Slash Command Completion (`/`)', () => { - 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[]; + it('should navigate down through suggestions with wrap-around', async () => { const { result } = renderHook(() => useCommandCompletion( useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, + false, + mockConfig, ), ); - expect(result.current.suggestions.length).toBe(slashCommands.length); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']), - ); + 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 filter commands based on partial input', async () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - }, - ] as unknown as SlashCommand[]; + it('should handle navigation with multiple suggestions', async () => { const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('/mem'), + useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, + false, + mockConfig, ), ); - expect(result.current.suggestions).toEqual([ - { label: 'memory', value: 'memory', description: 'Manage memory' }, - ]); - expect(result.current.showSuggestions).toBe(true); + 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 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[]; + it('should automatically select the first item when suggestions are available', async () => { + setupMocks({ slashSuggestions: mockSuggestions }); + const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('/usag'), // part of the word "usage" + useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, + false, + mockConfig, ), ); - 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(() => - useCommandCompletion( - useTextBufferForTest('/clear'), // No trailing space - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - - 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(() => - useCommandCompletion( - useTextBufferForTest(query), - testDirs, - testRootDir, - mockSlashCommands, - mockCommandContext, - ), + await waitFor(() => { + expect(result.current.suggestions.length).toBe( + mockSuggestions.length, ); - - 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(() => - useCommandCompletion( - useTextBufferForTest('/clear '), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - - it('should not provide suggestions for an unknown command', async () => { - const slashCommands = [ - { - name: 'help', - description: 'Show help', - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/unknown-command'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - - 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(() => - useCommandCompletion( - useTextBufferForTest('/memory'), // Note: no trailing space - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - // Assert that suggestions for sub-commands are shown immediately - 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' }, - ]), - ); - expect(result.current.showSuggestions).toBe(true); - }); - - 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(() => - useCommandCompletion( - useTextBufferForTest('/memory'), - testDirs, - testRootDir, - 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(() => - useCommandCompletion( - useTextBufferForTest('/memory a'), - testDirs, - testRootDir, - 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(() => - useCommandCompletion( - useTextBufferForTest('/memory dothisnow'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - - 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(() => - useCommandCompletion( - useTextBufferForTest('/chat resume my-ch'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); + expect(result.current.activeSuggestionIndex).toBe(0); }); - - expect(mockCompletionFn).toHaveBeenCalledWith( - mockCommandContext, - 'my-ch', - ); - - 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(() => - useCommandCompletion( - useTextBufferForTest('/chat resume '), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, ''); - expect(result.current.suggestions).toHaveLength(3); - expect(result.current.showSuggestions).toBe(true); - }); - - 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(() => - useCommandCompletion( - useTextBufferForTest('/chat resume '), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - }); - - describe('File Path Completion (`@`)', () => { - describe('Basic Completion', () => { - it('should use glob for top-level @ completions when available', async () => { - await createTestFile('', 'src', 'index.ts'); - await createTestFile('', 'derp', 'script.ts'); - await createTestFile('', 'README.md'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@s'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { - label: 'derp/script.ts', - value: 'derp/script.ts', - }, - { label: 'src', value: 'src' }, - ]), - ); - }); - - it('should handle directory-specific completions with git filtering', async () => { - await createEmptyDir('.git'); - await createTestFile('*.log', '.gitignore'); - await createTestFile('', 'src', 'component.tsx'); - await createTestFile('', 'src', 'temp.log'); - await createTestFile('', 'src', 'index.ts'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@src/comp'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Should filter out .log files but include matching .tsx files - expect(result.current.suggestions).toEqual([ - { label: 'component.tsx', value: 'component.tsx' }, - ]); - }); - - it('should include dotfiles in glob search when input starts with a dot', async () => { - await createTestFile('', '.env'); - await createTestFile('', '.gitignore'); - await createTestFile('', 'src', 'index.ts'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@.'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toEqual([ - { label: '.env', value: '.env' }, - { label: '.gitignore', value: '.gitignore' }, - ]); - }); - }); - - describe('Configuration-based Behavior', () => { - it('should not perform recursive search when disabled in config', async () => { - const mockConfigNoRecursive = { - ...mockConfig, - getEnableRecursiveFileSearch: vi.fn(() => false), - } as unknown as Config; - - await createEmptyDir('data'); - await createEmptyDir('dist'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@d'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfigNoRecursive, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toEqual([ - { label: 'data/', value: 'data/' }, - { label: 'dist/', value: 'dist/' }, - ]); - }); - - it('should work without config (fallback behavior)', async () => { - await createEmptyDir('src'); - await createEmptyDir('node_modules'); - await createTestFile('', 'README.md'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - undefined, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Without config, should include all files - expect(result.current.suggestions).toHaveLength(3); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { label: 'src/', value: 'src/' }, - { label: 'node_modules/', value: 'node_modules/' }, - { label: 'README.md', value: 'README.md' }, - ]), - ); - }); - - it('should handle git discovery service initialization failure gracefully', async () => { - // Intentionally don't create a .git directory to cause an initialization failure. - await createEmptyDir('src'); - await createTestFile('', 'README.md'); - - const consoleSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Since we use centralized service, initialization errors are handled at config level - // This test should verify graceful fallback behavior - expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0); - // Should still show completions even if git discovery fails - expect(result.current.suggestions.length).toBeGreaterThan(0); - - consoleSpy.mockRestore(); - }); - }); - - describe('Git-Aware Filtering', () => { - it('should filter git-ignored entries from @ completions', async () => { - await createEmptyDir('.git'); - await createTestFile('dist', '.gitignore'); - await createEmptyDir('data'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@d'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - // Wait for async operations to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce - }); - - expect(result.current.suggestions).toEqual( - expect.arrayContaining([{ label: 'data', value: 'data' }]), - ); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should filter git-ignored directories from @ completions', async () => { - await createEmptyDir('.git'); - await createTestFile('node_modules\ndist\n.env', '.gitignore'); - // gitignored entries - await createEmptyDir('node_modules'); - await createEmptyDir('dist'); - await createTestFile('', '.env'); - - // visible - await createEmptyDir('src'); - await createTestFile('', 'README.md'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - // Wait for async operations to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce - }); - - expect(result.current.suggestions).toEqual([ - { label: 'README.md', value: 'README.md' }, - { label: 'src/', value: 'src/' }, - ]); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should handle recursive search with git-aware filtering', async () => { - await createEmptyDir('.git'); - await createTestFile('node_modules/\ntemp/', '.gitignore'); - await createTestFile('', 'data', 'test.txt'); - await createEmptyDir('dist'); - await createEmptyDir('node_modules'); - await createTestFile('', 'src', 'index.ts'); - await createEmptyDir('src', 'components'); - await createTestFile('', 'temp', 'temp.log'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@t'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Should not include anything from node_modules or dist - const suggestionLabels = result.current.suggestions.map((s) => s.label); - expect(suggestionLabels).not.toContain('temp/'); - expect(suggestionLabels).not.toContain('node_modules/'); }); }); }); describe('handleAutocomplete', () => { - it('should complete a partial command', () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - { - name: 'add', - description: 'Add to memory', - }, - ], - }, - ] as unknown as SlashCommand[]; + 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'); @@ -1157,18 +431,17 @@ describe('useCommandCompletion', () => { textBuffer, testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, - mockConfig, ); return { ...completion, textBuffer }; }); - expect(result.current.suggestions.map((s) => s.value)).toEqual([ - 'memory', - ]); + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); + }); act(() => { result.current.handleAutocomplete(0); @@ -1177,99 +450,11 @@ describe('useCommandCompletion', () => { expect(result.current.textBuffer.text).toBe('/memory '); }); - it('should append a sub-command when the parent is complete', () => { - 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(() => { - const textBuffer = useTextBufferForTest('/memory'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ); - return { ...completion, textBuffer }; + it('should complete a file path', async () => { + setupMocks({ + atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], }); - // Suggestions are populated by useEffect - expect(result.current.suggestions.map((s) => s.value)).toEqual([ - 'show', - 'add', - ]); - - act(() => { - result.current.handleAutocomplete(1); // index 1 is 'add' - }); - - expect(result.current.textBuffer.text).toBe('/memory add '); - }); - - it('should complete a command with an alternative name', () => { - 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(() => { - const textBuffer = useTextBufferForTest('/?'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - result.current.suggestions.push({ - label: 'help', - value: 'help', - description: 'Show help', - }); - - act(() => { - result.current.handleAutocomplete(0); - }); - - expect(result.current.textBuffer.text).toBe('/help '); - }); - - it('should complete a file path', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('@src/fi'); const completion = useCommandCompletion( @@ -1284,9 +469,8 @@ describe('useCommandCompletion', () => { return { ...completion, textBuffer }; }); - result.current.suggestions.push({ - label: 'file1.txt', - value: 'file1.txt', + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); }); act(() => { @@ -1296,10 +480,14 @@ describe('useCommandCompletion', () => { 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', () => { - const text = '@src/fi le.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( @@ -1314,303 +502,17 @@ describe('useCommandCompletion', () => { return { ...completion, textBuffer }; }); - result.current.suggestions.push({ - label: 'file1.txt', - value: 'file1.txt', + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); }); act(() => { result.current.handleAutocomplete(0); }); - expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt'); - }); - - it('should complete the correct file path with multiple @-commands', () => { - const text = '@file1.txt @src/fi'; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(text); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - result.current.suggestions.push({ - label: 'file2.txt', - value: 'file2.txt', - }); - - act(() => { - result.current.handleAutocomplete(0); - }); - - expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt '); - }); - }); - - describe('File Path Escaping', () => { - it('should escape special characters in file names', async () => { - await createTestFile('', 'my file.txt'); - await createTestFile('', 'file(1).txt'); - await createTestFile('', 'backup[old].txt'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@my'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), + expect(result.current.textBuffer.text).toBe( + '@src/file1.txt is a good file', ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'my file.txt', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe('my\\ file.txt'); - }); - - it('should escape parentheses in file names', async () => { - await createTestFile('', 'document(final).docx'); - await createTestFile('', 'script(v2).sh'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@doc'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'document(final).docx', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe('document\\(final\\).docx'); - }); - - it('should escape square brackets in file names', async () => { - await createTestFile('', 'backup[2024-01-01].zip'); - await createTestFile('', 'config[dev].json'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@backup'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'backup[2024-01-01].zip', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe('backup\\[2024-01-01\\].zip'); - }); - - it('should escape multiple special characters in file names', async () => { - await createTestFile('', 'my file (backup) [v1.2].txt'); - await createTestFile('', 'data & config {prod}.json'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@my'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'my file (backup) [v1.2].txt', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe( - 'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt', - ); - }); - - it('should preserve path separators while escaping special characters', async () => { - await createTestFile( - '', - 'projects', - 'my project (2024)', - 'file with spaces.txt', - ); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@projects/my'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find((s) => - s.label.includes('my project'), - ); - expect(suggestion).toBeDefined(); - // Should escape spaces and parentheses but preserve forward slashes - expect(suggestion!.value).toMatch(/my\\ project\\ \\\(2024\\\)/); - expect(suggestion!.value).toContain('/'); // Should contain forward slash for path separator - }); - - it('should normalize Windows path separators to forward slashes while preserving escaping', async () => { - // Create test with complex nested structure - await createTestFile( - '', - 'deep', - 'nested', - 'special folder', - 'file with (parentheses).txt', - ); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@deep/nested/special'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find((s) => - s.label.includes('special folder'), - ); - expect(suggestion).toBeDefined(); - // Should use forward slashes for path separators and escape spaces - expect(suggestion!.value).toContain('special\\ folder/'); - expect(suggestion!.value).not.toContain('\\\\'); // Should not contain double backslashes for path separators - }); - - it('should handle directory names with special characters', async () => { - await createEmptyDir('my documents (personal)'); - await createEmptyDir('config [production]'); - await createEmptyDir('data & logs'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestions = result.current.suggestions; - - const docSuggestion = suggestions.find( - (s) => s.label === 'my documents (personal)/', - ); - expect(docSuggestion).toBeDefined(); - expect(docSuggestion!.value).toBe('my\\ documents\\ \\(personal\\)/'); - - const configSuggestion = suggestions.find( - (s) => s.label === 'config [production]/', - ); - expect(configSuggestion).toBeDefined(); - expect(configSuggestion!.value).toBe('config\\ \\[production\\]/'); - - const dataSuggestion = suggestions.find( - (s) => s.label === 'data & logs/', - ); - expect(dataSuggestion).toBeDefined(); - expect(dataSuggestion!.value).toBe('data\\ \\&\\ logs/'); - }); - - it('should handle files with various shell metacharacters', async () => { - await createTestFile('', 'file$var.txt'); - await createTestFile('', 'important!.md'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestions = result.current.suggestions; - - const dollarSuggestion = suggestions.find( - (s) => s.label === 'file$var.txt', - ); - expect(dollarSuggestion).toBeDefined(); - expect(dollarSuggestion!.value).toBe('file\\$var.txt'); - - const importantSuggestion = suggestions.find( - (s) => s.label === 'important!.md', - ); - expect(importantSuggestion).toBeDefined(); - expect(importantSuggestion!.value).toBe('important\\!.md'); }); }); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 7f4640e7..234ca93c 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -4,20 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { 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, - SHELL_SPECIAL_CHARS, -} from '@qwen-code/qwen-code-core'; +import { useCallback, useMemo, useEffect } from 'react'; import { Suggestion } from '../components/SuggestionsDisplay.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { @@ -26,8 +13,17 @@ import { } 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 '@qwen-code/qwen-code-core'; import { useCompletion } from './useCompletion.js'; +export enum CompletionMode { + IDLE = 'IDLE', + AT = 'AT', + SLASH = 'SLASH', +} + export interface UseCommandCompletionReturn { suggestions: Suggestion[]; activeSuggestionIndex: number; @@ -72,541 +68,109 @@ export function useCommandCompletion( navigateDown, } = useCompletion(); - const completionStart = useRef(-1); - const completionEnd = useRef(-1); - const cursorRow = buffer.cursor[0]; const cursorCol = buffer.cursor[1]; - // 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; + 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, + }; } - } - return -1; - }, [cursorRow, cursorCol, buffer.lines]); + 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(() => { - if (commandIndex === -1 || reverseSearchActive) { - setTimeout(resetCompletionState, 0); - return; - } + setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1); + setVisibleStartIndex(0); + }, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]); - 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 - command has action, no sub-commands were suggested - setIsPerfectMatch(true); - } else if (currentLevel) { - // Case: /command subcommand - 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. + useEffect(() => { + if (completionMode === CompletionMode.IDLE || reverseSearchActive) { 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 => { - 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 => { - 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 forward slashes for path separators, even on Windows. - // But preserve backslash escaping for special characters. - const specialCharsLookahead = `(?![${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`; - const pathSeparatorRegex = new RegExp( - `\\\\${specialCharsLookahead}`, - 'g', - ); - fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({ - ...suggestion, - label: suggestion.label.replace(pathSeparatorRegex, '/'), - value: suggestion.value.replace(pathSeparatorRegex, '/'), - })); - - // 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); - }; + // Show suggestions if we are loading OR if there are results to display. + setShowSuggestions(isLoadingSuggestions || suggestions.length > 0); }, [ - buffer.text, - cursorRow, - cursorCol, - buffer.lines, - dirs, - cwd, - commandIndex, - resetCompletionState, - slashCommands, - commandContext, - config, + completionMode, + suggestions.length, + isLoadingSuggestions, reverseSearchActive, - setSuggestions, + resetCompletionState, setShowSuggestions, - setActiveSuggestionIndex, - setIsLoadingSuggestions, - setIsPerfectMatch, - setVisibleStartIndex, ]); const handleAutocomplete = useCallback( @@ -616,18 +180,23 @@ export function useCommandCompletion( } const suggestion = suggestions[indexToUse].value; - if (completionStart.current === -1 || completionEnd.current === -1) { + let start = completionStart; + let end = completionEnd; + if (completionMode === CompletionMode.SLASH) { + start = slashCompletionRange.completionStart; + end = slashCompletionRange.completionEnd; + } + + if (start === -1 || end === -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 (completionMode === CompletionMode.SLASH) { if ( - completionStart.current === completionEnd.current && - completionStart.current > commandIndex + 1 && - (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' ' + start === end && + start > 1 && + (buffer.lines[cursorRow] || '')[start - 1] !== ' ' ) { suggestionText = ' ' + suggestionText; } @@ -636,12 +205,20 @@ export function useCommandCompletion( suggestionText += ' '; buffer.replaceRangeByOffset( - logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), - logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), + logicalPosToOffset(buffer.lines, cursorRow, start), + logicalPosToOffset(buffer.lines, cursorRow, end), suggestionText, ); }, - [cursorRow, buffer, suggestions, commandIndex], + [ + cursorRow, + buffer, + suggestions, + completionMode, + completionStart, + completionEnd, + slashCompletionRange, + ], ); return { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 85614d3b..036eb2e7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -93,6 +93,7 @@ export const useGeminiStream = ( performMemoryRefresh: () => Promise, modelSwitchedFromQuotaError: boolean, setModelSwitchedFromQuotaError: React.Dispatch>, + onEditorClose: () => void, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); @@ -133,6 +134,7 @@ export const useGeminiStream = ( config, setPendingHistoryItem, getPreferredEditor, + onEditorClose, ); const pendingToolCallGroupDisplay = useMemo( diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 83d68601..e87b4a03 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -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...', diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 7fdcb590..d360b7fa 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -70,6 +70,7 @@ export function useReactToolScheduler( React.SetStateAction >, 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, ], ); diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx index 1cc7e602..f2c22c2a 100644 --- a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx +++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx @@ -41,12 +41,17 @@ export function useReverseSearchCompletion( navigateDown, } = useCompletion(); - // whenever reverseSearchActive is on, filter history useEffect(() => { if (!reverseSearchActive) { resetCompletionState(); + } + }, [reverseSearchActive, resetCompletionState]); + + useEffect(() => { + if (!reverseSearchActive) { return; } + const q = buffer.text.toLowerCase(); const matches = shellHistory.reduce((acc, cmd) => { const idx = cmd.toLowerCase().indexOf(q); @@ -62,7 +67,6 @@ export function useReverseSearchCompletion( buffer.text, shellHistory, reverseSearchActive, - resetCompletionState, setActiveSuggestionIndex, setShowSuggestions, setSuggestions, diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts new file mode 100644 index 00000000..ba26f2d2 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -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([]); + 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); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts new file mode 100644 index 00000000..9836362f --- /dev/null +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -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, + }; +} diff --git a/packages/cli/src/ui/hooks/vim.test.ts b/packages/cli/src/ui/hooks/vim.test.ts index f939982f..0139119e 100644 --- a/packages/cli/src/ui/hooks/vim.test.ts +++ b/packages/cli/src/ui/hooks/vim.test.ts @@ -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 diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index cb65e1ee..97b73121 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.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) diff --git a/packages/cli/src/ui/utils/ConsolePatcher.ts b/packages/cli/src/ui/utils/ConsolePatcher.ts index 10be3bc7..a429698d 100644 --- a/packages/cli/src/ui/utils/ConsolePatcher.ts +++ b/packages/cli/src/ui/utils/ConsolePatcher.ts @@ -8,8 +8,9 @@ import util from 'util'; import { ConsoleMessageItem } from '../types.js'; interface ConsolePatcherParams { - onNewMessage: (message: Omit) => void; + onNewMessage?: (message: Omit) => 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, + }); + } } }; } diff --git a/packages/cli/src/utils/resolvePath.ts b/packages/cli/src/utils/resolvePath.ts new file mode 100644 index 00000000..b26ed8fc --- /dev/null +++ b/packages/cli/src/utils/resolvePath.ts @@ -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); +} diff --git a/packages/core/package.json b/packages/core/package.json index 7b56ba4a..b373d17e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,6 +33,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", @@ -41,7 +42,8 @@ "marked": "^15.0.12", "micromatch": "^4.0.8", "open": "^10.1.2", - "openai": "^5.7.0", + "openai": "5.11.0", + "picomatch": "^4.0.1", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", @@ -50,10 +52,12 @@ "ws": "^8.18.0" }, "devDependencies": { + "@qwen-code/qwen-code-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" diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 176f444d..b79f46dc 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -18,7 +18,18 @@ 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(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + statSync: vi.fn().mockReturnValue({ + isDirectory: vi.fn().mockReturnValue(true), + }), + realpathSync: vi.fn((path) => path), + }; +}); vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); @@ -120,7 +131,6 @@ describe('Server Config (config.ts)', () => { telemetry: TELEMETRY_SETTINGS, sessionId: SESSION_ID, model: MODEL, - ideClient: IdeClient.getInstance(false), }; beforeEach(() => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index af6b070a..9fb222b6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -48,6 +48,8 @@ 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 }; @@ -196,7 +198,6 @@ export interface ConfigParameters { summarizeToolOutput?: Record; ideModeFeature?: boolean; ideMode?: boolean; - ideClient?: IdeClient; enableOpenAILogging?: boolean; sampling_params?: Record; systemPromptMappings?: Array<{ @@ -209,6 +210,7 @@ export interface ConfigParameters { maxRetries?: number; }; cliVersion?: string; + loadMemoryFromIncludeDirectories?: boolean; } export class Config { @@ -283,6 +285,8 @@ export class Config { maxRetries?: number; }; private readonly cliVersion?: string; + private readonly loadMemoryFromIncludeDirectories: boolean = false; + constructor(params: ConfigParameters) { this.sessionId = params.sessionId; this.embeddingModel = @@ -345,15 +349,20 @@ export class Config { this.summarizeToolOutput = params.summarizeToolOutput; this.ideModeFeature = params.ideModeFeature ?? false; this.ideMode = params.ideMode ?? false; - this.ideClient = - params.ideClient ?? - IdeClient.getInstance(this.ideMode && this.ideModeFeature); + this.ideClient = IdeClient.getInstance(); + if (this.ideMode && this.ideModeFeature) { + this.ideClient.connect(); + logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.START)); + } this.systemPromptMappings = params.systemPromptMappings; this.enableOpenAILogging = params.enableOpenAILogging ?? false; this.sampling_params = params.sampling_params; this.contentGenerator = params.contentGenerator; this.cliVersion = params.cliVersion; + this.loadMemoryFromIncludeDirectories = + params.loadMemoryFromIncludeDirectories ?? false; + if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); } @@ -415,6 +424,10 @@ export class Config { return this.sessionId; } + shouldLoadMemoryFromIncludeDirectories(): boolean { + return this.loadMemoryFromIncludeDirectories; + } + getContentGeneratorConfig(): ContentGeneratorConfig { return this.contentGeneratorConfig; } @@ -698,12 +711,14 @@ export class Config { this.ideMode = value; } - setIdeClientDisconnected(): void { - this.ideClient.setDisconnected(); - } - - setIdeClientConnected(): void { - this.ideClient.reconnect(this.ideMode && this.ideModeFeature); + async setIdeModeAndSyncConnection(value: boolean): Promise { + this.ideMode = value; + if (value) { + await this.ideClient.connect(); + logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.SESSION)); + } else { + this.ideClient.disconnect(); + } } getEnableOpenAILogging(): boolean { diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index 0b68f993..a0034ea1 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -7,7 +7,6 @@ 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 +25,6 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: DEFAULT_GEMINI_MODEL, - ideClient: IdeClient.getInstance(false), }); // Initialize contentGeneratorConfig for testing @@ -51,7 +49,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 +72,6 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: 'custom-model', - ideClient: IdeClient.getInstance(false), }); expect(newConfig.getModel()).toBe('custom-model'); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 80651a14..623fb436 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -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(); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index b4c10a64..5f2cc895 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -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, editorType, signal, + this.onEditorClose, ); this.setArgsInternal(callId, updatedParams); this.setStatusInternal(callId, 'awaiting_approval', { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index be24db3e..c00b4fad 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -33,34 +33,58 @@ 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; } + async connect(): Promise { + this.setState(IDEConnectionStatus.Connecting); + + if (!this.currentIde || !this.currentIdeDisplayName) { + this.setState(IDEConnectionStatus.Disconnected); + return; + } + + if (!this.validateWorkspacePath()) { + return; + } + + const port = this.getPortFromEnv(); + if (!port) { + return; + } + + await this.establishConnection(port); + } + + disconnect() { + this.setState( + IDEConnectionStatus.Disconnected, + 'IDE integration disabled. To enable it again, run /ide enable.', + ); + this.client?.close(); + } + getCurrentIde(): DetectedIde | undefined { return this.currentIde; } @@ -70,45 +94,60 @@ export class IdeClient { } private setState(status: IDEConnectionStatus, details?: string) { - this.state = { status, details }; + 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 is disconnected. ', details); + 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, - 'Gemini CLI Companion extension not found. Install via /ide install and restart the CLI in a fresh terminal window.', + `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 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; @@ -120,20 +159,20 @@ export class IdeClient { ideContext.setIdeContext(notification.params); }, ); - this.client.onerror = (_error) => { - this.setState(IDEConnectionStatus.Disconnected, 'Client 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, 'Connection closed.'); + this.setState( + IDEConnectionStatus.Disconnected, + `IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`, + ); }; } - async reconnect(ideMode: boolean) { - IdeClient.instance = new IdeClient(ideMode); - } - private async establishConnection(port: string) { let transport: StreamableHTTPClientTransport | undefined; try { @@ -150,12 +189,12 @@ export class IdeClient { this.registerClientHandlers(); await this.client.connect(transport); - + this.registerClientHandlers(); this.setState(IDEConnectionStatus.Connected); - } catch (error) { + } catch (_error) { this.setState( IDEConnectionStatus.Disconnected, - `Failed to connect to IDE server: ${error}`, + `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 { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 57ed8672..e0f8efa3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,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'; diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 5bfd637b..3991aecc 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -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'); }); }); }); diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index ff21d6d7..b876655b 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -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; diff --git a/packages/core/src/prompts/prompt-registry.ts b/packages/core/src/prompts/prompt-registry.ts index 56699130..a94183ac 100644 --- a/packages/core/src/prompts/prompt-registry.ts +++ b/packages/core/src/prompts/prompt-registry.ts @@ -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); + } + } + } } diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 6b85a664..649d82b6 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -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; @@ -578,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 = [ { diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index 0c270f63..7ac56038 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -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( diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index bcd0cf26..f2ab49f6 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -15,6 +15,7 @@ export const EVENT_CLI_CONFIG = 'qwen-code.config'; export const EVENT_FLASH_FALLBACK = 'qwen-code.flash_fallback'; export const EVENT_NEXT_SPEAKER_CHECK = 'qwen-code.next_speaker_check'; export const EVENT_SLASH_COMMAND = 'qwen-code.slash_command'; +export const EVENT_IDE_CONNECTION = 'qwen-code.ide_connection'; export const METRIC_TOOL_CALL_COUNT = 'qwen-code.tool.call.count'; export const METRIC_TOOL_CALL_LATENCY = 'qwen-code.tool.call.latency'; diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index d93580e5..a4ba104a 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -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,23 @@ export function logSlashCommand( }; logger.emit(logRecord); } + +export function logIdeConnection( + config: Config, + event: IdeConnectionEvent, +): void { + 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); +} diff --git a/packages/core/src/telemetry/telemetry.test.ts b/packages/core/src/telemetry/telemetry.test.ts index 8ebb3d9a..9734e382 100644 --- a/packages/core/src/telemetry/telemetry.test.ts +++ b/packages/core/src/telemetry/telemetry.test.ts @@ -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( diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 22a0c44b..92bce92d 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -314,6 +314,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 @@ -326,4 +343,5 @@ export type TelemetryEvent = | LoopDetectedEvent | NextSpeakerCheckEvent | SlashCommandEvent - | MalformedJsonResponseEvent; + | MalformedJsonResponseEvent + | IdeConnectionEvent; diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index a8289d3b..9997d60e 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -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); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 4d8ba49e..7406b5ba 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -496,6 +496,9 @@ export async function discoverPrompts( promptRegistry: PromptRegistry, ): Promise { 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, diff --git a/packages/core/src/tools/mcp-tool.test.ts b/packages/core/src/tools/mcp-tool.test.ts index b5843b95..f8a9a8ba 100644 --- a/packages/core/src/tools/mcp-tool.test.ts +++ b/packages/core/src/tools/mcp-tool.test.ts @@ -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', () => { diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 9e814bba..3dd62e2b 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -22,6 +22,40 @@ import { type ToolParams = Record; +// 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 { private static readonly allowlist: Set = new Set(); @@ -114,70 +148,145 @@ export class DiscoveredMCPTool extends BaseTool { }, ]; - 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 */ diff --git a/packages/core/src/tools/modifiable-tool.test.ts b/packages/core/src/tools/modifiable-tool.test.ts index ce2f3956..0d92e549 100644 --- a/packages/core/src/tools/modifiable-tool.test.ts +++ b/packages/core/src/tools/modifiable-tool.test.ts @@ -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(); diff --git a/packages/core/src/tools/modifiable-tool.ts b/packages/core/src/tools/modifiable-tool.ts index 2c58ef68..52dcc8fa 100644 --- a/packages/core/src/tools/modifiable-tool.ts +++ b/packages/core/src/tools/modifiable-tool.ts @@ -138,6 +138,7 @@ export async function modifyWithEditor( modifyContext: ModifyContext, editorType: EditorType, _abortSignal: AbortSignal, + onEditorClose: () => void, ): Promise> { const currentContent = await modifyContext.getCurrentContent(originalParams); const proposedContent = @@ -150,7 +151,7 @@ export async function modifyWithEditor( ); try { - await openDiff(oldPath, newPath, editorType); + await openDiff(oldPath, newPath, editorType, onEditorClose); const result = getUpdatedParams( oldPath, newPath, diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 68bb9b0e..6ddd2a08 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -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(); + }); + }); }); diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 771577ec..1fa2e15c 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -70,6 +70,27 @@ export interface ReadManyFilesParams { }; } +/** + * Result type for file processing operations + */ +type FileProcessingResult = + | { + success: true; + filePath: string; + relativePathForDisplay: string; + fileReadResult: NonNullable< + Awaited> + >; + 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 => { + 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}`, + }); } } diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 1ed61759..012e70e6 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -543,3 +543,37 @@ describe('validateToolParams', () => { expect(result).toContain('is not a registered workspace directory'); }); }); + +describe('validateToolParams', () => { + it('should return null for valid directory', () => { + const config = { + getCoreTools: () => undefined, + getExcludeTools: () => undefined, + getTargetDir: () => '/root', + getWorkspaceContext: () => + createMockWorkspaceContext('/root', ['/users/test']), + } as unknown as Config; + const shellTool = new ShellTool(config); + const result = shellTool.validateToolParams({ + command: 'ls', + directory: 'test', + }); + expect(result).toBeNull(); + }); + + it('should return error for directory outside workspace', () => { + const config = { + getCoreTools: () => undefined, + getExcludeTools: () => undefined, + getTargetDir: () => '/root', + getWorkspaceContext: () => + createMockWorkspaceContext('/root', ['/users/test']), + } as unknown as Config; + const shellTool = new ShellTool(config); + const result = shellTool.validateToolParams({ + command: 'ls', + directory: 'test2', + }); + expect(result).toContain('is not a registered workspace directory'); + }); +}); diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index de7c6309..a2be1a93 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -30,7 +30,6 @@ 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 +139,6 @@ const baseConfigParams: ConfigParameters = { geminiMdFileCount: 0, approvalMode: ApprovalMode.DEFAULT, sessionId: 'test-session-id', - ideClient: IdeClient.getInstance(false), }; describe('ToolRegistry', () => { @@ -172,6 +170,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 +355,7 @@ describe('ToolRegistry', () => { mcpServerConfigVal, undefined, toolRegistry, - undefined, + config.getPromptRegistry(), false, ); }); @@ -376,7 +378,7 @@ describe('ToolRegistry', () => { mcpServerConfigVal, undefined, toolRegistry, - undefined, + config.getPromptRegistry(), false, ); }); diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 57627ee0..e60b8f74 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -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 { // 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 { // 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) { diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index 203223ae..afdc2b24 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -331,7 +331,7 @@ describe('editor utils', () => { }), }; (spawn as Mock).mockReturnValue(mockSpawn); - await openDiff('old.txt', 'new.txt', editor); + await openDiff('old.txt', 'new.txt', editor, () => {}); const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; expect(spawn).toHaveBeenCalledWith( diffCommand.command, @@ -361,9 +361,9 @@ describe('editor utils', () => { }), }; (spawn as Mock).mockReturnValue(mockSpawn); - await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow( - 'spawn error', - ); + await expect( + openDiff('old.txt', 'new.txt', editor, () => {}), + ).rejects.toThrow('spawn error'); }); it(`should reject if ${editor} exits with non-zero code`, async () => { @@ -375,9 +375,9 @@ describe('editor utils', () => { }), }; (spawn as Mock).mockReturnValue(mockSpawn); - await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow( - `${editor} exited with code 1`, - ); + await expect( + openDiff('old.txt', 'new.txt', editor, () => {}), + ).rejects.toThrow(`${editor} exited with code 1`); }); } @@ -385,7 +385,7 @@ describe('editor utils', () => { for (const editor of execSyncEditors) { it(`should call execSync for ${editor} on non-windows`, async () => { Object.defineProperty(process, 'platform', { value: 'linux' }); - await openDiff('old.txt', 'new.txt', editor); + await openDiff('old.txt', 'new.txt', editor, () => {}); expect(execSync).toHaveBeenCalledTimes(1); const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; const expectedCommand = `${ @@ -399,7 +399,7 @@ describe('editor utils', () => { it(`should call execSync for ${editor} on windows`, async () => { Object.defineProperty(process, 'platform', { value: 'win32' }); - await openDiff('old.txt', 'new.txt', editor); + await openDiff('old.txt', 'new.txt', editor, () => {}); expect(execSync).toHaveBeenCalledTimes(1); const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; const expectedCommand = `${diffCommand.command} ${diffCommand.args.join( @@ -417,11 +417,46 @@ describe('editor utils', () => { .spyOn(console, 'error') .mockImplementation(() => {}); // @ts-expect-error Testing unsupported editor - await openDiff('old.txt', 'new.txt', 'foobar'); + await openDiff('old.txt', 'new.txt', 'foobar', () => {}); expect(consoleErrorSpy).toHaveBeenCalledWith( 'No diff tool available. Install a supported editor.', ); }); + + describe('onEditorClose callback', () => { + it('should call onEditorClose for execSync editors', async () => { + (execSync as Mock).mockReturnValue(Buffer.from(`/usr/bin/`)); + const onEditorClose = vi.fn(); + await openDiff('old.txt', 'new.txt', 'vim', onEditorClose); + expect(execSync).toHaveBeenCalledTimes(1); + expect(onEditorClose).toHaveBeenCalledTimes(1); + }); + + it('should call onEditorClose for execSync editors when an error is thrown', async () => { + (execSync as Mock).mockImplementation(() => { + throw new Error('test error'); + }); + const onEditorClose = vi.fn(); + openDiff('old.txt', 'new.txt', 'vim', onEditorClose); + expect(execSync).toHaveBeenCalledTimes(1); + expect(onEditorClose).toHaveBeenCalledTimes(1); + }); + + it('should not call onEditorClose for spawn editors', async () => { + const onEditorClose = vi.fn(); + const mockSpawn = { + on: vi.fn((event, cb) => { + if (event === 'close') { + cb(0); + } + }), + }; + (spawn as Mock).mockReturnValue(mockSpawn); + await openDiff('old.txt', 'new.txt', 'vscode', onEditorClose); + expect(spawn).toHaveBeenCalledTimes(1); + expect(onEditorClose).not.toHaveBeenCalled(); + }); + }); }); describe('allowEditorTypeInSandbox', () => { diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 704d1cbb..f22297df 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -164,6 +164,7 @@ export async function openDiff( oldPath: string, newPath: string, editor: EditorType, + onEditorClose: () => void, ): Promise { const diffCommand = getDiffCommand(oldPath, newPath, editor); if (!diffCommand) { @@ -206,10 +207,16 @@ export async function openDiff( process.platform === 'win32' ? `${diffCommand.command} ${diffCommand.args.join(' ')}` : `${diffCommand.command} ${diffCommand.args.map((arg) => `"${arg}"`).join(' ')}`; - execSync(command, { - stdio: 'inherit', - encoding: 'utf8', - }); + try { + execSync(command, { + stdio: 'inherit', + encoding: 'utf8', + }); + } catch (e) { + console.error('Error in onEditorClose callback:', e); + } finally { + onEditorClose(); + } break; } diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index ca121bca..bcdf3fe7 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -426,6 +426,29 @@ describe('fileUtils', () => { expect(result.linesShown).toEqual([6, 10]); }); + it('should identify truncation when reading the end of a file', async () => { + const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); + actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); + + // Read from line 11 to 20. The start is not 0, so it's truncated. + const result = await processSingleFileContent( + testTextFilePath, + tempRootDir, + 10, + 10, + ); + const expectedContent = lines.slice(10, 20).join('\n'); + + expect(result.llmContent).toContain(expectedContent); + expect(result.llmContent).toContain( + '[File content truncated: showing lines 11-20 of 20 total lines. Use offset/limit parameters to view more.]', + ); + expect(result.returnDisplay).toBe('Read lines 11-20 of 20 from test.txt'); + expect(result.isTruncated).toBe(true); // This is the key check for the bug + expect(result.originalLineCount).toBe(20); + expect(result.linesShown).toEqual([11, 20]); + }); + it('should handle limit exceeding file length', async () => { const lines = ['Line 1', 'Line 2']; actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index c016cd4a..96f4b36c 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -299,7 +299,8 @@ export async function processSingleFileContent( return line; }); - const contentRangeTruncated = endLine < originalLineCount; + const contentRangeTruncated = + startLine > 0 || endLine < originalLineCount; const isTruncated = contentRangeTruncated || linesWereTruncatedInLength; let llmTextContent = ''; diff --git a/packages/core/src/utils/filesearch/crawlCache.test.ts b/packages/core/src/utils/filesearch/crawlCache.test.ts new file mode 100644 index 00000000..2feab61a --- /dev/null +++ b/packages/core/src/utils/filesearch/crawlCache.test.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { getCacheKey, read, write, clear } from './crawlCache.js'; + +describe('CrawlCache', () => { + describe('getCacheKey', () => { + it('should generate a consistent hash', () => { + const key1 = getCacheKey('/foo', 'bar'); + const key2 = getCacheKey('/foo', 'bar'); + expect(key1).toBe(key2); + }); + + it('should generate a different hash for different directories', () => { + const key1 = getCacheKey('/foo', 'bar'); + const key2 = getCacheKey('/bar', 'bar'); + expect(key1).not.toBe(key2); + }); + + it('should generate a different hash for different ignore content', () => { + const key1 = getCacheKey('/foo', 'bar'); + const key2 = getCacheKey('/foo', 'baz'); + expect(key1).not.toBe(key2); + }); + }); + + describe('in-memory cache operations', () => { + beforeEach(() => { + // Ensure a clean slate before each test + clear(); + }); + + afterEach(() => { + // Restore real timers after each test that uses fake ones + vi.useRealTimers(); + }); + + it('should write and read data from the cache', () => { + const key = 'test-key'; + const data = ['foo', 'bar']; + write(key, data, 10000); // 10 second TTL + const cachedData = read(key); + expect(cachedData).toEqual(data); + }); + + it('should return undefined for a nonexistent key', () => { + const cachedData = read('nonexistent-key'); + expect(cachedData).toBeUndefined(); + }); + + it('should clear the cache', () => { + const key = 'test-key'; + const data = ['foo', 'bar']; + write(key, data, 10000); + clear(); + const cachedData = read(key); + expect(cachedData).toBeUndefined(); + }); + + it('should automatically evict a cache entry after its TTL expires', async () => { + vi.useFakeTimers(); + const key = 'ttl-key'; + const data = ['foo']; + const ttl = 5000; // 5 seconds + + write(key, data, ttl); + + // Should exist immediately after writing + expect(read(key)).toEqual(data); + + // Advance time just before expiration + await vi.advanceTimersByTimeAsync(ttl - 1); + expect(read(key)).toEqual(data); + + // Advance time past expiration + await vi.advanceTimersByTimeAsync(1); + expect(read(key)).toBeUndefined(); + }); + + it('should reset the timer when an entry is updated', async () => { + vi.useFakeTimers(); + const key = 'update-key'; + const initialData = ['initial']; + const updatedData = ['updated']; + const ttl = 5000; // 5 seconds + + // Write initial data + write(key, initialData, ttl); + + // Advance time, but not enough to expire + await vi.advanceTimersByTimeAsync(3000); + expect(read(key)).toEqual(initialData); + + // Update the data, which should reset the timer + write(key, updatedData, ttl); + expect(read(key)).toEqual(updatedData); + + // Advance time again. If the timer wasn't reset, the total elapsed + // time (3000 + 3000 = 6000) would cause an eviction. + await vi.advanceTimersByTimeAsync(3000); + expect(read(key)).toEqual(updatedData); + + // Advance past the new expiration time + await vi.advanceTimersByTimeAsync(2001); + expect(read(key)).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/utils/filesearch/crawlCache.ts b/packages/core/src/utils/filesearch/crawlCache.ts new file mode 100644 index 00000000..3cc948c6 --- /dev/null +++ b/packages/core/src/utils/filesearch/crawlCache.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; + +const crawlCache = new Map(); +const cacheTimers = new Map(); + +/** + * Generates a unique cache key based on the project directory and the content + * of ignore files. This ensures that the cache is invalidated if the project + * or ignore rules change. + */ +export const getCacheKey = ( + directory: string, + ignoreContent: string, +): string => { + const hash = crypto.createHash('sha256'); + hash.update(directory); + hash.update(ignoreContent); + return hash.digest('hex'); +}; + +/** + * Reads cached data from the in-memory cache. + * Returns undefined if the key is not found. + */ +export const read = (key: string): string[] | undefined => crawlCache.get(key); + +/** + * Writes data to the in-memory cache and sets a timer to evict it after the TTL. + */ +export const write = (key: string, results: string[], ttlMs: number): void => { + // Clear any existing timer for this key to prevent premature deletion + if (cacheTimers.has(key)) { + clearTimeout(cacheTimers.get(key)!); + } + + // Store the new data + crawlCache.set(key, results); + + // Set a timer to automatically delete the cache entry after the TTL + const timerId = setTimeout(() => { + crawlCache.delete(key); + cacheTimers.delete(key); + }, ttlMs); + + // Store the timer handle so we can clear it if the entry is updated + cacheTimers.set(key, timerId); +}; + +/** + * Clears the entire cache and all active timers. + * Primarily used for testing. + */ +export const clear = (): void => { + for (const timerId of cacheTimers.values()) { + clearTimeout(timerId); + } + crawlCache.clear(); + cacheTimers.clear(); +}; diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts new file mode 100644 index 00000000..3a7200cd --- /dev/null +++ b/packages/core/src/utils/filesearch/fileSearch.test.ts @@ -0,0 +1,642 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as cache from './crawlCache.js'; +import { FileSearch, AbortError, filter } from './fileSearch.js'; +import { createTmpDir, cleanupTmpDir } from '@qwen-code/qwen-code-test-utils'; + +type FileSearchWithPrivateMethods = FileSearch & { + performCrawl: () => Promise; +}; + +describe('FileSearch', () => { + let tmpDir: string; + afterEach(async () => { + if (tmpDir) { + await cleanupTmpDir(tmpDir); + } + vi.restoreAllMocks(); + }); + + it('should use .geminiignore rules', async () => { + tmpDir = await createTmpDir({ + '.geminiignore': 'dist/', + dist: ['ignored.js'], + src: ['not-ignored.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: true, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['src/', '.geminiignore', 'src/not-ignored.js']); + }); + + it('should combine .gitignore and .geminiignore rules', async () => { + tmpDir = await createTmpDir({ + '.gitignore': 'dist/', + '.geminiignore': 'build/', + dist: ['ignored-by-git.js'], + build: ['ignored-by-gemini.js'], + src: ['not-ignored.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: true, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'src/', + '.geminiignore', + '.gitignore', + 'src/not-ignored.js', + ]); + }); + + it('should use ignoreDirs option', async () => { + tmpDir = await createTmpDir({ + logs: ['some.log'], + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: ['logs'], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['src/', 'src/main.js']); + }); + + it('should handle negated directories', async () => { + tmpDir = await createTmpDir({ + '.gitignore': ['build/**', '!build/public', '!build/public/**'].join( + '\n', + ), + build: { + 'private.js': '', + public: ['index.html'], + }, + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'build/', + 'build/public/', + 'src/', + '.gitignore', + 'build/public/index.html', + 'src/main.js', + ]); + }); + + it('should filter results with a search pattern', async () => { + tmpDir = await createTmpDir({ + src: { + 'main.js': '', + 'util.ts': '', + 'style.css': '', + }, + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search('**/*.js'); + + expect(results).toEqual(['src/main.js']); + }); + + it('should handle root-level file negation', async () => { + tmpDir = await createTmpDir({ + '.gitignore': ['*.mk', '!Foo.mk'].join('\n'), + 'bar.mk': '', + 'Foo.mk': '', + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['.gitignore', 'Foo.mk']); + }); + + it('should handle directory negation with glob', async () => { + tmpDir = await createTmpDir({ + '.gitignore': [ + 'third_party/**', + '!third_party/foo', + '!third_party/foo/bar', + '!third_party/foo/bar/baz_buffer', + ].join('\n'), + third_party: { + foo: { + bar: { + baz_buffer: '', + }, + }, + ignore_this: '', + }, + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'third_party/', + 'third_party/foo/', + 'third_party/foo/bar/', + '.gitignore', + 'third_party/foo/bar/baz_buffer', + ]); + }); + + it('should correctly handle negated patterns in .gitignore', async () => { + tmpDir = await createTmpDir({ + '.gitignore': ['dist/**', '!dist/keep.js'].join('\n'), + dist: ['ignore.js', 'keep.js'], + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'dist/', + 'src/', + '.gitignore', + 'dist/keep.js', + 'src/main.js', + ]); + }); + + // New test cases start here + + it('should initialize correctly when ignore files are missing', async () => { + tmpDir = await createTmpDir({ + src: ['file1.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: true, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + // Expect no errors to be thrown during initialization + await expect(fileSearch.initialize()).resolves.toBeUndefined(); + const results = await fileSearch.search(''); + expect(results).toEqual(['src/', 'src/file1.js']); + }); + + it('should respect maxResults option in search', async () => { + tmpDir = await createTmpDir({ + src: { + 'file1.js': '', + 'file2.js': '', + 'file3.js': '', + 'file4.js': '', + }, + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search('**/*.js', { maxResults: 2 }); + + expect(results).toEqual(['src/file1.js', 'src/file2.js']); // Assuming alphabetical sort + }); + + it('should return empty array when no matches are found', async () => { + tmpDir = await createTmpDir({ + src: ['file1.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search('nonexistent-file.xyz'); + + expect(results).toEqual([]); + }); + + it('should throw AbortError when filter is aborted', async () => { + const controller = new AbortController(); + const dummyPaths = Array.from({ length: 5000 }, (_, i) => `file${i}.js`); // Large array to ensure yielding + + const filterPromise = filter(dummyPaths, '*.js', controller.signal); + + // Abort after a short delay to ensure filter has started + setTimeout(() => controller.abort(), 1); + + await expect(filterPromise).rejects.toThrow(AbortError); + }); + + describe('with in-memory cache', () => { + beforeEach(() => { + cache.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should throw an error if search is called before initialization', async () => { + tmpDir = await createTmpDir({}); + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await expect(fileSearch.search('')).rejects.toThrow( + 'Engine not initialized. Call initialize() first.', + ); + }); + + it('should hit the cache for subsequent searches', async () => { + tmpDir = await createTmpDir({ 'file1.js': '' }); + const getOptions = () => ({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: true, + cacheTtl: 10, + }); + + const fs1 = new FileSearch(getOptions()); + const crawlSpy1 = vi.spyOn( + fs1 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs1.initialize(); + expect(crawlSpy1).toHaveBeenCalledTimes(1); + + // Second search should hit the cache because the options are identical + const fs2 = new FileSearch(getOptions()); + const crawlSpy2 = vi.spyOn( + fs2 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs2.initialize(); + expect(crawlSpy2).not.toHaveBeenCalled(); + }); + + it('should miss the cache when ignore rules change', async () => { + tmpDir = await createTmpDir({ + '.gitignore': 'a.txt', + 'a.txt': '', + 'b.txt': '', + }); + const options = { + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: true, + cacheTtl: 10000, + }; + + // Initial search to populate the cache + const fs1 = new FileSearch(options); + const crawlSpy1 = vi.spyOn( + fs1 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs1.initialize(); + const results1 = await fs1.search(''); + expect(crawlSpy1).toHaveBeenCalledTimes(1); + expect(results1).toEqual(['.gitignore', 'b.txt']); + + // Modify the ignore file + await fs.writeFile(path.join(tmpDir, '.gitignore'), 'b.txt'); + + // Second search should miss the cache and trigger a recrawl + const fs2 = new FileSearch(options); + const crawlSpy2 = vi.spyOn( + fs2 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs2.initialize(); + const results2 = await fs2.search(''); + expect(crawlSpy2).toHaveBeenCalledTimes(1); + expect(results2).toEqual(['.gitignore', 'a.txt']); + }); + + it('should miss the cache after TTL expires', async () => { + vi.useFakeTimers(); + tmpDir = await createTmpDir({ 'file1.js': '' }); + const options = { + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: true, + cacheTtl: 10, // 10 seconds + }; + + // Initial search to populate the cache + const fs1 = new FileSearch(options); + await fs1.initialize(); + + // Advance time past the TTL + await vi.advanceTimersByTimeAsync(11000); + + // Second search should miss the cache and trigger a recrawl + const fs2 = new FileSearch(options); + const crawlSpy = vi.spyOn( + fs2 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs2.initialize(); + + expect(crawlSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('should handle empty or commented-only ignore files', async () => { + tmpDir = await createTmpDir({ + '.gitignore': '# This is a comment\n\n \n', + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['src/', '.gitignore', 'src/main.js']); + }); + + it('should always ignore the .git directory', async () => { + tmpDir = await createTmpDir({ + '.git': ['config', 'HEAD'], + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, // Explicitly disable .gitignore to isolate this rule + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['src/', 'src/main.js']); + }); + + it('should be cancellable via AbortSignal', async () => { + const largeDir: Record = {}; + for (let i = 0; i < 100; i++) { + largeDir[`file${i}.js`] = ''; + } + tmpDir = await createTmpDir(largeDir); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + + const controller = new AbortController(); + const searchPromise = fileSearch.search('**/*.js', { + signal: controller.signal, + }); + + // Yield to allow the search to start before aborting. + await new Promise((resolve) => setImmediate(resolve)); + + controller.abort(); + + await expect(searchPromise).rejects.toThrow(AbortError); + }); + + it('should leverage ResultCache for bestBaseQuery optimization', async () => { + tmpDir = await createTmpDir({ + src: { + 'foo.js': '', + 'bar.ts': '', + nested: { + 'baz.js': '', + }, + }, + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: true, // Enable caching for this test + cacheTtl: 0, + }); + + await fileSearch.initialize(); + + // Perform a broad search to prime the cache + const broadResults = await fileSearch.search('src/**'); + expect(broadResults).toEqual([ + 'src/', + 'src/nested/', + 'src/bar.ts', + 'src/foo.js', + 'src/nested/baz.js', + ]); + + // Perform a more specific search that should leverage the broad search's cached results + const specificResults = await fileSearch.search('src/**/*.js'); + expect(specificResults).toEqual(['src/foo.js', 'src/nested/baz.js']); + + // Although we can't directly inspect ResultCache.hits/misses from here, + // the correctness of specificResults after a broad search implicitly + // verifies that the caching mechanism, including bestBaseQuery, is working. + }); + + it('should be case-insensitive by default', async () => { + tmpDir = await createTmpDir({ + 'File1.Js': '', + 'file2.js': '', + 'FILE3.JS': '', + 'other.txt': '', + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + + // Search with a lowercase pattern + let results = await fileSearch.search('file*.js'); + expect(results).toHaveLength(3); + expect(results).toEqual( + expect.arrayContaining(['File1.Js', 'file2.js', 'FILE3.JS']), + ); + + // Search with an uppercase pattern + results = await fileSearch.search('FILE*.JS'); + expect(results).toHaveLength(3); + expect(results).toEqual( + expect.arrayContaining(['File1.Js', 'file2.js', 'FILE3.JS']), + ); + + // Search with a mixed-case pattern + results = await fileSearch.search('FiLe*.Js'); + expect(results).toHaveLength(3); + expect(results).toEqual( + expect.arrayContaining(['File1.Js', 'file2.js', 'FILE3.JS']), + ); + }); + + it('should respect maxResults even when the cache returns an exact match', async () => { + tmpDir = await createTmpDir({ + 'file1.js': '', + 'file2.js': '', + 'file3.js': '', + 'file4.js': '', + 'file5.js': '', + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: true, // Ensure caching is enabled + cacheTtl: 10000, + }); + + await fileSearch.initialize(); + + // 1. Perform a broad search to populate the cache with an exact match. + const initialResults = await fileSearch.search('*.js'); + expect(initialResults).toEqual([ + 'file1.js', + 'file2.js', + 'file3.js', + 'file4.js', + 'file5.js', + ]); + + // 2. Perform the same search again, but this time with a maxResults limit. + const limitedResults = await fileSearch.search('*.js', { maxResults: 2 }); + + // 3. Assert that the maxResults limit was respected, even with a cache hit. + expect(limitedResults).toEqual(['file1.js', 'file2.js']); + }); +}); diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts new file mode 100644 index 00000000..5915821a --- /dev/null +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import { fdir } from 'fdir'; +import picomatch from 'picomatch'; +import { Ignore } from './ignore.js'; +import { ResultCache } from './result-cache.js'; +import * as cache from './crawlCache.js'; + +export type FileSearchOptions = { + projectRoot: string; + ignoreDirs: string[]; + useGitignore: boolean; + useGeminiignore: boolean; + cache: boolean; + cacheTtl: number; +}; + +export class AbortError extends Error { + constructor(message = 'Search aborted') { + super(message); + this.name = 'AbortError'; + } +} + +/** + * Filters a list of paths based on a given pattern. + * @param allPaths The list of all paths to filter. + * @param pattern The picomatch pattern to filter by. + * @param signal An AbortSignal to cancel the operation. + * @returns A promise that resolves to the filtered and sorted list of paths. + */ +export async function filter( + allPaths: string[], + pattern: string, + signal: AbortSignal | undefined, +): Promise { + const patternFilter = picomatch(pattern, { + dot: true, + contains: true, + nocase: true, + }); + + const results: string[] = []; + for (const [i, p] of allPaths.entries()) { + // Yield control to the event loop periodically to prevent blocking. + if (i % 1000 === 0) { + await new Promise((resolve) => setImmediate(resolve)); + if (signal?.aborted) { + throw new AbortError(); + } + } + + if (patternFilter(p)) { + results.push(p); + } + } + + results.sort((a, b) => { + const aIsDir = a.endsWith('/'); + const bIsDir = b.endsWith('/'); + + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + + // This is 40% faster than localeCompare and the only thing we would really + // gain from localeCompare is case-sensitive sort + return a < b ? -1 : a > b ? 1 : 0; + }); + + return results; +} + +export type SearchOptions = { + signal?: AbortSignal; + maxResults?: number; +}; + +/** + * Provides a fast and efficient way to search for files within a project, + * respecting .gitignore and .geminiignore rules, and utilizing caching + * for improved performance. + */ +export class FileSearch { + private readonly absoluteDir: string; + private readonly ignore: Ignore = new Ignore(); + private resultCache: ResultCache | undefined; + private allFiles: string[] = []; + + /** + * Constructs a new `FileSearch` instance. + * @param options Configuration options for the file search. + */ + constructor(private readonly options: FileSearchOptions) { + this.absoluteDir = path.resolve(options.projectRoot); + } + + /** + * Initializes the file search engine by loading ignore rules, crawling the + * file system, and building the in-memory cache. This method must be called + * before performing any searches. + */ + async initialize(): Promise { + this.loadIgnoreRules(); + await this.crawlFiles(); + this.buildResultCache(); + } + + /** + * Searches for files matching a given pattern. + * @param pattern The picomatch pattern to search for (e.g., '*.js', 'src/**'). + * @param options Search options, including an AbortSignal and maxResults. + * @returns A promise that resolves to a list of matching file paths, relative + * to the project root. + */ + async search( + pattern: string, + options: SearchOptions = {}, + ): Promise { + if (!this.resultCache) { + throw new Error('Engine not initialized. Call initialize() first.'); + } + + pattern = pattern || '*'; + + const { files: candidates, isExactMatch } = + await this.resultCache!.get(pattern); + + let filteredCandidates; + if (isExactMatch) { + filteredCandidates = candidates; + } else { + // Apply the user's picomatch pattern filter + filteredCandidates = await filter(candidates, pattern, options.signal); + this.resultCache!.set(pattern, filteredCandidates); + } + + // Trade-off: We apply a two-stage filtering process. + // 1. During the file system crawl (`performCrawl`), we only apply directory-level + // ignore rules (e.g., `node_modules/`, `dist/`). This is because applying + // a full ignore filter (which includes file-specific patterns like `*.log`) + // during the crawl can significantly slow down `fdir`. + // 2. Here, in the `search` method, we apply the full ignore filter + // (including file patterns) to the `filteredCandidates` (which have already + // been filtered by the user's search pattern and sorted). For autocomplete, + // the number of displayed results is small (MAX_SUGGESTIONS_TO_SHOW), + // so applying the full filter to this truncated list is much more efficient + // than applying it to every file during the initial crawl. + const fileFilter = this.ignore.getFileFilter(); + const results: string[] = []; + for (const [i, candidate] of filteredCandidates.entries()) { + // Yield to the event loop to avoid blocking on large result sets. + if (i % 1000 === 0) { + await new Promise((resolve) => setImmediate(resolve)); + if (options.signal?.aborted) { + throw new AbortError(); + } + } + + if (results.length >= (options.maxResults ?? Infinity)) { + break; + } + // The `ignore` library throws an error if the path is '.', so we skip it. + if (candidate === '.') { + continue; + } + if (!fileFilter(candidate)) { + results.push(candidate); + } + } + return results; + } + + /** + * Loads ignore rules from .gitignore and .geminiignore files, and applies + * any additional ignore directories specified in the options. + */ + private loadIgnoreRules(): void { + if (this.options.useGitignore) { + const gitignorePath = path.join(this.absoluteDir, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + this.ignore.add(fs.readFileSync(gitignorePath, 'utf8')); + } + } + + if (this.options.useGeminiignore) { + const geminiignorePath = path.join(this.absoluteDir, '.geminiignore'); + if (fs.existsSync(geminiignorePath)) { + this.ignore.add(fs.readFileSync(geminiignorePath, 'utf8')); + } + } + + const ignoreDirs = ['.git', ...this.options.ignoreDirs]; + this.ignore.add( + ignoreDirs.map((dir) => { + if (dir.endsWith('/')) { + return dir; + } + return `${dir}/`; + }), + ); + } + + /** + * Crawls the file system to get a list of all files and directories, + * optionally using a cache for faster initialization. + */ + private async crawlFiles(): Promise { + if (this.options.cache) { + const cacheKey = cache.getCacheKey( + this.absoluteDir, + this.ignore.getFingerprint(), + ); + const cachedResults = cache.read(cacheKey); + + if (cachedResults) { + this.allFiles = cachedResults; + return; + } + } + + this.allFiles = await this.performCrawl(); + + if (this.options.cache) { + const cacheKey = cache.getCacheKey( + this.absoluteDir, + this.ignore.getFingerprint(), + ); + cache.write(cacheKey, this.allFiles, this.options.cacheTtl * 1000); + } + } + + /** + * Performs the actual file system crawl using `fdir`, applying directory + * ignore rules. + * @returns A promise that resolves to a list of all files and directories. + */ + private async performCrawl(): Promise { + const dirFilter = this.ignore.getDirectoryFilter(); + + // We use `fdir` for fast file system traversal. A key performance + // optimization for large workspaces is to exclude entire directories + // early in the traversal process. This is why we apply directory-specific + // ignore rules (e.g., `node_modules/`, `dist/`) directly to `fdir`'s + // exclude filter. + const api = new fdir() + .withRelativePaths() + .withDirs() + .withPathSeparator('/') // Always use unix style paths + .exclude((_, dirPath) => { + const relativePath = path.relative(this.absoluteDir, dirPath); + return dirFilter(`${relativePath}/`); + }); + + return api.crawl(this.absoluteDir).withPromise(); + } + + /** + * Builds the in-memory cache for fast pattern matching. + */ + private buildResultCache(): void { + this.resultCache = new ResultCache(this.allFiles, this.absoluteDir); + } +} diff --git a/packages/core/src/utils/filesearch/ignore.test.ts b/packages/core/src/utils/filesearch/ignore.test.ts new file mode 100644 index 00000000..ff375e3f --- /dev/null +++ b/packages/core/src/utils/filesearch/ignore.test.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { Ignore } from './ignore.js'; + +describe('Ignore', () => { + describe('getDirectoryFilter', () => { + it('should ignore directories matching directory patterns', () => { + const ig = new Ignore().add(['foo/', 'bar/']); + const dirFilter = ig.getDirectoryFilter(); + expect(dirFilter('foo/')).toBe(true); + expect(dirFilter('bar/')).toBe(true); + expect(dirFilter('baz/')).toBe(false); + }); + + it('should not ignore directories with file patterns', () => { + const ig = new Ignore().add(['foo.js', '*.log']); + const dirFilter = ig.getDirectoryFilter(); + expect(dirFilter('foo.js')).toBe(false); + expect(dirFilter('foo.log')).toBe(false); + }); + }); + + describe('getFileFilter', () => { + it('should not ignore files with directory patterns', () => { + const ig = new Ignore().add(['foo/', 'bar/']); + const fileFilter = ig.getFileFilter(); + expect(fileFilter('foo')).toBe(false); + expect(fileFilter('foo/file.txt')).toBe(false); + }); + + it('should ignore files matching file patterns', () => { + const ig = new Ignore().add(['*.log', 'foo.js']); + const fileFilter = ig.getFileFilter(); + expect(fileFilter('foo.log')).toBe(true); + expect(fileFilter('foo.js')).toBe(true); + expect(fileFilter('bar.txt')).toBe(false); + }); + }); + + it('should accumulate patterns across multiple add() calls', () => { + const ig = new Ignore().add('foo.js'); + ig.add('bar.js'); + const fileFilter = ig.getFileFilter(); + expect(fileFilter('foo.js')).toBe(true); + expect(fileFilter('bar.js')).toBe(true); + expect(fileFilter('baz.js')).toBe(false); + }); + + it('should return a stable and consistent fingerprint', () => { + const ig1 = new Ignore().add(['foo', '!bar']); + const ig2 = new Ignore().add('foo\n!bar'); + + // Fingerprints should be identical for the same rules. + expect(ig1.getFingerprint()).toBe(ig2.getFingerprint()); + + // Adding a new rule should change the fingerprint. + ig2.add('baz'); + expect(ig1.getFingerprint()).not.toBe(ig2.getFingerprint()); + }); +}); diff --git a/packages/core/src/utils/filesearch/ignore.ts b/packages/core/src/utils/filesearch/ignore.ts new file mode 100644 index 00000000..9f756f93 --- /dev/null +++ b/packages/core/src/utils/filesearch/ignore.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import ignore from 'ignore'; +import picomatch from 'picomatch'; + +const hasFileExtension = picomatch('**/*[*.]*'); + +export class Ignore { + private readonly allPatterns: string[] = []; + private dirIgnorer = ignore(); + private fileIgnorer = ignore(); + + /** + * Adds one or more ignore patterns. + * @param patterns A single pattern string or an array of pattern strings. + * Each pattern can be a glob-like string similar to .gitignore rules. + * @returns The `Ignore` instance for chaining. + */ + add(patterns: string | string[]): this { + if (typeof patterns === 'string') { + patterns = patterns.split(/\r?\n/); + } + + for (const p of patterns) { + const pattern = p.trim(); + + if (pattern === '' || pattern.startsWith('#')) { + continue; + } + + this.allPatterns.push(pattern); + + const isPositiveDirPattern = + pattern.endsWith('/') && !pattern.startsWith('!'); + + if (isPositiveDirPattern) { + this.dirIgnorer.add(pattern); + } else { + // An ambiguous pattern (e.g., "build") could match a file or a + // directory. To optimize the file system crawl, we use a heuristic: + // patterns without a dot in the last segment are included in the + // directory exclusion check. + // + // This heuristic can fail. For example, an ignore pattern of "my.assets" + // intended to exclude a directory will not be treated as a directory + // pattern because it contains a ".". This results in crawling a + // directory that should have been excluded, reducing efficiency. + // Correctness is still maintained. The incorrectly crawled directory + // will be filtered out by the final ignore check. + // + // For maximum crawl efficiency, users should explicitly mark directory + // patterns with a trailing slash (e.g., "my.assets/"). + this.fileIgnorer.add(pattern); + if (!hasFileExtension(pattern)) { + this.dirIgnorer.add(pattern); + } + } + } + + return this; + } + + /** + * Returns a predicate that matches explicit directory ignore patterns (patterns ending with '/'). + * @returns {(dirPath: string) => boolean} + */ + getDirectoryFilter(): (dirPath: string) => boolean { + return (dirPath: string) => this.dirIgnorer.ignores(dirPath); + } + + /** + * Returns a predicate that matches file ignore patterns (all patterns not ending with '/'). + * Note: This may also match directories if a file pattern matches a directory name, but all explicit directory patterns are handled by getDirectoryFilter. + * @returns {(filePath: string) => boolean} + */ + getFileFilter(): (filePath: string) => boolean { + return (filePath: string) => this.fileIgnorer.ignores(filePath); + } + + /** + * Returns a string representing the current set of ignore patterns. + * This can be used to generate a unique identifier for the ignore configuration, + * useful for caching purposes. + * @returns A string fingerprint of the ignore patterns. + */ + getFingerprint(): string { + return this.allPatterns.join('\n'); + } +} diff --git a/packages/core/src/utils/filesearch/result-cache.test.ts b/packages/core/src/utils/filesearch/result-cache.test.ts new file mode 100644 index 00000000..0b1b4e17 --- /dev/null +++ b/packages/core/src/utils/filesearch/result-cache.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { test, expect } from 'vitest'; +import { ResultCache } from './result-cache.js'; + +test('ResultCache basic usage', async () => { + const files = [ + 'foo.txt', + 'bar.js', + 'baz.md', + 'subdir/file.txt', + 'subdir/other.js', + 'subdir/nested/file.md', + ]; + const cache = new ResultCache(files, path.resolve('.')); + const { files: resultFiles, isExactMatch } = await cache.get('*.js'); + expect(resultFiles).toEqual(files); + expect(isExactMatch).toBe(false); +}); + +test('ResultCache cache hit/miss', async () => { + const files = ['foo.txt', 'bar.js', 'baz.md']; + const cache = new ResultCache(files, path.resolve('.')); + // First call: miss + const { files: result1Files, isExactMatch: isExactMatch1 } = + await cache.get('*.js'); + expect(result1Files).toEqual(files); + expect(isExactMatch1).toBe(false); + + // Simulate FileSearch applying the filter and setting the result + cache.set('*.js', ['bar.js']); + + // Second call: hit + const { files: result2Files, isExactMatch: isExactMatch2 } = + await cache.get('*.js'); + expect(result2Files).toEqual(['bar.js']); + expect(isExactMatch2).toBe(true); +}); + +test('ResultCache best base query', async () => { + const files = ['foo.txt', 'foobar.js', 'baz.md']; + const cache = new ResultCache(files, path.resolve('.')); + + // Cache a broader query + cache.set('foo', ['foo.txt', 'foobar.js']); + + // Search for a more specific query that starts with the broader one + const { files: resultFiles, isExactMatch } = await cache.get('foobar'); + expect(resultFiles).toEqual(['foo.txt', 'foobar.js']); + expect(isExactMatch).toBe(false); +}); diff --git a/packages/core/src/utils/filesearch/result-cache.ts b/packages/core/src/utils/filesearch/result-cache.ts new file mode 100644 index 00000000..77b99aec --- /dev/null +++ b/packages/core/src/utils/filesearch/result-cache.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Implements an in-memory cache for file search results. + * This cache optimizes subsequent searches by leveraging previously computed results. + */ +export class ResultCache { + private readonly cache: Map; + private hits = 0; + private misses = 0; + + constructor( + private readonly allFiles: string[], + private readonly absoluteDir: string, + ) { + this.cache = new Map(); + } + + /** + * Retrieves cached search results for a given query, or provides a base set + * of files to search from. + * @param query The search query pattern. + * @returns An object containing the files to search and a boolean indicating + * if the result is an exact cache hit. + */ + async get( + query: string, + ): Promise<{ files: string[]; isExactMatch: boolean }> { + const isCacheHit = this.cache.has(query); + + if (isCacheHit) { + this.hits++; + return { files: this.cache.get(query)!, isExactMatch: true }; + } + + this.misses++; + + // This is the core optimization of the memory cache. + // If a user first searches for "foo", and then for "foobar", + // we don't need to search through all files again. We can start + // from the results of the "foo" search. + // This finds the most specific, already-cached query that is a prefix + // of the current query. + let bestBaseQuery = ''; + for (const key of this.cache?.keys?.() ?? []) { + if (query.startsWith(key) && key.length > bestBaseQuery.length) { + bestBaseQuery = key; + } + } + + const filesToSearch = bestBaseQuery + ? this.cache.get(bestBaseQuery)! + : this.allFiles; + + return { files: filesToSearch, isExactMatch: false }; + } + + /** + * Stores search results in the cache. + * @param query The search query pattern. + * @param results The matching file paths to cache. + */ + set(query: string, results: string[]): void { + this.cache.set(query, results); + } +} diff --git a/packages/core/src/utils/flashFallback.integration.test.ts b/packages/core/src/utils/flashFallback.integration.test.ts index 7f18b24f..6d4369fb 100644 --- a/packages/core/src/utils/flashFallback.integration.test.ts +++ b/packages/core/src/utils/flashFallback.integration.test.ts @@ -17,7 +17,8 @@ import { import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { retryWithBackoff } from './retry.js'; import { AuthType } from '../core/contentGenerator.js'; -import { IdeClient } from '../ide/ide-client.js'; + +vi.mock('node:fs'); vi.mock('node:fs'); @@ -35,7 +36,6 @@ describe('Flash Fallback Integration', () => { debugMode: false, cwd: '/test', model: 'gemini-2.5-pro', - ideClient: IdeClient.getInstance(false), }); // Reset simulation state for each test diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 562608c5..9709a06e 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -67,6 +67,7 @@ describe('loadServerHierarchicalMemory', () => { it('should return empty memory and count if no context files are found', async () => { const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); @@ -85,14 +86,13 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} --- -default context content ---- End of Context from: ${path.relative(cwd, defaultContextFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---`, fileCount: 1, }); }); @@ -108,14 +108,13 @@ default context content const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, customContextFile)} --- -custom context content ---- End of Context from: ${path.relative(cwd, customContextFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, customContextFile)} ---\ncustom context content\n--- End of Context from: ${path.relative(cwd, customContextFile)} ---`, fileCount: 1, }); }); @@ -135,18 +134,13 @@ custom context content const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, projectContextFile)} --- -project context content ---- End of Context from: ${path.relative(cwd, projectContextFile)} --- - ---- Context from: ${path.relative(cwd, cwdContextFile)} --- -cwd context content ---- End of Context from: ${path.relative(cwd, cwdContextFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, projectContextFile)} ---\nproject context content\n--- End of Context from: ${path.relative(cwd, projectContextFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdContextFile)} ---\ncwd context content\n--- End of Context from: ${path.relative(cwd, cwdContextFile)} ---`, fileCount: 2, }); }); @@ -163,18 +157,13 @@ cwd context content const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${customFilename} --- -CWD custom memory ---- End of Context from: ${customFilename} --- - ---- Context from: ${path.join('subdir', customFilename)} --- -Subdir custom memory ---- End of Context from: ${path.join('subdir', customFilename)} ---`, + memoryContent: `--- Context from: ${customFilename} ---\nCWD custom memory\n--- End of Context from: ${customFilename} ---\n\n--- Context from: ${path.join('subdir', customFilename)} ---\nSubdir custom memory\n--- End of Context from: ${path.join('subdir', customFilename)} ---`, fileCount: 2, }); }); @@ -191,18 +180,13 @@ Subdir custom memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, projectRootGeminiFile)} --- -Project root memory ---- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} --- - ---- Context from: ${path.relative(cwd, srcGeminiFile)} --- -Src directory memory ---- End of Context from: ${path.relative(cwd, srcGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, srcGeminiFile)} ---\nSrc directory memory\n--- End of Context from: ${path.relative(cwd, srcGeminiFile)} ---`, fileCount: 2, }); }); @@ -219,18 +203,13 @@ Src directory memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} --- -CWD memory ---- End of Context from: ${DEFAULT_CONTEXT_FILENAME} --- - ---- Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} --- -Subdir memory ---- End of Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---`, + memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} ---\nCWD memory\n--- End of Context from: ${DEFAULT_CONTEXT_FILENAME} ---\n\n--- Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---\nSubdir memory\n--- End of Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---`, fileCount: 2, }); }); @@ -259,30 +238,13 @@ Subdir memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} --- -default context content ---- End of Context from: ${path.relative(cwd, defaultContextFile)} --- - ---- Context from: ${path.relative(cwd, rootGeminiFile)} --- -Project parent memory ---- End of Context from: ${path.relative(cwd, rootGeminiFile)} --- - ---- Context from: ${path.relative(cwd, projectRootGeminiFile)} --- -Project root memory ---- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} --- - ---- Context from: ${path.relative(cwd, cwdGeminiFile)} --- -CWD memory ---- End of Context from: ${path.relative(cwd, cwdGeminiFile)} --- - ---- Context from: ${path.relative(cwd, subDirGeminiFile)} --- -Subdir memory ---- End of Context from: ${path.relative(cwd, subDirGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootGeminiFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdGeminiFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, subDirGeminiFile)} ---\nSubdir memory\n--- End of Context from: ${path.relative(cwd, subDirGeminiFile)} ---`, fileCount: 5, }); }); @@ -302,6 +264,7 @@ Subdir memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), [], @@ -314,9 +277,7 @@ Subdir memory ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, regularSubDirGeminiFile)} --- -My code memory ---- End of Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---\nMy code memory\n--- End of Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---`, fileCount: 1, }); }); @@ -333,6 +294,7 @@ My code memory // Pass the custom limit directly to the function await loadServerHierarchicalMemory( cwd, + [], true, new FileDiscoveryService(projectRoot), [], @@ -353,6 +315,7 @@ My code memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); @@ -371,15 +334,36 @@ My code memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), [extensionFilePath], ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, extensionFilePath)} --- -Extension memory content ---- End of Context from: ${path.relative(cwd, extensionFilePath)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, extensionFilePath)} ---\nExtension memory content\n--- End of Context from: ${path.relative(cwd, extensionFilePath)} ---`, + fileCount: 1, + }); + }); + + it('should load memory from included directories', async () => { + const includedDir = await createEmptyDir( + path.join(testRootDir, 'included'), + ); + const includedFile = await createTestFile( + path.join(includedDir, DEFAULT_CONTEXT_FILENAME), + 'included directory memory', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [includedDir], + false, + new FileDiscoveryService(projectRoot), + ); + + expect(result).toEqual({ + memoryContent: `--- Context from: ${path.relative(cwd, includedFile)} ---\nincluded directory memory\n--- End of Context from: ${path.relative(cwd, includedFile)} ---`, fileCount: 1, }); }); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index cf387446..91abc987 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -83,6 +83,36 @@ async function findProjectRoot(startDir: string): Promise { async function getGeminiMdFilePathsInternal( currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[], + userHomePath: string, + debugMode: boolean, + fileService: FileDiscoveryService, + extensionContextFilePaths: string[] = [], + fileFilteringOptions: FileFilteringOptions, + maxDirs: number, +): Promise { + const dirs = new Set([ + ...includeDirectoriesToReadGemini, + currentWorkingDirectory, + ]); + const paths = []; + for (const dir of dirs) { + const pathsByDir = await getGeminiMdFilePathsInternalForEachDir( + dir, + userHomePath, + debugMode, + fileService, + extensionContextFilePaths, + fileFilteringOptions, + maxDirs, + ); + paths.push(...pathsByDir); + } + return Array.from(new Set(paths)); +} + +async function getGeminiMdFilePathsInternalForEachDir( + dir: string, userHomePath: string, debugMode: boolean, fileService: FileDiscoveryService, @@ -115,8 +145,8 @@ async function getGeminiMdFilePathsInternal( // FIX: Only perform the workspace search (upward and downward scans) // if a valid currentWorkingDirectory is provided. - if (currentWorkingDirectory) { - const resolvedCwd = path.resolve(currentWorkingDirectory); + if (dir) { + const resolvedCwd = path.resolve(dir); if (debugMode) logger.debug( `Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`, @@ -257,6 +287,7 @@ function concatenateInstructions( */ export async function loadServerHierarchicalMemory( currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[], debugMode: boolean, fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], @@ -274,6 +305,7 @@ export async function loadServerHierarchicalMemory( const userHomePath = homedir(); const filePaths = await getGeminiMdFilePathsInternal( currentWorkingDirectory, + includeDirectoriesToReadGemini, userHomePath, debugMode, fileService, diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 16d1b4c9..efbc8a4c 100644 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -15,6 +15,8 @@ import * as path from 'path'; export class WorkspaceContext { private directories: Set; + private initialDirectories: Set; + /** * Creates a new WorkspaceContext with the given initial directory and optional additional directories. * @param initialDirectory The initial working directory (usually cwd) @@ -22,11 +24,14 @@ export class WorkspaceContext { */ constructor(initialDirectory: string, additionalDirectories: string[] = []) { this.directories = new Set(); + this.initialDirectories = new Set(); this.addDirectoryInternal(initialDirectory); + this.addInitialDirectoryInternal(initialDirectory); for (const dir of additionalDirectories) { this.addDirectoryInternal(dir); + this.addInitialDirectoryInternal(dir); } } @@ -69,6 +74,33 @@ export class WorkspaceContext { this.directories.add(realPath); } + private addInitialDirectoryInternal( + directory: string, + basePath: string = process.cwd(), + ): void { + const absolutePath = path.isAbsolute(directory) + ? directory + : path.resolve(basePath, directory); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`Directory does not exist: ${absolutePath}`); + } + + const stats = fs.statSync(absolutePath); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${absolutePath}`); + } + + let realPath: string; + try { + realPath = fs.realpathSync(absolutePath); + } catch (_error) { + throw new Error(`Failed to resolve path: ${absolutePath}`); + } + + this.initialDirectories.add(realPath); + } + /** * Gets a copy of all workspace directories. * @returns Array of absolute directory paths @@ -77,6 +109,17 @@ export class WorkspaceContext { return Array.from(this.directories); } + getInitialDirectories(): readonly string[] { + return Array.from(this.initialDirectories); + } + + setDirectories(directories: readonly string[]): void { + this.directories.clear(); + for (const dir of directories) { + this.addDirectoryInternal(dir); + } + } + /** * Checks if a given path is within any of the workspace directories. * @param pathToCheck The path to validate diff --git a/packages/test-utils/index.ts b/packages/test-utils/index.ts new file mode 100644 index 00000000..d69ad168 --- /dev/null +++ b/packages/test-utils/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './src/file-system-test-helpers.js'; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 00000000..9368176e --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,18 @@ +{ + "name": "@qwen-code/qwen-code-test-utils", + "version": "0.1.18", + "private": true, + "main": "src/index.ts", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "node ../../scripts/build_package.js", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/test-utils/src/file-system-test-helpers.ts b/packages/test-utils/src/file-system-test-helpers.ts new file mode 100644 index 00000000..f78c7af4 --- /dev/null +++ b/packages/test-utils/src/file-system-test-helpers.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Defines the structure of a virtual file system to be created for testing. + * Keys are file or directory names, and values can be: + * - A string: The content of a file. + * - A `FileSystemStructure` object: Represents a subdirectory with its own structure. + * - An array of strings or `FileSystemStructure` objects: Represents a directory + * where strings are empty files and objects are subdirectories. + * + * @example + * // Example 1: Simple files and directories + * const structure1 = { + * 'file1.txt': 'Hello, world!', + * 'empty-dir': [], + * 'src': { + * 'main.js': '// Main application file', + * 'utils.ts': '// Utility functions', + * }, + * }; + * + * @example + * // Example 2: Nested directories and empty files within an array + * const structure2 = { + * 'config.json': '{ "port": 3000 }', + * 'data': [ + * 'users.csv', + * 'products.json', + * { + * 'logs': [ + * 'error.log', + * 'access.log', + * ], + * }, + * ], + * }; + */ +export type FileSystemStructure = { + [name: string]: + | string + | FileSystemStructure + | Array; +}; + +/** + * Recursively creates files and directories based on the provided `FileSystemStructure`. + * @param dir The base directory where the structure will be created. + * @param structure The `FileSystemStructure` defining the files and directories. + */ +async function create(dir: string, structure: FileSystemStructure) { + for (const [name, content] of Object.entries(structure)) { + const newPath = path.join(dir, name); + if (typeof content === 'string') { + await fs.writeFile(newPath, content); + } else if (Array.isArray(content)) { + await fs.mkdir(newPath, { recursive: true }); + for (const item of content) { + if (typeof item === 'string') { + await fs.writeFile(path.join(newPath, item), ''); + } else { + await create(newPath, item as FileSystemStructure); + } + } + } else if (typeof content === 'object' && content !== null) { + await fs.mkdir(newPath, { recursive: true }); + await create(newPath, content as FileSystemStructure); + } + } +} + +/** + * Creates a temporary directory and populates it with a given file system structure. + * @param structure The `FileSystemStructure` to create within the temporary directory. + * @returns A promise that resolves to the absolute path of the created temporary directory. + */ +export async function createTmpDir( + structure: FileSystemStructure, +): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-')); + await create(tmpDir, structure); + return tmpDir; +} + +/** + * Cleans up (deletes) a temporary directory and its contents. + * @param dir The absolute path to the temporary directory to clean up. + */ +export async function cleanupTmpDir(dir: string) { + await fs.rm(dir, { recursive: true, force: true }); +} diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts new file mode 100644 index 00000000..b8af8aa7 --- /dev/null +++ b/packages/test-utils/src/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './file-system-test-helpers.js'; diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 00000000..ee9b84b1 --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["DOM", "DOM.Iterable", "ES2021"], + "composite": true, + "types": ["node"] + }, + "include": ["index.ts", "src/**/*.ts", "src/**/*.json"], + "exclude": ["node_modules", "dist"] +} diff --git a/scripts/generate-git-commit-info.js b/scripts/generate-git-commit-info.js index 237ec09b..7c4871ec 100644 --- a/scripts/generate-git-commit-info.js +++ b/scripts/generate-git-commit-info.js @@ -19,11 +19,12 @@ import { execSync } from 'child_process'; import { existsSync, mkdirSync, writeFileSync } from 'fs'; -import { dirname, join } from 'path'; +import { dirname, join, relative } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); +const scriptPath = relative(root, fileURLToPath(import.meta.url)); const generatedDir = join(root, 'packages/cli/src/generated'); const gitCommitFile = join(generatedDir, 'git-commit.ts'); let gitCommitInfo = 'N/A'; @@ -38,12 +39,6 @@ try { }).trim(); if (gitHash) { gitCommitInfo = gitHash; - const gitStatus = execSync('git status --porcelain', { - encoding: 'utf-8', - }).trim(); - if (gitStatus) { - gitCommitInfo = `${gitHash} (local modifications)`; - } } } catch { // ignore @@ -55,7 +50,7 @@ const fileContent = `/** * SPDX-License-Identifier: Apache-2.0 */ -// This file is auto-generated by the build script (scripts/build.js) +// This file is auto-generated by the build script (${scriptPath}) // Do not edit this file manually. export const GIT_COMMIT_INFO = '${gitCommitInfo}'; `; diff --git a/scripts/start.js b/scripts/start.js index 5ff1a3ac..ae100f28 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -55,7 +55,7 @@ if (process.env.DEBUG && !sandboxCommand) { } } -nodeArgs.push('./packages/cli'); +nodeArgs.push(join(root, 'packages', 'cli')); nodeArgs.push(...process.argv.slice(2)); const env = { diff --git a/scripts/version.js b/scripts/version.js index 692a2135..2ee33365 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -24,8 +24,8 @@ function writeJson(filePath, data) { } // 1. Get the version from the command line arguments. -const versionArg = process.argv[2]; -if (!versionArg) { +const versionType = process.argv[2]; +if (!versionType) { console.error('Error: No version specified.'); console.error( 'Usage: npm run version (e.g., 1.2.3 or patch|minor|major|prerelease)', @@ -33,15 +33,11 @@ if (!versionArg) { process.exit(1); } -// 2. Determine if we have a specific version or a version type -const isSpecificVersion = /^\d+\.\d+\.\d+/.test(versionArg); -const npmVersionArg = isSpecificVersion ? versionArg : versionArg; +// 2. Bump the version in the root and all workspace package.json files. +run(`npm version ${versionType} --no-git-tag-version --allow-same-version`); -// 3. Bump the version in the root and all workspace package.json files. -run(`npm version ${npmVersionArg} --no-git-tag-version --allow-same-version`); - -// 4. Get all workspaces and filter out the one we don't want to version. -const workspacesToExclude = ['qwen-code-vscode-ide-companion']; +// 3. Get all workspaces and filter out the one we don't want to version. +const workspacesToExclude = ['gemini-cli-vscode-ide-companion']; const lsOutput = JSON.parse( execSync('npm ls --workspaces --json --depth=0').toString(), ); @@ -52,11 +48,11 @@ const workspacesToVersion = allWorkspaces.filter( for (const workspaceName of workspacesToVersion) { run( - `npm version ${npmVersionArg} --workspace ${workspaceName} --no-git-tag-version --allow-same-version`, + `npm version ${versionType} --workspace ${workspaceName} --no-git-tag-version --allow-same-version`, ); } -// 5. Get the new version number from the root package.json +// 4. Get the new version number from the root package.json const rootPackageJsonPath = resolve(process.cwd(), 'package.json'); const newVersion = readJson(rootPackageJsonPath).version;