Compare commits

..

15 Commits

Author SHA1 Message Date
jacob314 e2981da430 refactor(cli): simplify command completion orchestration and fix shell cache race
- Simplified `useCommandCompletion.tsx` by moving shell-specific tokenization into `useShellCompletion.ts`.
- Reverted most changes to the `useCommandCompletion` `useMemo` block to keep it clean and consistent with other modes.
- Fixed a potential race condition in `useShellCompletion.ts` where `scanPathExecutables` could be called multiple times if it was slow. It now caches the Promise itself.
- Updated tests to match the refined hook interfaces.
2026-02-25 10:35:14 -08:00
jacob314 1dbb9af41c fix(cli): prioritize shorter command names in shell autocomplete results 2026-02-25 10:00:14 -08:00
jacob314 b4724a1eed test(cli): update snapshots for shell autocompletion refinements 2026-02-25 09:37:28 -08:00
jacob314 4eea909f28 fix(cli): add Windows shell built-in commands to autocomplete list
On Windows, many common commands like `dir`, `copy`, `del`, etc., are internal to `cmd.exe` and do not exist as separate executables on the disk. This updates `scanPathExecutables` to explicitly include these built-ins when running on Windows, ensuring a consistent autocomplete experience for Windows users.
2026-02-25 09:26:38 -08:00
jacob314 af8462f76d fix(cli): disable shell autocompletion on completely empty prompts
To reduce visual noise and distraction, autocompletion is now suppressed when the shell prompt is entirely empty (or only contains whitespace). It remains active as soon as the user starts typing or moves the cursor to a position that would suggest paths/commands.
2026-02-25 09:09:33 -08:00
jacob314 2f4242fb60 fix(cli): prevent autocomplete UI flicker by decoupling disabled state reset from completion refresh
When typing outside of shell mode (e.g., using `@` completion), the `useShellCompletion` hook was continuously re-rendering and executing its `useEffect` because `performCompletion` was recreated on every keystroke (since its `query` dependency changed).

Because `enabled` was false, the effect unconditionally called `setSuggestions([])`, rapidly overriding the suggestions populated by `useAtCompletion` and causing severe UI flickering.

This splits the effect to ensure `setSuggestions([])` is only called once when `enabled` becomes false, stabilizing the suggestions list.
2026-02-25 08:58:31 -08:00
jacob314 aeecf5a9e7 fix(cli): prevent auto-submit on Enter when navigating to first shell autocomplete suggestion
When using shell autocomplete, pressing Down then Up returns the activeSuggestionIndex to 0. If the first suggestion happened to be an exact match, the InputPrompt would bypass the normal autocomplete logic and eagerly submit the prompt on Enter because it ignored whether the user had actively navigated.

This fix updates the isPerfectMatch check to require that the user has not navigated the suggestions list, ensuring that explicitly selected items always autocomplete instead of submitting.
2026-02-25 08:23:53 -08:00
MD. MOHIBUR RAHMAN dfa5c79a47 refactor(cli): centralize token parsing in useCommandCompletion to optimize re-renders 2026-02-24 05:22:51 +06:00
MD. MOHIBUR RAHMAN d6770d2ae9 fix(cli): escape executables in shell autocompletion to prevent command injection 2026-02-24 05:06:10 +06:00
MD. MOHIBUR RAHMAN c1d7bc4c8c fix(cli): escape npm script names in shell autocompletion to prevent command injection 2026-02-24 05:04:27 +06:00
MD. MOHIBUR RAHMAN 39b393abfb fix(cli): prevent command injection by escaping git branch suggestions 2026-02-24 04:25:16 +06:00
MD. MOHIBUR RAHMAN 5b3819073f fix(cli): add missing shell metacharacters (\n, \r, \t, \\) to escape regex 2026-02-24 04:14:54 +06:00
MD. MOHIBUR RAHMAN 926afab788 fix(cli): improve shell completion for dotfiles, spaces, and quotes 2026-02-24 03:41:33 +06:00
MD. MOHIBUR RAHMAN 555b95eb09 feat(cli): add context-sensitive shell completions for git and npm (#2492) 2026-02-24 02:52:20 +06:00
MD. MOHIBUR RAHMAN 0c6ebbd1f5 feat(cli): implement interactive shell autocompletion (#2492) 2026-02-24 01:49:30 +06:00
149 changed files with 2848 additions and 5921 deletions
-8
View File
@@ -77,14 +77,6 @@ runs:
--image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \
--output-file final_image_uri.txt
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
- name: 'verify'
shell: 'bash'
run: |-
docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc '
set -e
node -e "const fs=require(\"node:fs\"); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json\",\"utf8\")); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json\",\"utf8\"));"
/usr/local/share/npm-global/bin/gemini --version >/dev/null
'
- name: 'publish'
shell: 'bash'
if: "${{ inputs.dry-run != 'true' }}"
+1 -1
View File
@@ -22,7 +22,7 @@ get_issue_labels() {
# Check cache
case "${ISSUE_LABELS_CACHE_FLAT}" in
*"|${ISSUE_NUM}:"*)
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}"
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
echo "${suffix%%|*}"
return
;;
+2 -2
View File
@@ -224,6 +224,8 @@ jobs:
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
runs-on: 'gemini-cli-windows-16-core'
continue-on-error: true
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -313,7 +315,6 @@ jobs:
needs:
- 'e2e_linux'
- 'e2e_mac'
- 'e2e_windows'
- 'evals'
- 'merge_queue_skipper'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -322,7 +323,6 @@ jobs:
run: |
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
${{ needs.e2e_mac.result }} != 'success' || \
${{ needs.e2e_windows.result }} != 'success' || \
${{ needs.evals.result }} != 'success' ]]; then
echo "One or more E2E jobs failed."
exit 1
+1 -2
View File
@@ -360,6 +360,7 @@ jobs:
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
continue-on-error: true
timeout-minutes: 60
strategy:
matrix:
@@ -457,7 +458,6 @@ jobs:
- 'link_checker'
- 'test_linux'
- 'test_mac'
- 'test_windows'
- 'codeql'
- 'bundle_size'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -468,7 +468,6 @@ jobs:
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
(${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
echo "One or more CI jobs failed."
-1
View File
@@ -27,7 +27,6 @@ jobs:
fail-fast: false
matrix:
model:
- 'gemini-3.1-pro-preview-customtools'
- 'gemini-3-pro-preview'
- 'gemini-3-flash-preview'
- 'gemini-2.5-pro'
-29
View File
@@ -1,29 +0,0 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'PR rate limiter'
permissions: {}
on:
pull_request_target:
types:
- 'opened'
- 'reopened'
jobs:
limit:
runs-on: 'gemini-cli-ubuntu-16-core'
permissions:
contents: 'read'
pull-requests: 'write'
steps:
- name: 'Limit open pull requests per user'
uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master
with:
except-author-associations: 'MEMBER,OWNER,COLLABORATOR'
comment-limit: 8
comment: >
You already have 7 pull requests open. Please work on getting
existing PRs merged before opening more.
close-limit: 8
close: true
+6 -5
View File
@@ -372,7 +372,8 @@ specific debug settings.
### React DevTools
To debug the CLI's React-based UI, you can use React DevTools.
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
used for the CLI's interface, is compatible with React DevTools version 4.x.
1. **Start the Gemini CLI in development mode:**
@@ -380,20 +381,20 @@ To debug the CLI's React-based UI, you can use React DevTools.
DEV=true npm start
```
2. **Install and run React DevTools version 6 (which matches the CLI's
`react-devtools-core`):**
2. **Install and run React DevTools version 4.28.5 (or the latest compatible
4.x version):**
You can either install it globally:
```bash
npm install -g react-devtools@6
npm install -g react-devtools@4.28.5
react-devtools
```
Or run it directly using npx:
```bash
npx react-devtools@6
npx react-devtools@4.28.5
```
Your running CLI application should then connect to React DevTools.
+1 -4
View File
@@ -42,10 +42,7 @@ USER node
# install gemini-cli and clean up
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
RUN npm install -g /tmp/gemini-core.tgz \
&& npm install -g /tmp/gemini-cli.tgz \
&& node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
&& gemini --version > /dev/null \
RUN npm install -g /tmp/gemini-cli.tgz /tmp/gemini-core.tgz \
&& npm cache clean --force \
&& rm -f /tmp/gemini-{cli,core}.tgz
+8 -11
View File
@@ -29,7 +29,6 @@ they appear in the UI.
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
@@ -112,15 +111,14 @@ they appear in the UI.
### Security
| UI Label | Setting | Description | Default |
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
| Enable Context-Aware Security | `security.enableConseca` | Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions. | `false` |
| UI Label | Setting | Description | Default |
| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
### Advanced
@@ -137,7 +135,6 @@ they appear in the UI.
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
### Skills
+14 -38
View File
@@ -116,9 +116,7 @@ The manifest file defines the extension's behavior and configuration.
"description": "My awesome extension",
"mcpServers": {
"my-server": {
"command": "node",
"args": ["${extensionPath}/my-server.js"],
"cwd": "${extensionPath}"
"command": "node my-server.js"
}
},
"contextFileName": "GEMINI.md",
@@ -126,41 +124,19 @@ The manifest file defines the extension's behavior and configuration.
}
```
- `name`: The name of the extension. This is used to uniquely identify the
extension and for conflict resolution when extension commands have the same
name as user or project commands. The name should be lowercase or numbers and
use dashes instead of underscores or spaces. This is how users will refer to
your extension in the CLI. Note that we expect this name to match the
extension directory name.
- `version`: The version of the extension.
- `description`: A short description of the extension. This will be displayed on
[geminicli.com/extensions](https://geminicli.com/extensions).
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
server, and the value is the server configuration. These servers will be
loaded on startup just like MCP servers defined in a
[`settings.json` file](../reference/configuration.md). If both an extension
and a `settings.json` file define an MCP server with the same name, the server
defined in the `settings.json` file takes precedence.
- Note that all MCP server configuration options are supported except for
`trust`.
- For portability, you should use `${extensionPath}` to refer to files within
your extension directory.
- Separate your executable and its arguments using `command` and `args`
instead of putting them both in `command`.
- `contextFileName`: The name of the file that contains the context for the
extension. This will be used to load the context from the extension directory.
If this property is not used but a `GEMINI.md` file is present in your
extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also
specify command-specific restrictions for tools that support it, like the
`run_shell_command` tool. For example,
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
command. Note that this differs from the MCP server `excludeTools`
functionality, which can be listed in the MCP server config.
When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
precedence.
- `name`: A unique identifier for the extension. Use lowercase letters, numbers,
and dashes. This name must match the extension's directory name.
- `version`: The current version of the extension.
- `description`: A short summary shown in the extension gallery.
- <a id="mcp-servers"></a>`mcpServers`: A map of Model Context Protocol (MCP)
servers. Extension servers follow the same format as standard
[CLI configuration](../reference/configuration.md).
- `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can
also be an array of strings to load multiple context files.
- `excludeTools`: An array of tools to block from the model. You can restrict
specific arguments, such as `run_shell_command(rm -rf)`.
- `themes`: An optional list of themes provided by the extension. See
[Themes](../cli/themes.md) for more information.
### Extension settings
-8
View File
@@ -98,8 +98,6 @@ and parameter rewriting.
- `tool_name`: (`string`) The name of the tool being called.
- `tool_input`: (`object`) The raw arguments generated by the model.
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
- `original_request_name`: (`string`) The original name of the tool being
called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
executing.
@@ -122,18 +120,12 @@ hiding sensitive output from the agent.
- `tool_response`: (`object`) The result containing `llmContent`,
`returnDisplay`, and optional `error`.
- `mcp_context`: (`object`)
- `original_request_name`: (`string`) The original name of the tool being
called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
- `reason`: Required if denied. This text **replaces** the tool result sent
back to the model.
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
tool result for the agent.
- `hookSpecificOutput.tailToolCallRequest`: (`{ name: string, args: object }`)
A request to execute another tool immediately after this one. The result of
this "tail call" will replace the original tool's response. Ideal for
programmatic tool routing.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
replacement content sent to the agent. **The turn continues.**
-18
View File
@@ -142,11 +142,6 @@ their corresponding top-level category object in your `settings.json` file.
request" errors.
- **Default:** `false`
- **`general.maxAttempts`** (number):
- **Description:** Maximum number of attempts for requests to the main chat
model. Cannot exceed 10.
- **Default:** `10`
- **`general.debugKeystrokeLogging`** (boolean):
- **Description:** Enable debug logging of keystrokes to the console.
- **Default:** `false`
@@ -873,14 +868,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`security.enableConseca`** (boolean):
- **Description:** Enable the context-aware security checker. This feature
uses an LLM to dynamically generate and enforce security policies for tool
use based on your prompt, providing an additional layer of protection
against unintended actions.
- **Default:** `false`
- **Requires restart:** Yes
#### `advanced`
- **`advanced.autoConfigureMemory`** (boolean):
@@ -982,11 +969,6 @@ their corresponding top-level category object in your `settings.json` file.
during tool execution.
- **Default:** `false`
- **`experimental.directWebFetch`** (boolean):
- **Description:** Enable web fetch behavior that bypasses LLM summarization.
- **Default:** `false`
- **Requires restart:** Yes
#### `skills`
- **`skills.enabled`** (boolean):
+14 -42
View File
@@ -64,11 +64,9 @@ primary conditions are the tool's name and its arguments.
The `toolName` in the rule must match the name of the tool being called.
- **Wildcards**: You can use wildcards to match multiple tools.
- `*`: Matches **any tool** (built-in or MCP).
- `server__*`: Matches any tool from a specific MCP server.
- `*__toolName`: Matches a specific tool name across **all** MCP servers.
- `*__*`: Matches **any tool from any MCP server**.
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
wildcard. A `toolName` of `my-server__*` will match any tool from the
`my-server` MCP.
#### Arguments pattern
@@ -146,9 +144,9 @@ A rule matches a tool call if all of its conditions are met:
1. **Tool name**: The `toolName` in the rule must match the name of the tool
being called.
- **Wildcards**: You can use wildcards like `*`, `server__*`, or
`*__toolName` to match multiple tools. See [Tool Name](#tool-name) for
details.
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
wildcard. A `toolName` of `my-server__*` will match any tool from the
`my-server` MCP.
2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments
are converted to a stable JSON string, which is then tested against the
provided regular expression. If the arguments don't match the pattern, the
@@ -274,12 +272,13 @@ priority = 100
### Special syntax for MCP tools
You can create rules that target tools from Model Context Protocol (MCP) servers
using the `mcpName` field or composite wildcard patterns.
You can create rules that target tools from Model-hosting-protocol (MCP) servers
using the `mcpName` field or a wildcard pattern.
**1. Targeting a specific tool on a server**
**1. Using `mcpName`**
Combine `mcpName` and `toolName` to target a single operation.
To target a specific tool from a specific server, combine `mcpName` and
`toolName`.
```toml
# Allows the `search` tool on the `my-jira-server` MCP
@@ -290,10 +289,10 @@ decision = "allow"
priority = 200
```
**2. Targeting all tools on a specific server**
**2. Using a wildcard**
Specify only the `mcpName` to apply a rule to every tool provided by that
server.
To create a rule that applies to _all_ tools on a specific MCP server, specify
only the `mcpName`.
```toml
# Denies all tools from the `untrusted-server` MCP
@@ -304,33 +303,6 @@ priority = 500
deny_message = "This server is not trusted by the admin."
```
**3. Targeting all MCP servers**
Use `mcpName = "*"` to create a rule that applies to **all** tools from **any**
registered MCP server. This is useful for setting category-wide defaults.
```toml
# Ask user for any tool call from any MCP server
[[rule]]
mcpName = "*"
decision = "ask_user"
priority = 10
```
**4. Targeting a tool name across all servers**
Use `mcpName = "*"` with a specific `toolName` to target that operation
regardless of which server provides it.
```toml
# Allow the `search` tool across all connected MCP servers
[[rule]]
mcpName = "*"
toolName = "search"
decision = "allow"
priority = 50
```
## Default policies
The Gemini CLI ships with a set of default policies to provide a safe
+4 -7
View File
@@ -105,11 +105,10 @@ lines containing matches, along with their file paths and line numbers.
## 6. `replace` (Edit)
`replace` replaces text within a file. By default, the tool expects to find and
replace exactly ONE occurrence of `old_string`. If you want to replace multiple
occurrences of the exact same string, set `allow_multiple` to `true`. This tool
is designed for precise, targeted changes and requires significant context
around the `old_string` to ensure it modifies the correct location.
`replace` replaces text within a file. By default, replaces a single occurrence,
but can replace multiple occurrences when `expected_replacements` is specified.
This tool is designed for precise, targeted changes and requires significant
context around the `old_string` to ensure it modifies the correct location.
- **Tool name:** `replace`
- **Arguments:**
@@ -117,8 +116,6 @@ around the `old_string` to ensure it modifies the correct location.
- `instruction` (string, required): Semantic description of the change.
- `old_string` (string, required): Exact literal text to find.
- `new_string` (string, required): Exact literal text to replace with.
- `allow_multiple` (boolean, optional): If `true`, replaces all occurrences.
If `false` (default), only succeeds if exactly one occurrence is found.
- **Confirmation:** Requires manual user approval.
## Next steps
+2 -62
View File
@@ -163,8 +163,7 @@ Each server configuration supports the following properties:
- **`args`** (string[]): Command-line arguments for Stdio transport
- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl`
- **`env`** (object): Environment variables for the server process. Values can
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all
platforms), or `%VAR_NAME%` (Windows only).
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax
- **`cwd`** (string): Working directory for Stdio transport
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms =
10 minutes)
@@ -185,63 +184,6 @@ Each server configuration supports the following properties:
Service Account to impersonate. Used with
`authProviderType: 'service_account_impersonation'`.
### Environment variable expansion
Gemini CLI automatically expands environment variables in the `env` block of
your MCP server configuration. This allows you to securely reference variables
defined in your shell or environment without hardcoding sensitive information
directly in your `settings.json` file.
The expansion utility supports:
- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on
all platforms)
- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows)
If a variable is not defined in the current environment, it resolves to an empty
string.
**Example:**
```json
"env": {
"API_KEY": "$MY_EXTERNAL_TOKEN",
"LOG_LEVEL": "$LOG_LEVEL",
"TEMP_DIR": "%TEMP%"
}
```
### Security and environment sanitization
To protect your credentials, Gemini CLI performs environment sanitization when
spawning MCP server processes.
#### Automatic redaction
By default, the CLI redacts sensitive environment variables from the base
environment (inherited from the host process) to prevent unintended exposure to
third-party MCP servers. This includes:
- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
`*KEY*`, `*AUTH*`, `*CREDENTIAL*`.
- Certificates and private key patterns.
#### Explicit overrides
If an environment variable must be passed to an MCP server, you must explicitly
state it in the `env` property of the server configuration in `settings.json`.
Explicitly defined variables (including those from extensions) are trusted and
are **not** subjected to the automatic redaction process.
This follows the security principle that if a variable is explicitly configured
by the user for a specific server, it constitutes informed consent to share that
specific data with that server.
> **Note:** Even when explicitly defined, you should avoid hardcoding secrets.
> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to
> securely pull the value from your host environment at runtime.
### OAuth support for remote MCP servers
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using
@@ -796,9 +738,7 @@ The MCP integration tracks several states:
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
cautiously and only for servers you completely control
- **Access tokens:** Be security-aware when configuring environment variables
containing API keys or tokens. See
[Security and environment sanitization](#security-and-environment-sanitization)
for details on how Gemini CLI protects your credentials.
containing API keys or tokens
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
available within the sandbox environment
- **Private data:** Using broadly scoped personal access tokens can lead to
+12 -13
View File
@@ -78,23 +78,22 @@ describe('Frugal reads eval', () => {
).toBe(true);
let totalLinesRead = 0;
const readRanges: { start_line: number; end_line: number }[] = [];
const readRanges: { offset: number; limit: number }[] = [];
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
args.end_line,
'Agent read the entire file (missing end_line) instead of using ranged read',
args.limit,
'Agent read the entire file (missing limit) instead of using ranged read',
).toBeDefined();
const end_line = args.end_line;
const start_line = args.start_line ?? 1;
const linesRead = end_line - start_line + 1;
totalLinesRead += linesRead;
readRanges.push({ start_line, end_line });
const limit = args.limit;
const offset = args.offset ?? 0;
totalLinesRead += limit;
readRanges.push({ offset, limit });
expect(linesRead, 'Agent read too many lines at once').toBeLessThan(
expect(args.limit, 'Agent read too many lines at once').toBeLessThan(
1001,
);
}
@@ -109,7 +108,7 @@ describe('Frugal reads eval', () => {
const errorLines = [500, 510, 520];
for (const line of errorLines) {
const covered = readRanges.some(
(range) => line >= range.start_line && line <= range.end_line,
(range) => line >= range.offset && line < range.offset + range.limit,
);
expect(covered, `Agent should have read around line ${line}`).toBe(
true,
@@ -192,8 +191,8 @@ describe('Frugal reads eval', () => {
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
args.end_line,
'Agent should have used ranged read (end_line) to save tokens',
args.limit,
'Agent should have used ranged read (limit) to save tokens',
).toBeDefined();
}
},
@@ -254,7 +253,7 @@ describe('Frugal reads eval', () => {
// and just read the whole file to be efficient with tool calls.
const readEntireFile = targetFileReads.some((call) => {
const args = JSON.parse(call.toolRequest.args);
return args.end_line === undefined;
return args.limit === undefined;
});
expect(
+2 -2
View File
@@ -68,7 +68,7 @@ describe('Frugal Search', () => {
const args = getParams(call);
return (
args.file_path === 'src/legacy_processor.ts' &&
(args.end_line === undefined || args.end_line === null)
(args.limit === undefined || args.limit === null)
);
});
@@ -87,7 +87,7 @@ describe('Frugal Search', () => {
if (
call.toolRequest.name === 'read_file' &&
args.file_path === 'src/legacy_processor.ts' &&
args.end_line !== undefined
args.limit !== undefined
) {
return true;
}
+1 -1
View File
@@ -56,7 +56,7 @@ describe('interactive_commands', () => {
const scaffoldCall = logs.find(
(l) =>
l.toolRequest.name === 'run_shell_command' &&
/npm (init|create)|npx (.*)?create-|yarn create|pnpm create/.test(
/npm (init|create)|npx create-|yarn create|pnpm create/.test(
l.toolRequest.args,
),
);
@@ -1,2 +0,0 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Tail call completed successfully."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
-107
View File
@@ -286,113 +286,6 @@ describe('Hooks System Integration', () => {
});
});
describe('Command Hooks - Tail Tool Calls', () => {
it('should execute a tail tool call from AfterTool hooks and replace original response', async () => {
// Create a script that acts as the hook.
// It will trigger on "read_file" and issue a tail call to "write_file".
rig.setup('should execute a tail tool call from AfterTool hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.tail-tool-call.responses',
),
});
const hookOutput = {
decision: 'allow',
hookSpecificOutput: {
hookEventName: 'AfterTool',
tailToolCallRequest: {
name: 'write_file',
args: {
file_path: 'tail-called-file.txt',
content: 'Content from tail call',
},
},
},
};
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
hookOutput,
)})); process.exit(0);`;
const scriptPath = join(rig.testDir!, 'tail_call_hook.js');
writeFileSync(scriptPath, hookScript);
const commandPath = scriptPath.replace(/\\/g, '/');
rig.setup('should execute a tail tool call from AfterTool hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.tail-tool-call.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
AfterTool: [
{
matcher: 'read_file',
hooks: [
{
type: 'command',
command: `node "${commandPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
// Create a test file to trigger the read_file tool
rig.createFile('original.txt', 'Original content');
const cliOutput = await rig.run({
args: 'Read original.txt', // Fake responses should trigger read_file on this
});
// 1. Verify that write_file was called (as a tail call replacing read_file)
// Since read_file was replaced before finalizing, it will not appear in the tool logs.
const foundWriteFile = await rig.waitForToolCall('write_file');
expect(foundWriteFile).toBeTruthy();
// Ensure hook logs are flushed and the final LLM response is received.
// The mock LLM is configured to respond with "Tail call completed successfully."
expect(cliOutput).toContain('Tail call completed successfully.');
// Ensure telemetry is written to disk
await rig.waitForTelemetryReady();
// Read hook logs to debug
const hookLogs = rig.readHookLogs();
const relevantHookLog = hookLogs.find(
(l) => l.hookCall.hook_event_name === 'AfterTool',
);
expect(relevantHookLog).toBeDefined();
// 2. Verify write_file was executed.
// In non-interactive mode, the CLI deduplicates tool execution logs by callId.
// Since a tail call reuses the original callId, "Tool: write_file" is not printed.
// Instead, we verify the side-effect (file creation) and the telemetry log.
// 3. Verify the tail-called tool actually wrote the file
const modifiedContent = rig.readFile('tail-called-file.txt');
expect(modifiedContent).toBe('Content from tail call');
// 4. Verify telemetry for the final tool call.
// The original 'read_file' call is replaced, so only 'write_file' is finalized and logged.
const toolLogs = rig.readToolLogs();
const successfulTools = toolLogs.filter((t) => t.toolRequest.success);
expect(
successfulTools.some((t) => t.toolRequest.name === 'write_file'),
).toBeTruthy();
// The original request name should be preserved in the log payload if possible,
// but the executed tool name is 'write_file'.
});
});
describe('BeforeModel Hooks - LLM Request Modification', () => {
it('should modify LLM requests with BeforeModel hooks', async () => {
// Create a hook script that replaces the LLM request with a modified version
+426 -379
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -29,8 +29,6 @@ import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
getContextIdFromMetadata,
getAgentSettingsFromMetadata,
} from '../types.js';
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
@@ -119,7 +117,8 @@ export class CoderAgentExecutor implements AgentExecutor {
const agentSettings = persistedState._agentSettings;
const config = await this.getConfig(agentSettings, sdkTask.id);
const contextId: string =
getContextIdFromMetadata(metadata) || sdkTask.contextId;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(metadata['_contextId'] as string) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
contextId,
@@ -142,10 +141,8 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise<TaskWrapper> {
const agentSettings: AgentSettings = agentSettingsInput || {
kind: CoderAgentEvent.StateAgentSettingsEvent,
workspacePath: process.cwd(),
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = agentSettingsInput || ({} as AgentSettings);
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
taskId,
@@ -295,7 +292,8 @@ export class CoderAgentExecutor implements AgentExecutor {
const contextId: string =
userMessage.contextId ||
sdkTask?.contextId ||
getContextIdFromMetadata(sdkTask?.metadata) ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(sdkTask?.metadata?.['_contextId'] as string) ||
uuidv4();
logger.info(
@@ -390,7 +388,10 @@ export class CoderAgentExecutor implements AgentExecutor {
}
} else {
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = userMessage.metadata?.[
'coderAgent'
] as AgentSettings;
try {
wrapper = await this.createTask(
taskId,
+2 -8
View File
@@ -513,10 +513,7 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
},
confirmationDetails: { onConfirm: onConfirmSpy },
},
] as unknown as ToolCall[];
@@ -536,10 +533,7 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
},
confirmationDetails: { onConfirm: onConfirmSpy },
},
] as unknown as ToolCall[];
+41 -99
View File
@@ -59,33 +59,6 @@ import type { PartUnion, Part as genAiPart } from '@google/genai';
type UnionKeys<T> = T extends T ? keyof T : never;
type ConfirmationType = ToolCallConfirmationDetails['type'];
const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [
'edit',
'exec',
'mcp',
'info',
'ask_user',
'exit_plan_mode',
] as const;
function isToolCallConfirmationDetails(
value: unknown,
): value is ToolCallConfirmationDetails {
if (
typeof value !== 'object' ||
value === null ||
!('onConfirm' in value) ||
typeof value.onConfirm !== 'function' ||
!('type' in value) ||
typeof value.type !== 'string'
) {
return false;
}
return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type);
}
export class Task {
id: string;
contextId: string;
@@ -403,10 +376,11 @@ export class Task {
}
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
const details = tc.confirmationDetails;
if (isToolCallConfirmationDetails(details)) {
this.pendingToolConfirmationDetails.set(tc.request.callId, details);
}
this.pendingToolConfirmationDetails.set(
tc.request.callId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
tc.confirmationDetails as ToolCallConfirmationDetails,
);
}
// Only send an update if the status has actually changed.
@@ -438,12 +412,11 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
const details = tc.confirmationDetails;
if (isToolCallConfirmationDetails(details)) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
this.pendingToolConfirmationDetails.delete(tc.request.callId);
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
this.pendingToolConfirmationDetails.delete(tc.request.callId);
}
});
return;
@@ -493,13 +466,15 @@ export class Task {
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys<T>,
>(from: T, ...fields: K[]): Partial<T> {
const ret: Partial<T> = {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const ret = {} as Pick<T, K>;
for (const field of fields) {
if (field in from && from[field] !== undefined) {
if (field in from) {
ret[field] = from[field];
}
}
return ret;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return ret as Partial<T>;
}
private toolStatusMessage(
@@ -510,11 +485,8 @@ export class Task {
const messageParts: Part[] = [];
// Create a serializable version of the ToolCall (pick necessary
// properties/avoid methods causing circular reference errors).
// Type allows tool to be Partial<AnyDeclarativeTool> for serialization.
const serializableToolCall: Partial<Omit<ToolCall, 'tool'>> & {
tool?: Partial<AnyDeclarativeTool>;
} = this._pickFields(
// properties/avoid methods causing circular reference errors)
const serializableToolCall: Partial<ToolCall> = this._pickFields(
tc,
'request',
'status',
@@ -524,7 +496,8 @@ export class Task {
);
if (tc.tool) {
const toolFields = this._pickFields(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
serializableToolCall.tool = this._pickFields(
tc.tool,
'name',
'displayName',
@@ -534,8 +507,7 @@ export class Task {
'canUpdateOutput',
'schema',
'parameterSchema',
);
serializableToolCall.tool = toolFields;
) as AnyDeclarativeTool;
}
messageParts.push({
@@ -558,15 +530,8 @@ export class Task {
old_string: string,
new_string: string,
): Promise<string> {
// Validate path to prevent path traversal vulnerabilities
const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
const pathError = this.config.validatePathAccess(resolvedPath, 'read');
if (pathError) {
throw new Error(`Path validation failed: ${pathError}`);
}
try {
const currentContent = await fs.readFile(resolvedPath, 'utf8');
const currentContent = await fs.readFile(file_path, 'utf8');
return this._applyReplacement(
currentContent,
old_string,
@@ -660,32 +625,15 @@ export class Task {
request.args['old_string'] &&
request.args['new_string']
) {
const filePath = request.args['file_path'];
const oldString = request.args['old_string'];
const newString = request.args['new_string'];
if (
typeof filePath === 'string' &&
typeof oldString === 'string' &&
typeof newString === 'string'
) {
// Resolve and validate path to prevent path traversal (user-controlled file_path).
const resolvedPath = path.resolve(
this.config.getTargetDir(),
filePath,
);
const pathError = this.config.validatePathAccess(
resolvedPath,
'read',
);
if (!pathError) {
const newContent = await this.getProposedContent(
resolvedPath,
oldString,
newString,
);
return { ...request, args: { ...request.args, newContent } };
}
}
const newContent = await this.getProposedContent(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['file_path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['old_string'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['new_string'] as string,
);
return { ...request, args: { ...request.args, newContent } };
}
return request;
}),
@@ -777,17 +725,10 @@ export class Task {
break;
case GeminiEventType.Error:
default: {
// Use type guard instead of unsafe type assertion
let errorEvent: ServerGeminiErrorEvent | undefined;
if (
event.type === GeminiEventType.Error &&
event.value &&
typeof event.value === 'object' &&
'error' in event.value
) {
errorEvent = event;
}
const errorMessage = errorEvent?.value?.error
// Block scope for lexical declaration
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
const errorMessage = errorEvent.value?.error
? getErrorMessage(errorEvent.value.error)
: 'Unknown error from LLM stream';
logger.error(
@@ -796,7 +737,7 @@ export class Task {
);
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
if (errorEvent?.value?.error) {
if (errorEvent.value?.error) {
errMessage = parseAndFormatApiError(errorEvent.value.error);
}
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
@@ -873,11 +814,12 @@ export class Task {
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
const newContent = part.data['newContent'];
const payload =
typeof newContent === 'string'
? ({ newContent } as ToolConfirmationPayload)
: undefined;
const payload = part.data['newContent']
? ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
newContent: part.data['newContent'] as string,
} as ToolConfirmationPayload)
: undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
await confirmationDetails.onConfirm(confirmationOutcome, payload);
+2 -51
View File
@@ -122,60 +122,11 @@ export type PersistedTaskMetadata = { [k: string]: unknown };
export const METADATA_KEY = '__persistedState';
function isAgentSettings(value: unknown): value is AgentSettings {
return (
typeof value === 'object' &&
value !== null &&
'kind' in value &&
value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
'workspacePath' in value &&
typeof value.workspacePath === 'string'
);
}
function isPersistedStateMetadata(
value: unknown,
): value is PersistedStateMetadata {
return (
typeof value === 'object' &&
value !== null &&
'_agentSettings' in value &&
'_taskState' in value &&
isAgentSettings(value._agentSettings)
);
}
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
const state = metadata?.[METADATA_KEY];
if (isPersistedStateMetadata(state)) {
return state;
}
return undefined;
}
export function getContextIdFromMetadata(
metadata: PersistedTaskMetadata | undefined,
): string | undefined {
if (!metadata) {
return undefined;
}
const contextId = metadata['_contextId'];
return typeof contextId === 'string' ? contextId : undefined;
}
export function getAgentSettingsFromMetadata(
metadata: PersistedTaskMetadata | undefined,
): AgentSettings | undefined {
if (!metadata) {
return undefined;
}
const coderAgent = metadata['coderAgent'];
if (isAgentSettings(coderAgent)) {
return coderAgent;
}
return undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
}
export function setPersistedState(
@@ -71,7 +71,6 @@ export function createMockConfig(
getMcpServers: vi.fn().mockReturnValue({}),
}),
getGitService: vi.fn(),
validatePathAccess: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
-34
View File
@@ -2016,40 +2016,6 @@ describe('loadCliConfig useRipgrep', () => {
});
});
describe('loadCliConfig directWebFetch', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should be false by default when directWebFetch is not set in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getDirectWebFetch()).toBe(false);
});
it('should be true when directWebFetch is set to true in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
experimental: {
directWebFetch: true,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getDirectWebFetch()).toBe(true);
});
});
describe('screenReader configuration', () => {
beforeEach(() => {
vi.resetAllMocks();
+1 -3
View File
@@ -826,8 +826,7 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan,
planSettings: settings.general.plan,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -878,7 +877,6 @@ export async function loadCliConfig(
agents: refreshedSettings.merged.agents,
};
},
enableConseca: settings.security?.enableConseca,
});
}
@@ -132,35 +132,6 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.ASK_USER);
});
it('should handle global MCP wildcard (*) in settings', async () => {
const settings: Settings = {
mcp: {
allowed: ['*'],
},
};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
);
const engine = new PolicyEngine(config);
// ANY tool with a server name should be allowed
expect(
(await engine.check({ name: 'mcp-server__tool' }, 'mcp-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'another-server__tool' }, 'another-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
// Built-in tools should NOT be allowed by the MCP wildcard
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => {
const settings: Settings = {
mcp: {
@@ -352,38 +323,6 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.DENY);
});
it('should correctly match tool annotations', async () => {
const settings: Settings = {};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
);
// Add a manual rule with annotations to the config
config.rules = config.rules || [];
config.rules.push({
toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ALLOW,
priority: 10,
});
const engine = new PolicyEngine(config);
// A tool with readOnlyHint=true should be ALLOWED
const roCall = { name: 'some_tool', args: {} };
const roMeta = { readOnlyHint: true };
expect((await engine.check(roCall, undefined, roMeta)).decision).toBe(
PolicyDecision.ALLOW,
);
// A tool without the hint (or with false) should follow default decision (ASK_USER)
const rwMeta = { readOnlyHint: false };
expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe(
PolicyDecision.ASK_USER,
);
});
describe.each(['write_file', 'replace'])(
'Plan Mode policy for %s',
(toolName) => {
-44
View File
@@ -142,48 +142,4 @@ describe('resolveWorkspacePolicyState', () => {
expect.stringContaining('Automatically accepting and loading'),
);
});
it('should not return workspace policies if cwd is the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
// Run from HOME directory (tempDir is mocked as HOME in beforeEach)
const result = await resolveWorkspacePolicyState({
cwd: tempDir,
trustedFolder: true,
interactive: true,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
// Create a symlink to the home directory
const symlinkDir = path.join(
os.tmpdir(),
`gemini-cli-symlink-${Date.now()}`,
);
fs.symlinkSync(tempDir, symlinkDir, 'dir');
try {
// Run from symlink to HOME directory
const result = await resolveWorkspacePolicyState({
cwd: symlinkDir,
trustedFolder: true,
interactive: true,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
} finally {
// Clean up symlink
fs.unlinkSync(symlinkDir);
}
});
});
+3 -9
View File
@@ -67,15 +67,9 @@ export async function resolveWorkspacePolicyState(options: {
| undefined;
if (trustedFolder) {
const storage = new Storage(cwd);
// If we are in the home directory (or rather, our target Gemini dir is the global one),
// don't treat it as a workspace to avoid loading global policies twice.
if (storage.isWorkspaceHomeDir()) {
return { workspacePoliciesDir: undefined };
}
const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir();
const potentialWorkspacePoliciesDir = new Storage(
cwd,
).getWorkspacePoliciesDir();
const integrityManager = new PolicyIntegrityManager();
const integrityResult = await integrityManager.checkIntegrity(
'workspace',
+7 -41
View File
@@ -79,7 +79,6 @@ import {
import {
FatalConfigError,
GEMINI_DIR,
Storage,
type MCPServerConfig,
} from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -127,30 +126,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const os = await import('node:os');
const pathMod = await import('node:path');
const fsMod = await import('node:fs');
// Helper to resolve paths using the test's mocked environment
const testResolve = (p: string | undefined) => {
if (!p) return '';
try {
// Use the mocked fs.realpathSync if available, otherwise fallback
return fsMod.realpathSync(pathMod.resolve(p));
} catch {
return pathMod.resolve(p);
}
};
// Create a smarter mock for isWorkspaceHomeDir
vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation(
function (this: Storage) {
const target = testResolve(pathMod.dirname(this.getGeminiDir()));
// Pick up the mocked home directory specifically from the 'os' mock
const home = testResolve(os.homedir());
return actual.normalizePath(target) === actual.normalizePath(home);
},
);
return {
...actual,
coreEvents: mockCoreEvents,
@@ -1516,29 +1491,20 @@ describe('Settings Loading and Merging', () => {
return pStr;
});
// Force the storage check to return true for this specific test
const isWorkspaceHomeDirSpy = vi
.spyOn(Storage.prototype, 'isWorkspaceHomeDir')
.mockReturnValue(true);
(mockFsExistsSync as Mock).mockImplementation(
(p: string) =>
// Only return true for workspace settings path to see if it gets loaded
p === mockWorkspaceSettingsPath,
);
try {
const settings = loadSettings(mockSymlinkDir);
const settings = loadSettings(mockSymlinkDir);
// Verify that even though the file exists, it was NOT loaded because realpath matched home
expect(fs.readFileSync).not.toHaveBeenCalledWith(
mockWorkspaceSettingsPath,
'utf-8',
);
expect(settings.workspace.settings).toEqual({});
} finally {
isWorkspaceHomeDirSpy.mockRestore();
}
// Verify that even though the file exists, it was NOT loaded because realpath matched home
expect(fs.readFileSync).not.toHaveBeenCalledWith(
mockWorkspaceSettingsPath,
'utf-8',
);
expect(settings.workspace.settings).toEqual({});
});
});
+21 -5
View File
@@ -637,8 +637,24 @@ export function loadSettings(
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
const storage = new Storage(workspaceDir);
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
const resolvedHomeDir = path.resolve(homedir());
let realWorkspaceDir = resolvedWorkspaceDir;
try {
// fs.realpathSync gets the "true" path, resolving any symlinks
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
} catch (_e) {
// This is okay. The path might not exist yet, and that's a valid state.
}
// We expect homedir to always exist and be resolvable.
const realHomeDir = fs.realpathSync(resolvedHomeDir);
const workspaceSettingsPath = new Storage(
workspaceDir,
).getWorkspaceSettingsPath();
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
try {
@@ -696,7 +712,7 @@ export function loadSettings(
settings: {} as Settings,
rawJson: undefined,
};
if (!storage.isWorkspaceHomeDir()) {
if (realWorkspaceDir !== realHomeDir) {
workspaceResult = load(workspaceSettingsPath);
}
@@ -784,11 +800,11 @@ export function loadSettings(
readOnly: false,
},
{
path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
settings: workspaceSettings,
originalSettings: workspaceOriginalSettings,
rawJson: workspaceResult.rawJson,
readOnly: storage.isWorkspaceHomeDir(),
readOnly: realWorkspaceDir === realHomeDir,
},
isTrusted,
settingsErrors,
-30
View File
@@ -297,16 +297,6 @@ const SETTINGS_SCHEMA = {
'Retry on "exception TypeError: fetch failed sending request" errors.',
showInDialog: false,
},
maxAttempts: {
type: 'number',
label: 'Max Chat Model Attempts',
category: 'General',
requiresRestart: false,
default: 10,
description:
'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.',
showInDialog: true,
},
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
@@ -1493,16 +1483,6 @@ const SETTINGS_SCHEMA = {
},
},
},
enableConseca: {
type: 'boolean',
label: 'Enable Context-Aware Security',
category: 'Security',
requiresRestart: true,
default: false,
description:
'Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.',
showInDialog: true,
},
},
},
@@ -1713,16 +1693,6 @@ const SETTINGS_SCHEMA = {
'Enable model steering (user hints) to guide the model during tool execution.',
showInDialog: true,
},
directWebFetch: {
type: 'boolean',
label: 'Direct Web Fetch',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable web fetch behavior that bypasses LLM summarization.',
showInDialog: true,
},
},
},
@@ -182,7 +182,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('85%');
expect(lastFrame()).toContain('15%');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -411,6 +411,73 @@ describe('InputPrompt', () => {
unmount();
});
it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => {
props.shellModeActive = true;
props.buffer.setText('ls ');
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'dir1', value: 'dir1' },
{ label: 'dir2', value: 'dir2' },
],
activeSuggestionIndex: 0,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
// Press Enter without navigating — should dismiss suggestions and fall
// through to the main submit handler.
await act(async () => {
stdin.write('\r');
});
await waitFor(() => {
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed)
});
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
unmount();
});
it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => {
props.shellModeActive = true;
props.buffer.setText('ls ');
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'dir1', value: 'dir1' },
{ label: 'dir2', value: 'dir2' },
],
activeSuggestionIndex: 1,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
// Press ArrowDown to navigate, then Enter to accept
await act(async () => {
stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions
});
await waitFor(() =>
expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(),
);
await act(async () => {
stdin.write('\r'); // Enter — should accept navigated suggestion
});
await waitFor(() => {
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
});
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should NOT call shell history methods when not in shell mode', async () => {
props.buffer.setText('some text');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
+34 -3
View File
@@ -259,6 +259,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
>(null);
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const innerBoxRef = useRef<DOMElement>(null);
const hasUserNavigatedSuggestions = useRef(false);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [commandSearchActive, setCommandSearchActive] = useState(false);
@@ -615,6 +616,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setSuppressCompletion(
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
);
hasUserNavigatedSuggestions.current = false;
}
// TODO(jacobr): this special case is likely not needed anymore.
@@ -648,7 +650,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
Boolean(completion.promptCompletion.text) ||
reverseSearchActive ||
commandSearchActive;
if (isPlainTab) {
if (isPlainTab && shellModeActive) {
resetPlainTabPress();
if (!completion.showSuggestions) {
setSuppressCompletion(false);
}
} else if (isPlainTab) {
if (!hasTabCompletionInteraction) {
if (registerPlainTabPress() === 2) {
toggleCleanUiDetailsVisible();
@@ -892,7 +900,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.isPerfectMatch &&
keyMatchers[Command.SUBMIT](key) &&
recentUnsafePasteTime === null &&
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
(!completion.showSuggestions ||
(completion.activeSuggestionIndex <= 0 &&
!hasUserNavigatedSuggestions.current))
) {
handleSubmit(buffer.text);
return true;
@@ -908,11 +918,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (completion.suggestions.length > 1) {
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
hasUserNavigatedSuggestions.current = true;
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return true;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
hasUserNavigatedSuggestions.current = true;
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return true;
}
@@ -930,6 +942,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const isEnterKey = key.name === 'return' && !key.ctrl;
if (isEnterKey && shellModeActive) {
if (hasUserNavigatedSuggestions.current) {
completion.handleAutocomplete(
completion.activeSuggestionIndex,
);
setExpandedSuggestionIndex(-1);
hasUserNavigatedSuggestions.current = false;
return true;
}
completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
hasUserNavigatedSuggestions.current = false;
if (buffer.text.trim()) {
handleSubmit(buffer.text);
}
return true;
}
if (isEnterKey && buffer.text.startsWith('/')) {
const { isArgumentCompletion, leafCommand } =
completion.slashCompletionRange;
@@ -1386,7 +1416,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
scrollOffset={activeCompletion.visibleStartIndex}
userInput={buffer.text}
mode={
completion.completionMode === CompletionMode.AT
completion.completionMode === CompletionMode.AT ||
completion.completionMode === CompletionMode.SHELL
? 'reverse'
: buffer.text.startsWith('/') &&
!reverseSearchActive &&
@@ -5,20 +5,10 @@
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { QuotaDisplay } from './QuotaDisplay.js';
describe('QuotaDisplay', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'America/Los_Angeles');
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-02T20:29:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it('should not render when remaining is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={undefined} limit={100} />,
@@ -46,7 +36,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should not render when usage < 80%', async () => {
it('should not render when usage > 20%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={85} limit={100} />,
);
@@ -55,7 +45,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render warning when used >= 80%', async () => {
it('should render yellow when usage < 20%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={15} limit={100} />,
);
@@ -64,7 +54,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render critical when used >= 95%', async () => {
it('should render red when usage < 5%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={4} limit={100} />,
);
+16 -18
View File
@@ -7,9 +7,9 @@
import type React from 'react';
import { Text } from 'ink';
import {
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
} from '../utils/displayUtils.js';
import { formatResetTime } from '../utils/formatters.js';
@@ -30,24 +30,26 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
return null;
}
const usedPercentage = 100 - (remaining / limit) * 100;
const percentage = (remaining / limit) * 100;
if (usedPercentage < QUOTA_USED_WARNING_THRESHOLD) {
if (percentage > QUOTA_THRESHOLD_HIGH) {
return null;
}
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
});
const resetInfo =
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
if (remaining === 0) {
const resetMsg = resetTime
? `, resets in ${formatResetTime(resetTime, true)}`
: '';
return (
<Text color={color}>
{terse ? 'Limit reached' : `Limit reached${resetMsg}`}
{terse
? 'Limit reached'
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`}
</Text>
);
}
@@ -55,12 +57,8 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
return (
<Text color={color}>
{terse
? `${usedPercentage.toFixed(0)}%`
: `${usedPercentage.toFixed(0)}% used${
resetTime
? ` (Limit resets in ${formatResetTime(resetTime, true)})`
: ''
}`}
? `${percentage.toFixed(0)}%`
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`}
</Text>
);
};
@@ -9,9 +9,9 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { formatResetTime } from '../utils/formatters.js';
import {
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
} from '../utils/displayUtils.js';
interface QuotaStatsInfoProps {
@@ -31,24 +31,19 @@ export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
return null;
}
const usedPercentage = 100 - (remaining / limit) * 100;
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
const percentage = (remaining / limit) * 100;
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
});
return (
<Box flexDirection="column" marginTop={0} marginBottom={0}>
<Text color={color}>
{remaining === 0
? `Limit reached${
resetTime ? `, resets in ${formatResetTime(resetTime, true)}` : ''
}`
: `${usedPercentage.toFixed(0)}% used${
resetTime
? ` (Limit resets in ${formatResetTime(resetTime, true)})`
: ''
}`}
? `Limit reached`
: `${percentage.toFixed(0)}% usage remaining`}
{resetTime && `, ${formatResetTime(resetTime)}`}
</Text>
{showDetails && (
<>
@@ -465,9 +465,9 @@ describe('<StatsDisplay />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Model usage');
expect(output).toContain('25%');
expect(output).toContain('Usage resets');
expect(output).toContain('Usage remaining');
expect(output).toContain('75.0%');
expect(output).toContain('resets in 1h 30m');
expect(output).toMatchSnapshot();
vi.useRealTimers();
@@ -521,8 +521,8 @@ describe('<StatsDisplay />', () => {
await waitUntilReady();
const output = lastFrame();
// (1 - 710/1100) * 100 = 35.5%
expect(output).toContain('35%');
// (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
expect(output).toContain('65% usage remaining');
expect(output).toContain('Usage limit: 1,100');
expect(output).toMatchSnapshot();
@@ -571,8 +571,8 @@ describe('<StatsDisplay />', () => {
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('-'); // for requests
expect(output).toContain('50%');
expect(output).toContain('Usage resets');
expect(output).toContain('50.0%');
expect(output).toContain('resets in 2h');
expect(output).toMatchSnapshot();
vi.useRealTimers();
+40 -164
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { Box, Text, useStdout } from 'ink';
import { Box, Text } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import { theme } from '../semantic-colors.js';
import { formatDuration, formatResetTime } from '../utils/formatters.js';
@@ -19,9 +19,6 @@ import {
USER_AGREEMENT_RATE_MEDIUM,
CACHE_EFFICIENCY_HIGH,
CACHE_EFFICIENCY_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import {
@@ -158,8 +155,6 @@ const ModelUsageTable: React.FC<{
useGemini3_1,
useCustomToolModel,
}) => {
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 84;
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
if (rows.length === 0) {
@@ -168,47 +163,12 @@ const ModelUsageTable: React.FC<{
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
const nameWidth = 23;
const requestsWidth = 5;
const nameWidth = 25;
const requestsWidth = 7;
const uncachedWidth = 15;
const cachedWidth = 14;
const outputTokensWidth = 15;
const percentageWidth = showQuotaColumn ? 6 : 0;
const resetWidth = 22;
// Total width of other columns (including parent box paddingX={2})
const fixedWidth = nameWidth + requestsWidth + percentageWidth + resetWidth;
const outerPadding = 4;
const availableForUsage = terminalWidth - outerPadding - fixedWidth;
const usageLimitWidth = showQuotaColumn
? Math.max(10, Math.min(24, availableForUsage))
: 0;
const progressBarWidth = Math.max(2, usageLimitWidth - 4);
const renderProgressBar = (
usedFraction: number,
color: string,
totalSteps = 20,
) => {
let filledSteps = Math.round(usedFraction * totalSteps);
// If something is used (fraction > 0) but rounds to 0, show 1 tick.
// If < 100% (fraction < 1) but rounds to 20, show 19 ticks.
if (usedFraction > 0 && usedFraction < 1) {
filledSteps = Math.min(Math.max(filledSteps, 1), totalSteps - 1);
}
const emptySteps = Math.max(0, totalSteps - filledSteps);
return (
<Box flexDirection="row" flexShrink={0}>
<Text wrap="truncate-end">
<Text color={color}>{'▬'.repeat(filledSteps)}</Text>
<Text color={theme.border.default}>{'▬'.repeat(emptySteps)}</Text>
</Text>
</Box>
);
};
const usageLimitWidth = showQuotaColumn ? 28 : 0;
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
green: CACHE_EFFICIENCY_HIGH,
@@ -219,13 +179,25 @@ const ModelUsageTable: React.FC<{
nameWidth +
requestsWidth +
(showQuotaColumn
? usageLimitWidth + percentageWidth + resetWidth
? usageLimitWidth
: uncachedWidth + cachedWidth + outputTokensWidth);
const isAuto = currentModel && isAutoModel(currentModel);
const modelUsageTitle = isAuto
? `${getDisplayString(currentModel)} Usage`
: `Model Usage`;
return (
<Box flexDirection="column" marginBottom={1}>
{/* Header */}
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Text bold color={theme.text.primary} wrap="truncate-end">
{modelUsageTitle}
</Text>
</Box>
</Box>
{isAuto &&
showQuotaColumn &&
pooledRemaining !== undefined &&
@@ -244,7 +216,7 @@ const ModelUsageTable: React.FC<{
)}
<Box alignItems="flex-end">
<Box width={nameWidth} flexShrink={0}>
<Box width={nameWidth}>
<Text bold color={theme.text.primary}>
Model
</Text>
@@ -295,31 +267,15 @@ const ModelUsageTable: React.FC<{
</>
)}
{showQuotaColumn && (
<>
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={4}
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Model usage
</Text>
</Box>
<Box width={percentageWidth} flexShrink={0} />
<Box
width={resetWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={2}
flexShrink={0}
>
<Text bold color={theme.text.primary} wrap="truncate-end">
Usage resets
</Text>
</Box>
</>
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
<Text bold color={theme.text.primary}>
Usage remaining
</Text>
</Box>
)}
</Box>
@@ -336,7 +292,7 @@ const ModelUsageTable: React.FC<{
{rows.map((row) => (
<Box key={row.key}>
<Box width={nameWidth} flexShrink={0}>
<Box width={nameWidth}>
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
wrap="truncate-end"
@@ -396,100 +352,20 @@ const ModelUsageTable: React.FC<{
</Box>
</>
)}
{showQuotaColumn && (
<>
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={4}
flexShrink={0}
>
{row.bucket && row.bucket.remainingFraction != null && (
<Box flexDirection="row" flexShrink={0}>
{(() => {
const actualUsedFraction =
1 - row.bucket.remainingFraction;
const effectiveUsedFraction =
actualUsedFraction === 0 && row.isActive
? 0.001
: actualUsedFraction;
const usedPercentage = effectiveUsedFraction * 100;
const statusColor =
getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
}) ??
(row.isActive ? theme.text.primary : theme.ui.comment);
return renderProgressBar(
effectiveUsedFraction,
statusColor,
progressBarWidth,
);
})()}
</Box>
)}
</Box>
<Box
width={percentageWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
{row.bucket && row.bucket.remainingFraction != null && (
<Box>
{(() => {
const actualUsedFraction =
1 - row.bucket.remainingFraction;
const effectiveUsedFraction =
actualUsedFraction === 0 && row.isActive
? 0.001
: actualUsedFraction;
const usedPercentage = effectiveUsedFraction * 100;
const statusColor =
getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
}) ??
(row.isActive ? theme.text.primary : theme.ui.comment);
const percentageText =
usedPercentage > 0 && usedPercentage < 1
? `${usedPercentage.toFixed(1)}%`
: `${usedPercentage.toFixed(0)}%`;
return row.bucket.remainingFraction === 0 ? (
<Text color={theme.status.error} wrap="truncate-end">
Limit
</Text>
) : (
<Text color={statusColor} wrap="truncate-end">
{percentageText}
</Text>
);
})()}
</Box>
)}
</Box>
<Box
width={resetWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={2}
flexShrink={0}
>
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
{row.bucket &&
row.bucket.remainingFraction != null &&
row.bucket.resetTime && (
<Text color={theme.text.secondary} wrap="truncate-end">
{row.bucket?.resetTime &&
formatResetTime(row.bucket.resetTime, 'column')
? formatResetTime(row.bucket.resetTime, 'column')
: ''}
{(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
{formatResetTime(row.bucket.resetTime)}
</Text>
</Box>
</>
)}
)}
</Box>
</Box>
))}
@@ -89,12 +89,11 @@ const renderStatusDisplay = async (
};
describe('StatusDisplay', () => {
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
});
const originalEnv = process.env;
afterEach(() => {
vi.unstubAllEnvs();
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
vi.restoreAllMocks();
});
@@ -113,7 +112,7 @@ describe('StatusDisplay', () => {
});
it('renders system md indicator if env var is set', async () => {
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
process.env['GEMINI_SYSTEM_MD'] = 'true';
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -6,7 +6,7 @@ exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] =
`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 85%
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%
"
`;
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `
"85%
"15%
"
`;
exports[`QuotaDisplay > should render critical when used >= 95% 1`] = `
"96% used
exports[`QuotaDisplay > should render red when usage < 5% 1`] = `
"/stats 4% usage remaining
"
`;
@@ -15,12 +15,12 @@ exports[`QuotaDisplay > should render terse limit reached message 1`] = `
"
`;
exports[`QuotaDisplay > should render warning when used >= 80% 1`] = `
"85% used
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
"/stats 15% usage remaining, resets in 1h
"
`;
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
"85% used (Limit resets in 1h)
exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `
"/stats 15% usage remaining
"
`;
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -117,9 +117,10 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 100 0 100
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 0 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -161,15 +162,16 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
35% used
auto Usage
│ 65% usage remaining │
│ Usage limit: 1,100 │
│ Usage limits span all sessions and reset daily. │
│ For a full token breakdown, run \`/stats model\`. │
│ │
│ Model Reqs Model usage Usage resets
│ ────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90%
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30%
│ Model Reqs Usage remaining
│ ────────────────────────────────────────────────────────────
│ gemini-2.5-pro -
│ gemini-2.5-flash -
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -191,9 +193,10 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Reqs Model usage Usage resets
────────────────────────────────────────────────────────────────────────────────
gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% 6:00 AM (2h)
│ Model Usage
Model Reqs Usage remaining
────────────────────────────────────────────────────────────
│ gemini-2.5-flash - 50.0% resets in 2h │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -215,9 +218,10 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Reqs Model usage Usage resets
────────────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% 5:30 AM (1h 30m)
│ Model Usage
Model Reqs Usage remaining
────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -279,10 +283,11 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » API Time: 19.5s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 3 500 500 2,000
│ gemini-2.5-flash 5 15,000 10,000 15,000
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 3 500 500 2,000
│ gemini-2.5-flash 5 15,000 10,000 15,000 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -307,9 +312,10 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 50 50 100
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 50 50 100 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -58,10 +58,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderColor,
borderDimColor,
isExpandable,
originalRequestName,
}) => {
const {
activePtyId: activeShellPtyId,
@@ -132,7 +129,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
status={status}
description={description}
emphasis={emphasis}
originalRequestName={originalRequestName}
/>
<FocusHint
@@ -520,77 +520,4 @@ describe('ToolConfirmationMessage', () => {
expect(output).toMatchSnapshot();
unmount();
});
it('should show MCP tool details expand hint for MCP confirmations', async () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test-server',
toolName: 'test-tool',
toolDisplayName: 'Test Tool',
toolArgs: {
url: 'https://www.google.co.jp',
},
toolDescription: 'Navigates browser to a URL.',
toolParameterSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'Destination URL',
},
},
required: ['url'],
},
onConfirm: vi.fn(),
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MCP Tool Details:');
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
expect(output).not.toContain('https://www.google.co.jp');
expect(output).not.toContain('Navigates browser to a URL.');
unmount();
});
it('should omit empty MCP invocation arguments from details', async () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test-server',
toolName: 'test-tool',
toolDisplayName: 'Test Tool',
toolArgs: {},
toolDescription: 'No arguments required.',
onConfirm: vi.fn(),
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MCP Tool Details:');
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
expect(output).not.toContain('Invocation Arguments:');
unmount();
});
});
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo, useCallback, useState } from 'react';
import { useMemo, useCallback } from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
@@ -29,7 +29,6 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
@@ -65,17 +64,6 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
}) => {
const { confirm, isDiffingEnabled } = useToolActions();
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
callId: string;
expanded: boolean;
}>({
callId,
expanded: false,
});
const isMcpToolDetailsExpanded =
mcpDetailsExpansionState.callId === callId
? mcpDetailsExpansionState.expanded
: false;
const settings = useSettings();
const allowPermanentApproval =
@@ -98,81 +86,9 @@ export const ToolConfirmationMessage: React.FC<
[confirm, callId],
);
const mcpToolDetailsText = useMemo(() => {
if (confirmationDetails.type !== 'mcp') {
return null;
}
const detailsLines: string[] = [];
const hasNonEmptyToolArgs =
confirmationDetails.toolArgs !== undefined &&
!(
typeof confirmationDetails.toolArgs === 'object' &&
confirmationDetails.toolArgs !== null &&
Object.keys(confirmationDetails.toolArgs).length === 0
);
if (hasNonEmptyToolArgs) {
let argsText: string;
try {
argsText = stripUnsafeCharacters(
JSON.stringify(confirmationDetails.toolArgs, null, 2),
);
} catch {
argsText = '[unserializable arguments]';
}
detailsLines.push('Invocation Arguments:');
detailsLines.push(argsText);
}
const description = confirmationDetails.toolDescription?.trim();
if (description) {
if (detailsLines.length > 0) {
detailsLines.push('');
}
detailsLines.push('Description:');
detailsLines.push(stripUnsafeCharacters(description));
}
if (confirmationDetails.toolParameterSchema !== undefined) {
let schemaText: string;
try {
schemaText = stripUnsafeCharacters(
JSON.stringify(confirmationDetails.toolParameterSchema, null, 2),
);
} catch {
schemaText = '[unserializable schema]';
}
if (detailsLines.length > 0) {
detailsLines.push('');
}
detailsLines.push('Input Schema:');
detailsLines.push(schemaText);
}
if (detailsLines.length === 0) {
return null;
}
return detailsLines.join('\n');
}, [confirmationDetails]);
const hasMcpToolDetails = !!mcpToolDetailsText;
const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES);
useKeypress(
(key) => {
if (!isFocused) return false;
if (
confirmationDetails.type === 'mcp' &&
hasMcpToolDetails &&
keyMatchers[Command.SHOW_MORE_LINES](key)
) {
setMcpDetailsExpansionState({
callId,
expanded: !isMcpToolDetailsExpanded,
});
return true;
}
if (keyMatchers[Command.ESCAPE](key)) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
@@ -184,7 +100,7 @@ export const ToolConfirmationMessage: React.FC<
}
return false;
},
{ isActive: isFocused, priority: true },
{ isActive: isFocused },
);
const handleSelect = useCallback(
@@ -588,31 +504,12 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
<Box flexDirection="column">
<>
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</>
{hasMcpToolDetails && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>MCP Tool Details:</Text>
{isMcpToolDetailsExpanded ? (
<>
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to collapse MCP tool details)
</Text>
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
</>
) : (
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to expand MCP tool details)
</Text>
)}
</Box>
)}
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</Box>
);
}
@@ -625,17 +522,8 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
isMcpToolDetailsExpanded,
hasMcpToolDetails,
mcpToolDetailsText,
expandDetailsHintKey,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
? 'bottom'
: 'top';
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
@@ -671,7 +559,7 @@ export const ToolConfirmationMessage: React.FC<
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection={bodyOverflowDirection}
overflowDirection="top"
>
{bodyContent}
</MaxSizedBox>
@@ -57,7 +57,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
config,
progressMessage,
progressPercent,
originalRequestName,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
@@ -94,7 +93,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
emphasis={emphasis}
progressMessage={progressMessage}
progressPercent={progressPercent}
originalRequestName={originalRequestName}
/>
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
@@ -189,7 +189,6 @@ type ToolInfoProps = {
emphasis: TextEmphasis;
progressMessage?: string;
progressPercent?: number;
originalRequestName?: string;
};
export const ToolInfo: React.FC<ToolInfoProps> = ({
@@ -199,7 +198,6 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
emphasis,
progressMessage,
progressPercent,
originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const nameColor = React.useMemo<string>(() => {
@@ -244,12 +242,6 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
<Text color={nameColor} bold>
{name}
</Text>
{originalRequestName && originalRequestName !== name && (
<Text color={theme.text.secondary} italic>
{' '}
(redirection from {originalRequestName})
</Text>
)}
{!isCompletedAskUser && (
<>
{' '}
@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { gitProvider } from './gitProvider.js';
import * as childProcess from 'node:child_process';
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return {
...actual,
execFile: vi.fn(),
};
});
describe('gitProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('suggests git subcommands for cursorIndex 1', async () => {
const result = await gitProvider.getCompletions(['git', 'ch'], 1, '/tmp');
expect(result.exclusive).toBe(true);
expect(result.suggestions).toEqual(
expect.arrayContaining([expect.objectContaining({ value: 'checkout' })]),
);
expect(
result.suggestions.find((s) => s.value === 'commit'),
).toBeUndefined();
});
it('suggests branch names for checkout at cursorIndex 2', async () => {
vi.mocked(childProcess.execFile).mockImplementation(
(_cmd, _args, _opts, cb: unknown) => {
const callback = (typeof _opts === 'function' ? _opts : cb) as (
error: Error | null,
result: { stdout: string },
) => void;
callback(null, {
stdout: 'main\nfeature-branch\nfix/bug\nbranch(with)special\n',
});
return {} as ReturnType<typeof childProcess.execFile>;
},
);
const result = await gitProvider.getCompletions(
['git', 'checkout', 'feat'],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(1);
expect(result.suggestions[0].label).toBe('feature-branch');
expect(result.suggestions[0].value).toBe('feature-branch');
expect(childProcess.execFile).toHaveBeenCalledWith(
'git',
['branch', '--format=%(refname:short)'],
expect.any(Object),
expect.any(Function),
);
});
it('escapes branch names with shell metacharacters', async () => {
vi.mocked(childProcess.execFile).mockImplementation(
(_cmd, _args, _opts, cb: unknown) => {
const callback = (typeof _opts === 'function' ? _opts : cb) as (
error: Error | null,
result: { stdout: string },
) => void;
callback(null, { stdout: 'main\nbranch(with)special\n' });
return {} as ReturnType<typeof childProcess.execFile>;
},
);
const result = await gitProvider.getCompletions(
['git', 'checkout', 'branch('],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(1);
expect(result.suggestions[0].label).toBe('branch(with)special');
// On Windows, space escape is not done. But since UNIX_SHELL_SPECIAL_CHARS is mostly tested,
// we can use a matcher that checks if escaping was applied (it differs per platform but that's handled by escapeShellPath).
// Let's match the value against either unescaped (win) or escaped (unix).
const isWin = process.platform === 'win32';
expect(result.suggestions[0].value).toBe(
isWin ? 'branch(with)special' : 'branch\\(with\\)special',
);
});
it('returns empty results if git branch fails', async () => {
vi.mocked(childProcess.execFile).mockImplementation(
(_cmd, _args, _opts, cb: unknown) => {
const callback = (typeof _opts === 'function' ? _opts : cb) as (
error: Error,
stdout?: string,
) => void;
callback(new Error('Not a git repository'));
return {} as ReturnType<typeof childProcess.execFile>;
},
);
const result = await gitProvider.getCompletions(
['git', 'checkout', ''],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(0);
});
it('returns non-exclusive for unrecognized position', async () => {
const result = await gitProvider.getCompletions(
['git', 'commit', '-m', 'some message'],
3,
'/tmp',
);
expect(result.exclusive).toBe(false);
expect(result.suggestions).toHaveLength(0);
});
});
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { ShellCompletionProvider, CompletionResult } from './types.js';
import { escapeShellPath } from '../useShellCompletion.js';
const execFileAsync = promisify(execFile);
const GIT_SUBCOMMANDS = [
'add',
'branch',
'checkout',
'commit',
'diff',
'merge',
'pull',
'push',
'rebase',
'status',
'switch',
];
export const gitProvider: ShellCompletionProvider = {
command: 'git',
async getCompletions(
tokens: string[],
cursorIndex: number,
cwd: string,
signal?: AbortSignal,
): Promise<CompletionResult> {
// We are completing the first argument (subcommand)
if (cursorIndex === 1) {
const partial = tokens[1] || '';
return {
suggestions: GIT_SUBCOMMANDS.filter((cmd) =>
cmd.startsWith(partial),
).map((cmd) => ({
label: cmd,
value: cmd,
description: 'git command',
})),
exclusive: true,
};
}
// We are completing the second argument (e.g. branch name)
if (cursorIndex === 2) {
const subcommand = tokens[1];
if (
subcommand === 'checkout' ||
subcommand === 'switch' ||
subcommand === 'merge' ||
subcommand === 'branch'
) {
const partial = tokens[2] || '';
try {
const { stdout } = await execFileAsync(
'git',
['branch', '--format=%(refname:short)'],
{ cwd, signal },
);
const branches = stdout
.split('\n')
.map((b) => b.trim())
.filter(Boolean);
return {
suggestions: branches
.filter((b) => b.startsWith(partial))
.map((b) => ({
label: b,
value: escapeShellPath(b),
description: 'branch',
})),
exclusive: true,
};
} catch {
// If git fails (e.g. not a git repo), return nothing
return { suggestions: [], exclusive: true };
}
}
}
// Unhandled git argument, fallback to default file completions
return { suggestions: [], exclusive: false };
},
};
@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ShellCompletionProvider, CompletionResult } from './types.js';
import { gitProvider } from './gitProvider.js';
import { npmProvider } from './npmProvider.js';
const providers: ShellCompletionProvider[] = [gitProvider, npmProvider];
export async function getArgumentCompletions(
commandToken: string,
tokens: string[],
cursorIndex: number,
cwd: string,
signal?: AbortSignal,
): Promise<CompletionResult | null> {
const provider = providers.find((p) => p.command === commandToken);
if (!provider) {
return null;
}
return provider.getCompletions(tokens, cursorIndex, cwd, signal);
}
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { npmProvider } from './npmProvider.js';
import * as fs from 'node:fs/promises';
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));
describe('npmProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('suggests npm subcommands for cursorIndex 1', async () => {
const result = await npmProvider.getCompletions(['npm', 'ru'], 1, '/tmp');
expect(result.exclusive).toBe(true);
expect(result.suggestions).toEqual([
expect.objectContaining({ value: 'run' }),
]);
});
it('suggests package.json scripts for npm run at cursorIndex 2', async () => {
const mockPackageJson = {
scripts: {
start: 'node index.js',
build: 'tsc',
'build:dev': 'tsc --watch',
},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
const result = await npmProvider.getCompletions(
['npm', 'run', 'bu'],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(2);
expect(result.suggestions[0].label).toBe('build');
expect(result.suggestions[0].value).toBe('build');
expect(result.suggestions[1].label).toBe('build:dev');
expect(result.suggestions[1].value).toBe('build:dev');
expect(fs.readFile).toHaveBeenCalledWith(
expect.stringContaining('package.json'),
'utf8',
);
});
it('escapes script names with shell metacharacters', async () => {
const mockPackageJson = {
scripts: {
'build(prod)': 'tsc',
'test:watch': 'vitest',
},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
const result = await npmProvider.getCompletions(
['npm', 'run', 'bu'],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(1);
expect(result.suggestions[0].label).toBe('build(prod)');
// Windows does not escape spaces/parens in cmds by default in our function, but Unix does.
const isWin = process.platform === 'win32';
expect(result.suggestions[0].value).toBe(
isWin ? 'build(prod)' : 'build\\(prod\\)',
);
});
it('handles missing package.json gracefully', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
const result = await npmProvider.getCompletions(
['npm', 'run', ''],
2,
'/tmp',
);
expect(result.exclusive).toBe(true);
expect(result.suggestions).toHaveLength(0);
});
it('returns non-exclusive for unrecognized position', async () => {
const result = await npmProvider.getCompletions(
['npm', 'install', 'react'],
2,
'/tmp',
);
expect(result.exclusive).toBe(false);
expect(result.suggestions).toHaveLength(0);
});
});
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { ShellCompletionProvider, CompletionResult } from './types.js';
import { escapeShellPath } from '../useShellCompletion.js';
const NPM_SUBCOMMANDS = [
'build',
'ci',
'dev',
'install',
'publish',
'run',
'start',
'test',
];
export const npmProvider: ShellCompletionProvider = {
command: 'npm',
async getCompletions(
tokens: string[],
cursorIndex: number,
cwd: string,
signal?: AbortSignal,
): Promise<CompletionResult> {
if (cursorIndex === 1) {
const partial = tokens[1] || '';
return {
suggestions: NPM_SUBCOMMANDS.filter((cmd) =>
cmd.startsWith(partial),
).map((cmd) => ({
label: cmd,
value: cmd,
description: 'npm command',
})),
exclusive: true,
};
}
if (cursorIndex === 2 && tokens[1] === 'run') {
const partial = tokens[2] || '';
try {
if (signal?.aborted) return { suggestions: [], exclusive: true };
const pkgJsonPath = path.join(cwd, 'package.json');
const content = await fs.readFile(pkgJsonPath, 'utf8');
const pkg = JSON.parse(content) as unknown;
const scripts =
pkg &&
typeof pkg === 'object' &&
'scripts' in pkg &&
pkg.scripts &&
typeof pkg.scripts === 'object'
? Object.keys(pkg.scripts)
: [];
return {
suggestions: scripts
.filter((s) => s.startsWith(partial))
.map((s) => ({
label: s,
value: escapeShellPath(s),
description: 'npm script',
})),
exclusive: true,
};
} catch {
// No package.json or invalid JSON
return { suggestions: [], exclusive: true };
}
}
return { suggestions: [], exclusive: false };
},
};
@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Suggestion } from '../../components/SuggestionsDisplay.js';
export interface CompletionResult {
suggestions: Suggestion[];
// If true, this prevents the shell from appending generic file/path completions
// to this list. Use this when the tool expects ONLY specific values (e.g. branches).
exclusive?: boolean;
}
export interface ShellCompletionProvider {
command: string; // The command trigger, e.g., 'git' or 'npm'
getCompletions(
tokens: string[], // List of arguments parsed from the input
cursorIndex: number, // Which token index the cursor is currently on
cwd: string,
signal?: AbortSignal,
): Promise<CompletionResult>;
}
@@ -275,20 +275,5 @@ describe('toolMapping', () => {
expect(result.tools[0].resultDisplay).toBeUndefined();
expect(result.tools[0].status).toBe(CoreToolCallStatus.Scheduled);
});
it('propagates originalRequestName correctly', () => {
const toolCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
...mockRequest,
originalRequestName: 'original_tool',
},
tool: mockTool,
invocation: mockInvocation,
};
const result = mapToDisplay(toolCall);
expect(result.tools[0].originalRequestName).toBe('original_tool');
});
});
});
-1
View File
@@ -107,7 +107,6 @@ export function mapToDisplay(
progressMessage,
progressPercent,
approvalMode: call.approvalMode,
originalRequestName: call.request.originalRequestName,
};
});
@@ -28,6 +28,7 @@ import type { UseAtCompletionProps } from './useAtCompletion.js';
import { useAtCompletion } from './useAtCompletion.js';
import type { UseSlashCompletionProps } from './useSlashCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { useShellCompletion } from './useShellCompletion.js';
vi.mock('./useAtCompletion', () => ({
useAtCompletion: vi.fn(),
@@ -40,19 +41,35 @@ vi.mock('./useSlashCompletion', () => ({
})),
}));
vi.mock('./useShellCompletion', () => ({
useShellCompletion: vi.fn(() => ({
completionStart: 0,
completionEnd: 0,
query: '',
})),
}));
// Helper to set up mocks in a consistent way for both child hooks
const setupMocks = ({
atSuggestions = [],
slashSuggestions = [],
shellSuggestions = [],
isLoading = false,
isPerfectMatch = false,
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
}: {
atSuggestions?: Suggestion[];
slashSuggestions?: Suggestion[];
shellSuggestions?: Suggestion[];
isLoading?: boolean;
isPerfectMatch?: boolean;
slashCompletionRange?: { completionStart: number; completionEnd: number };
shellCompletionRange?: {
completionStart: number;
completionEnd: number;
query: string;
};
}) => {
// Mock for @-completions
(useAtCompletion as Mock).mockImplementation(
@@ -89,11 +106,25 @@ const setupMocks = ({
return slashCompletionRange;
},
);
// Mock for shell completions
(useShellCompletion as Mock).mockImplementation(
({ enabled, setSuggestions, setIsLoadingSuggestions }) => {
useEffect(() => {
if (enabled) {
setIsLoadingSuggestions(isLoading);
setSuggestions(shellSuggestions);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
return shellCompletionRange;
},
);
};
describe('useCommandCompletion', () => {
const mockCommandContext = {} as CommandContext;
const mockConfig = {
getEnablePromptCompletion: () => false,
getGeminiClient: vi.fn(),
} as unknown as Config;
const testRootDir = '/';
@@ -498,6 +529,7 @@ describe('useCommandCompletion', () => {
describe('prompt completion filtering', () => {
it('should not trigger prompt completion for line comments', async () => {
const mockConfig = {
getEnablePromptCompletion: () => true,
getGeminiClient: vi.fn(),
} as unknown as Config;
@@ -530,6 +562,7 @@ describe('useCommandCompletion', () => {
it('should not trigger prompt completion for block comments', async () => {
const mockConfig = {
getEnablePromptCompletion: () => true,
getGeminiClient: vi.fn(),
} as unknown as Config;
@@ -564,6 +597,7 @@ describe('useCommandCompletion', () => {
it('should trigger prompt completion for regular text when enabled', async () => {
const mockConfig = {
getEnablePromptCompletion: () => true,
getGeminiClient: vi.fn(),
} as unknown as Config;
@@ -13,6 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { useShellCompletion } from './useShellCompletion.js';
import type { PromptCompletion } from './usePromptCompletion.js';
import {
usePromptCompletion,
@@ -26,6 +27,7 @@ export enum CompletionMode {
AT = 'AT',
SLASH = 'SLASH',
PROMPT = 'PROMPT',
SHELL = 'SHELL',
}
export interface UseCommandCompletionReturn {
@@ -99,89 +101,105 @@ export function useCommandCompletion({
const cursorRow = buffer.cursor[0];
const cursorCol = buffer.cursor[1];
const { completionMode, query, completionStart, completionEnd } =
useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
const codePoints = toCodePoints(currentLine);
// FIRST: Check for @ completion (scan backwards from cursor)
// This must happen before slash command check so that `/cmd @file`
// triggers file completion, not just slash command completion.
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) {
break;
}
} 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,
};
}
}
// THEN: Check for slash command (only if no @ completion is active)
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
completionMode: CompletionMode.SLASH,
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
};
}
// Check for prompt completion - only if enabled
const trimmedText = buffer.text.trim();
const isPromptCompletionEnabled = false;
if (
isPromptCompletionEnabled &&
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
!isSlashCommand(trimmedText) &&
!trimmedText.includes('@')
) {
return {
completionMode: CompletionMode.PROMPT,
query: trimmedText,
completionStart: 0,
completionEnd: trimmedText.length,
};
}
const {
completionMode,
query: memoQuery,
completionStart,
completionEnd,
} = useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
const codePoints = toCodePoints(currentLine);
if (shellModeActive) {
return {
completionMode: CompletionMode.IDLE,
query: null,
completionMode:
currentLine.trim().length === 0
? CompletionMode.IDLE
: CompletionMode.SHELL,
query: '',
completionStart: -1,
completionEnd: -1,
};
}, [cursorRow, cursorCol, buffer.lines, buffer.text]);
}
// FIRST: Check for @ completion (scan backwards from cursor)
// This must happen before slash command check so that `/cmd @file`
// triggers file completion, not just slash command completion.
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) {
break;
}
} 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,
};
}
}
// THEN: Check for slash command (only if no @ completion is active)
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
completionMode: CompletionMode.SLASH,
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
};
}
// Check for prompt completion - only if enabled
const trimmedText = buffer.text.trim();
const isPromptCompletionEnabled = false;
if (
isPromptCompletionEnabled &&
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
!isSlashCommand(trimmedText) &&
!trimmedText.includes('@')
) {
return {
completionMode: CompletionMode.PROMPT,
query: trimmedText,
completionStart: 0,
completionEnd: trimmedText.length,
};
}
return {
completionMode: CompletionMode.IDLE,
query: null,
completionStart: -1,
completionEnd: -1,
};
}, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]);
useAtCompletion({
enabled: active && completionMode === CompletionMode.AT,
pattern: query || '',
pattern: memoQuery || '',
config,
cwd,
setSuggestions,
@@ -191,7 +209,7 @@ export function useCommandCompletion({
const slashCompletionRange = useSlashCompletion({
enabled:
active && completionMode === CompletionMode.SLASH && !shellModeActive,
query,
query: memoQuery,
slashCommands,
commandContext,
setSuggestions,
@@ -199,9 +217,22 @@ export function useCommandCompletion({
setIsPerfectMatch,
});
const shellCompletionRange = useShellCompletion({
enabled: active && completionMode === CompletionMode.SHELL,
line: buffer.lines[cursorRow] || '',
cursorCol,
cwd,
setSuggestions,
setIsLoadingSuggestions,
});
const query =
completionMode === CompletionMode.SHELL
? shellCompletionRange.query
: memoQuery;
const promptCompletion = usePromptCompletion({
buffer,
config,
});
useEffect(() => {
@@ -258,6 +289,9 @@ export function useCommandCompletion({
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
} else if (completionMode === CompletionMode.SHELL) {
start = shellCompletionRange.completionStart;
end = shellCompletionRange.completionEnd;
}
if (start === -1 || end === -1) {
@@ -287,6 +321,7 @@ export function useCommandCompletion({
completionStart,
completionEnd,
slashCompletionRange,
shellCompletionRange,
],
);
@@ -307,6 +342,9 @@ export function useCommandCompletion({
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
} else if (completionMode === CompletionMode.SHELL) {
start = shellCompletionRange.completionStart;
end = shellCompletionRange.completionEnd;
}
// Add space padding for Tab completion (auto-execute gets padding from getCompletedText)
@@ -345,6 +383,7 @@ export function useCommandCompletion({
completionStart,
completionEnd,
slashCompletionRange,
shellCompletionRange,
getCompletedText,
],
);
@@ -0,0 +1,382 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import {
getTokenAtCursor,
escapeShellPath,
resolvePathCompletions,
scanPathExecutables,
} from './useShellCompletion.js';
import type { FileSystemStructure } from '@google/gemini-cli-test-utils';
import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
describe('useShellCompletion utilities', () => {
describe('getTokenAtCursor', () => {
it('should return empty token struct for empty line', () => {
expect(getTokenAtCursor('', 0)).toEqual({
token: '',
start: 0,
end: 0,
isFirstToken: true,
tokens: [''],
cursorIndex: 0,
commandToken: '',
});
});
it('should extract the first token at cursor position 0', () => {
const result = getTokenAtCursor('git status', 3);
expect(result).toEqual({
token: 'git',
start: 0,
end: 3,
isFirstToken: true,
tokens: ['git', 'status'],
cursorIndex: 0,
commandToken: 'git',
});
});
it('should extract the second token when cursor is on it', () => {
const result = getTokenAtCursor('git status', 7);
expect(result).toEqual({
token: 'status',
start: 4,
end: 10,
isFirstToken: false,
tokens: ['git', 'status'],
cursorIndex: 1,
commandToken: 'git',
});
});
it('should handle cursor at start of second token', () => {
const result = getTokenAtCursor('git status', 4);
expect(result).toEqual({
token: 'status',
start: 4,
end: 10,
isFirstToken: false,
tokens: ['git', 'status'],
cursorIndex: 1,
commandToken: 'git',
});
});
it('should handle escaped spaces', () => {
const result = getTokenAtCursor('cat my\\ file.txt', 16);
expect(result).toEqual({
token: 'my file.txt',
start: 4,
end: 16,
isFirstToken: false,
tokens: ['cat', 'my file.txt'],
cursorIndex: 1,
commandToken: 'cat',
});
});
it('should handle single-quoted strings', () => {
const result = getTokenAtCursor("cat 'my file.txt'", 17);
expect(result).toEqual({
token: 'my file.txt',
start: 4,
end: 17,
isFirstToken: false,
tokens: ['cat', 'my file.txt'],
cursorIndex: 1,
commandToken: 'cat',
});
});
it('should handle double-quoted strings', () => {
const result = getTokenAtCursor('cat "my file.txt"', 17);
expect(result).toEqual({
token: 'my file.txt',
start: 4,
end: 17,
isFirstToken: false,
tokens: ['cat', 'my file.txt'],
cursorIndex: 1,
commandToken: 'cat',
});
});
it('should handle cursor past all tokens (trailing space)', () => {
const result = getTokenAtCursor('git ', 4);
expect(result).toEqual({
token: '',
start: 4,
end: 4,
isFirstToken: false,
tokens: ['git', ''],
cursorIndex: 1,
commandToken: 'git',
});
});
it('should handle cursor in the middle of a word', () => {
const result = getTokenAtCursor('git checkout main', 7);
expect(result).toEqual({
token: 'checkout',
start: 4,
end: 12,
isFirstToken: false,
tokens: ['git', 'checkout', 'main'],
cursorIndex: 1,
commandToken: 'git',
});
});
it('should mark isFirstToken correctly for first word', () => {
const result = getTokenAtCursor('gi', 2);
expect(result?.isFirstToken).toBe(true);
});
it('should mark isFirstToken correctly for second word', () => {
const result = getTokenAtCursor('git sta', 7);
expect(result?.isFirstToken).toBe(false);
});
});
describe('escapeShellPath', () => {
it('should escape spaces', () => {
expect(escapeShellPath('my file.txt')).toBe('my\\ file.txt');
});
it('should escape parentheses', () => {
expect(escapeShellPath('file (copy).txt')).toBe('file\\ \\(copy\\).txt');
});
it('should not escape normal characters', () => {
expect(escapeShellPath('normal-file.txt')).toBe('normal-file.txt');
});
it('should escape tabs, newlines, carriage returns, and backslashes', () => {
expect(escapeShellPath('a\tb')).toBe('a\\\tb');
expect(escapeShellPath('a\nb')).toBe('a\\\nb');
expect(escapeShellPath('a\rb')).toBe('a\\\rb');
expect(escapeShellPath('a\\b')).toBe('a\\\\b');
});
it('should handle empty string', () => {
expect(escapeShellPath('')).toBe('');
});
});
describe('resolvePathCompletions', () => {
let tmpDir: string;
afterEach(async () => {
if (tmpDir) {
await cleanupTmpDir(tmpDir);
}
});
it('should list directory contents for empty partial', async () => {
const structure: FileSystemStructure = {
'file.txt': '',
subdir: {},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('', tmpDir);
const values = results.map((s) => s.label);
expect(values).toContain('subdir/');
expect(values).toContain('file.txt');
});
it('should filter by prefix', async () => {
const structure: FileSystemStructure = {
'abc.txt': '',
'def.txt': '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('a', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].label).toBe('abc.txt');
});
it('should match case-insensitively', async () => {
const structure: FileSystemStructure = {
Desktop: {},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('desk', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].label).toBe('Desktop/');
});
it('should append trailing slash to directories', async () => {
const structure: FileSystemStructure = {
mydir: {},
'myfile.txt': '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('my', tmpDir);
const dirSuggestion = results.find((s) => s.label.startsWith('mydir'));
expect(dirSuggestion?.label).toBe('mydir/');
expect(dirSuggestion?.description).toBe('directory');
});
it('should hide dotfiles by default', async () => {
const structure: FileSystemStructure = {
'.hidden': '',
visible: '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('', tmpDir);
const labels = results.map((s) => s.label);
expect(labels).not.toContain('.hidden');
expect(labels).toContain('visible');
});
it('should show dotfiles when query starts with a dot', async () => {
const structure: FileSystemStructure = {
'.hidden': '',
'.bashrc': '',
visible: '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('.h', tmpDir);
const labels = results.map((s) => s.label);
expect(labels).toContain('.hidden');
});
it('should show dotfiles in the current directory when query is exactly "."', async () => {
const structure: FileSystemStructure = {
'.hidden': '',
'.bashrc': '',
visible: '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('.', tmpDir);
const labels = results.map((s) => s.label);
expect(labels).toContain('.hidden');
expect(labels).toContain('.bashrc');
expect(labels).not.toContain('visible');
});
it('should handle dotfile completions within a subdirectory', async () => {
const structure: FileSystemStructure = {
subdir: {
'.secret': '',
'public.txt': '',
},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('subdir/.', tmpDir);
const labels = results.map((s) => s.label);
expect(labels).toContain('.secret');
expect(labels).not.toContain('public.txt');
});
it('should strip leading quotes to resolve inner directory contents', async () => {
const structure: FileSystemStructure = {
src: {
'index.ts': '',
},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('"src/', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].label).toBe('index.ts');
const resultsSingleQuote = await resolvePathCompletions("'src/", tmpDir);
expect(resultsSingleQuote).toHaveLength(1);
expect(resultsSingleQuote[0].label).toBe('index.ts');
});
it('should properly escape resolutions with spaces inside stripped quote queries', async () => {
const structure: FileSystemStructure = {
'Folder With Spaces': {},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('"Fo', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].label).toBe('Folder With Spaces/');
expect(results[0].value).toBe(escapeShellPath('Folder With Spaces/'));
});
it('should return empty array for non-existent directory', async () => {
const results = await resolvePathCompletions(
'/nonexistent/path/foo',
'/tmp',
);
expect(results).toEqual([]);
});
it('should handle tilde expansion', async () => {
// Just ensure ~ doesn't throw
const results = await resolvePathCompletions('~/', '/tmp');
// We can't assert specific files since it depends on the test runner's home
expect(Array.isArray(results)).toBe(true);
});
it('should escape special characters in results', async () => {
const structure: FileSystemStructure = {
'my file.txt': '',
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('my', tmpDir);
expect(results).toHaveLength(1);
expect(results[0].value).toBe('my\\ file.txt');
});
it('should sort directories before files', async () => {
const structure: FileSystemStructure = {
'b-file.txt': '',
'a-dir': {},
};
tmpDir = await createTmpDir(structure);
const results = await resolvePathCompletions('', tmpDir);
expect(results[0].description).toBe('directory');
expect(results[1].description).toBe('file');
});
});
describe('scanPathExecutables', () => {
it('should return an array of executables', async () => {
const results = await scanPathExecutables();
expect(Array.isArray(results)).toBe(true);
// Very basic sanity check: common commands should be found
if (process.platform !== 'win32') {
expect(results).toContain('ls');
} else {
expect(results).toContain('dir');
expect(results).toContain('cls');
expect(results).toContain('copy');
}
});
it('should support abort signal', async () => {
const controller = new AbortController();
controller.abort();
const results = await scanPathExecutables(controller.signal);
// May return empty or partial depending on timing
expect(Array.isArray(results)).toBe(true);
});
it('should handle empty PATH', async () => {
vi.stubEnv('PATH', '');
const results = await scanPathExecutables();
expect(results).toEqual([]);
vi.unstubAllEnvs();
});
});
});
@@ -0,0 +1,620 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef, useCallback, useMemo } from 'react';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import { debugLogger } from '@google/gemini-cli-core';
import { getArgumentCompletions } from './shell-completions/index.js';
/**
* Maximum number of suggestions to return to avoid freezing the React Ink UI.
*/
const MAX_SHELL_SUGGESTIONS = 100;
/**
* Debounce interval (ms) for file system completions.
*/
const FS_COMPLETION_DEBOUNCE_MS = 50;
// Backslash-quote shell metacharacters on non-Windows platforms.
// On Unix, backslash-quote shell metacharacters (spaces, parens, etc.).
// On Windows, cmd.exe doesn't use backslash-quoting and `\` is the path
// separator, so we leave the path as-is.
const UNIX_SHELL_SPECIAL_CHARS = /[ \t\n\r'"()&|;<>!#$`{}[\]*?\\]/g;
/**
* Escapes special shell characters in a path segment.
*/
export function escapeShellPath(segment: string): string {
if (process.platform === 'win32') {
return segment;
}
return segment.replace(UNIX_SHELL_SPECIAL_CHARS, '\\$&');
}
export interface TokenInfo {
/** The raw token text (without surrounding quotes but with internal escapes). */
token: string;
/** Offset in the original line where this token begins. */
start: number;
/** Offset in the original line where this token ends (exclusive). */
end: number;
/** Whether this is the first token (command position). */
isFirstToken: boolean;
/** The fully built list of tokens parsing the string. */
tokens: string[];
/** The index in the tokens list where the cursor lies. */
cursorIndex: number;
/** The command token (always tokens[0] if length > 0, otherwise empty string) */
commandToken: string;
}
export function getTokenAtCursor(
line: string,
cursorCol: number,
): TokenInfo | null {
const tokensInfo: Array<{ token: string; start: number; end: number }> = [];
let i = 0;
while (i < line.length) {
// Skip whitespace
if (line[i] === ' ' || line[i] === '\t') {
i++;
continue;
}
const tokenStart = i;
let token = '';
while (i < line.length) {
const ch = line[i];
// Backslash escape: consume the next char literally
if (ch === '\\' && i + 1 < line.length) {
token += line[i + 1];
i += 2;
continue;
}
// Single-quoted string
if (ch === "'") {
i++; // skip opening quote
while (i < line.length && line[i] !== "'") {
token += line[i];
i++;
}
if (i < line.length) i++; // skip closing quote
continue;
}
// Double-quoted string
if (ch === '"') {
i++; // skip opening quote
while (i < line.length && line[i] !== '"') {
if (line[i] === '\\' && i + 1 < line.length) {
token += line[i + 1];
i += 2;
} else {
token += line[i];
i++;
}
}
if (i < line.length) i++; // skip closing quote
continue;
}
// Unquoted whitespace ends the token
if (ch === ' ' || ch === '\t') {
break;
}
token += ch;
i++;
}
tokensInfo.push({ token, start: tokenStart, end: i });
}
const rawTokens = tokensInfo.map((t) => t.token);
const commandToken = rawTokens.length > 0 ? rawTokens[0] : '';
if (tokensInfo.length === 0) {
return {
token: '',
start: cursorCol,
end: cursorCol,
isFirstToken: true,
tokens: [''],
cursorIndex: 0,
commandToken: '',
};
}
// Find the token that contains or is immediately adjacent to the cursor
for (let idx = 0; idx < tokensInfo.length; idx++) {
const t = tokensInfo[idx];
if (cursorCol >= t.start && cursorCol <= t.end) {
return {
token: t.token,
start: t.start,
end: t.end,
isFirstToken: idx === 0,
tokens: rawTokens,
cursorIndex: idx,
commandToken,
};
}
}
// Cursor is past all tokens — treat as starting a new token at cursor position
// (useful when the user types a space and then presses Tab)
return {
token: '',
start: cursorCol,
end: cursorCol,
isFirstToken: tokensInfo.length === 0,
tokens: [...rawTokens, ''],
cursorIndex: rawTokens.length,
commandToken,
};
}
export async function scanPathExecutables(
signal?: AbortSignal,
): Promise<string[]> {
const pathEnv = process.env['PATH'] ?? '';
const dirs = pathEnv.split(path.delimiter).filter(Boolean);
const isWindows = process.platform === 'win32';
const pathExtList = isWindows
? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM')
.split(';')
.filter(Boolean)
.map((e) => e.toLowerCase())
: [];
const seen = new Set<string>();
const executables: string[] = [];
// Add Windows shell built-ins
if (isWindows) {
const builtins = [
'assoc',
'break',
'call',
'cd',
'chcp',
'chdir',
'cls',
'color',
'copy',
'date',
'del',
'dir',
'echo',
'endlocal',
'erase',
'exit',
'for',
'ftype',
'goto',
'if',
'md',
'mkdir',
'mklink',
'move',
'path',
'pause',
'popd',
'prompt',
'pushd',
'rd',
'rem',
'ren',
'rename',
'rmdir',
'set',
'setlocal',
'shift',
'start',
'time',
'title',
'type',
'ver',
'verify',
'vol',
];
for (const builtin of builtins) {
seen.add(builtin);
executables.push(builtin);
}
}
const dirResults = await Promise.all(
dirs.map(async (dir) => {
if (signal?.aborted) return [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const validEntries: string[] = [];
// Check executability in parallel (batched per directory)
await Promise.all(
entries.map(async (entry) => {
if (signal?.aborted) return;
if (!entry.isFile() && !entry.isSymbolicLink()) return;
const name = entry.name;
if (isWindows) {
const ext = path.extname(name).toLowerCase();
if (pathExtList.length > 0 && !pathExtList.includes(ext)) return;
}
try {
await fs.access(
path.join(dir, name),
fs.constants.R_OK | fs.constants.X_OK,
);
validEntries.push(name);
} catch {
// Not executable — skip
}
}),
);
return validEntries;
} catch {
// EACCES, ENOENT, etc. — skip this directory
return [];
}
}),
);
for (const names of dirResults) {
for (const name of names) {
if (!seen.has(name)) {
seen.add(name);
executables.push(name);
}
}
}
executables.sort();
return executables;
}
function expandTilde(inputPath: string): [string, boolean] {
if (
inputPath === '~' ||
inputPath.startsWith('~/') ||
inputPath.startsWith('~' + path.sep)
) {
return [path.join(os.homedir(), inputPath.slice(1)), true];
}
return [inputPath, false];
}
export async function resolvePathCompletions(
partial: string,
cwd: string,
signal?: AbortSignal,
): Promise<Suggestion[]> {
if (partial == null) return [];
// Input Sanitization
let strippedPartial = partial;
if (strippedPartial.startsWith('"') || strippedPartial.startsWith("'")) {
strippedPartial = strippedPartial.slice(1);
}
if (strippedPartial.endsWith('"') || strippedPartial.endsWith("'")) {
strippedPartial = strippedPartial.slice(0, -1);
}
// Normalize separators \ to /
const normalizedPartial = strippedPartial.replace(/\\/g, '/');
const [expandedPartial, didExpandTilde] = expandTilde(normalizedPartial);
// Directory Detection
const endsWithSep =
normalizedPartial.endsWith('/') || normalizedPartial === '';
const dirToRead = endsWithSep
? path.resolve(cwd, expandedPartial)
: path.resolve(cwd, path.dirname(expandedPartial));
const prefix = endsWithSep ? '' : path.basename(expandedPartial);
const prefixLower = prefix.toLowerCase();
const showDotfiles = prefix.startsWith('.');
let entries: Array<import('node:fs').Dirent>;
try {
if (signal?.aborted) return [];
entries = await fs.readdir(dirToRead, { withFileTypes: true });
} catch {
// EACCES, ENOENT, etc.
return [];
}
if (signal?.aborted) return [];
const suggestions: Suggestion[] = [];
for (const entry of entries) {
if (signal?.aborted) break;
const name = entry.name;
// Hide dotfiles unless query starts with '.'
if (name.startsWith('.') && !showDotfiles) continue;
// Case-insensitive matching
if (!name.toLowerCase().startsWith(prefixLower)) continue;
const isDir = entry.isDirectory();
const displayName = isDir ? name + '/' : name;
// Build the completion value relative to what the user typed
let completionValue: string;
if (endsWithSep) {
completionValue = normalizedPartial + displayName;
} else {
const parentPart = normalizedPartial.slice(
0,
normalizedPartial.length - path.basename(normalizedPartial).length,
);
completionValue = parentPart + displayName;
}
// Restore tilde if we expanded it
if (didExpandTilde) {
const homeDir = os.homedir().replace(/\\/g, '/');
if (completionValue.startsWith(homeDir)) {
completionValue = '~' + completionValue.slice(homeDir.length);
}
}
// Output formatting: Escape special characters in the completion value
// Since normalizedPartial stripped quotes, we escape the value directly.
const escapedValue = escapeShellPath(completionValue);
suggestions.push({
label: displayName,
value: escapedValue,
description: isDir ? 'directory' : 'file',
});
if (suggestions.length >= MAX_SHELL_SUGGESTIONS) break;
}
// Sort: directories first, then alphabetically
suggestions.sort((a, b) => {
const aIsDir = a.description === 'directory';
const bIsDir = b.description === 'directory';
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
return a.label.localeCompare(b.label);
});
return suggestions;
}
export interface UseShellCompletionProps {
/** Whether shell completion is active. */
enabled: boolean;
/** The current line text. */
line: string;
/** The current cursor column. */
cursorCol: number;
/** The current working directory for path resolution. */
cwd: string;
/** Callback to set suggestions on the parent state. */
setSuggestions: (suggestions: Suggestion[]) => void;
/** Callback to set loading state on the parent. */
setIsLoadingSuggestions: (isLoading: boolean) => void;
}
export interface UseShellCompletionReturn {
completionStart: number;
completionEnd: number;
query: string;
}
export function useShellCompletion({
enabled,
line,
cursorCol,
cwd,
setSuggestions,
setIsLoadingSuggestions,
}: UseShellCompletionProps): UseShellCompletionReturn {
const pathCachePromiseRef = useRef<Promise<string[]> | null>(null);
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tokenInfo = useMemo(
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
[enabled, line, cursorCol],
);
const {
token: query = '',
start: completionStart = -1,
end: completionEnd = -1,
isFirstToken: isCommandPosition = false,
tokens = [],
cursorIndex = -1,
commandToken = '',
} = tokenInfo || {};
// Invalidate PATH cache when $PATH changes
useEffect(() => {
const currentPath = process.env['PATH'] ?? '';
if (currentPath !== pathEnvRef.current) {
pathCachePromiseRef.current = null;
pathEnvRef.current = currentPath;
}
});
const performCompletion = useCallback(async () => {
if (!enabled || !tokenInfo) {
setSuggestions([]);
return;
}
// Skip flags
if (query.startsWith('-')) {
setSuggestions([]);
return;
}
// Cancel any in-flight request
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
const { signal } = controller;
try {
let results: Suggestion[];
if (isCommandPosition) {
setIsLoadingSuggestions(true);
if (!pathCachePromiseRef.current) {
// We don't pass the signal here because we want the cache to finish
// even if this specific completion request is aborted.
pathCachePromiseRef.current = scanPathExecutables();
}
const executables = await pathCachePromiseRef.current;
if (signal.aborted) return;
const queryLower = query.toLowerCase();
results = executables
.filter((cmd) => cmd.toLowerCase().startsWith(queryLower))
.sort((a, b) => {
// Prioritize shorter commands as they are likely common built-ins
if (a.length !== b.length) {
return a.length - b.length;
}
return a.localeCompare(b);
})
.slice(0, MAX_SHELL_SUGGESTIONS)
.map((cmd) => ({
label: cmd,
value: escapeShellPath(cmd),
description: 'command',
}));
} else {
const argumentCompletions = await getArgumentCompletions(
commandToken,
tokens,
cursorIndex,
cwd,
signal,
);
if (signal.aborted) return;
if (argumentCompletions?.exclusive) {
results = argumentCompletions.suggestions;
} else {
const pathSuggestions = await resolvePathCompletions(
query,
cwd,
signal,
);
if (signal.aborted) return;
results = [
...(argumentCompletions?.suggestions ?? []),
...pathSuggestions,
].slice(0, MAX_SHELL_SUGGESTIONS);
}
}
if (signal.aborted) return;
setSuggestions(results);
} catch (error) {
if (
!(
signal.aborted ||
(error instanceof Error && error.name === 'AbortError')
)
) {
debugLogger.warn(
`[WARN] shell completion failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (!signal.aborted) {
setSuggestions([]);
}
} finally {
if (!signal.aborted) {
setIsLoadingSuggestions(false);
}
}
}, [
enabled,
tokenInfo,
query,
isCommandPosition,
tokens,
cursorIndex,
commandToken,
cwd,
setSuggestions,
setIsLoadingSuggestions,
]);
useEffect(() => {
if (!enabled) {
setSuggestions([]);
setIsLoadingSuggestions(false);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
// Debounced effect to trigger completion
useEffect(() => {
if (!enabled) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
performCompletion();
}, FS_COMPLETION_DEBOUNCE_MS);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [enabled, performCompletion]);
// Cleanup on unmount
useEffect(
() => () => {
abortRef.current?.abort();
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
},
[],
);
return {
completionStart,
completionEnd,
query,
};
}
@@ -13,7 +13,6 @@ import {
Scheduler,
type Config,
type MessageBus,
type ExecutingToolCall,
type CompletedToolCall,
type ToolCallsUpdateMessage,
type AnyDeclarativeTool,
@@ -111,7 +110,7 @@ describe('useToolScheduler', () => {
tool: createMockTool(),
invocation: createMockInvocation(),
liveOutput: 'Loading...',
} as ExecutingToolCall;
};
act(() => {
void mockMessageBus.publish({
@@ -406,62 +405,4 @@ describe('useToolScheduler', () => {
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
).toBe('subagent-1');
});
it('adapts success/error status to executing when a tail call is present', () => {
vi.useFakeTimers();
const { result } = renderHook(() =>
useToolScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const startTime = Date.now();
vi.advanceTimersByTime(1000);
const mockToolCall = {
status: CoreToolCallStatus.Success as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
response: {
callId: 'call-1',
resultDisplay: 'OK',
responseParts: [],
error: undefined,
errorType: undefined,
},
tailToolCallRequest: {
name: 'tail_tool',
args: {},
isClientInitiated: false,
prompt_id: '123',
},
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
const [toolCalls, , , , , lastOutputTime] = result.current;
// Check if status has been adapted to 'executing'
expect(toolCalls[0].status).toBe(CoreToolCallStatus.Executing);
// Check if lastOutputTime was updated due to the transitional state
expect(lastOutputTime).toBeGreaterThan(startTime);
vi.useRealTimers();
});
});
+2 -26
View File
@@ -14,7 +14,6 @@ import {
Scheduler,
type EditorType,
type ToolCallsUpdateMessage,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
@@ -116,16 +115,7 @@ export function useToolScheduler(
useEffect(() => {
const handler = (event: ToolCallsUpdateMessage) => {
// Update output timer for UI spinners (Side Effect)
const hasExecuting = event.toolCalls.some(
(tc) =>
tc.status === CoreToolCallStatus.Executing ||
((tc.status === CoreToolCallStatus.Success ||
tc.status === CoreToolCallStatus.Error) &&
'tailToolCallRequest' in tc &&
tc.tailToolCallRequest != null),
);
if (hasExecuting) {
if (event.toolCalls.some((tc) => tc.status === 'executing')) {
setLastToolOutputTime(Date.now());
}
@@ -248,23 +238,9 @@ function adaptToolCalls(
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
let status = coreCall.status;
// If a tool call has completed but scheduled a tail call, it is in a transitional
// state. Force the UI to render it as "executing".
if (
(status === CoreToolCallStatus.Success ||
status === CoreToolCallStatus.Error) &&
'tailToolCallRequest' in coreCall &&
coreCall.tailToolCallRequest != null
) {
status = CoreToolCallStatus.Executing;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...coreCall,
status,
responseSubmittedToGemini,
} as TrackedToolCall;
};
});
}
+1
View File
@@ -352,6 +352,7 @@ describe('keyMatchers', () => {
createKey('l', { ctrl: true }),
],
},
// Shell commands
{
command: Command.REVERSE_SEARCH,
-1
View File
@@ -110,7 +110,6 @@ export interface IndividualToolCallDisplay {
approvalMode?: ApprovalMode;
progressMessage?: string;
progressPercent?: number;
originalRequestName?: string;
}
export interface CompressionProps {
-19
View File
@@ -19,9 +19,6 @@ export const CACHE_EFFICIENCY_MEDIUM = 15;
export const QUOTA_THRESHOLD_HIGH = 20;
export const QUOTA_THRESHOLD_MEDIUM = 5;
export const QUOTA_USED_WARNING_THRESHOLD = 80;
export const QUOTA_USED_CRITICAL_THRESHOLD = 95;
// --- Color Logic ---
export const getStatusColor = (
value: number,
@@ -39,19 +36,3 @@ export const getStatusColor = (
}
return options.defaultColor ?? theme.status.error;
};
/**
* Gets the status color based on "used" percentage (where higher is worse).
*/
export const getUsedStatusColor = (
usedPercentage: number,
thresholds: { warning: number; critical: number },
) => {
if (usedPercentage >= thresholds.critical) {
return theme.status.error;
}
if (usedPercentage >= thresholds.warning) {
return theme.status.warning;
}
return undefined;
};
@@ -10,46 +10,9 @@ import {
formatBytes,
formatTimeAgo,
stripReferenceContent,
formatResetTime,
} from './formatters.js';
describe('formatters', () => {
describe('formatResetTime', () => {
const NOW = new Date('2025-01-01T12:00:00Z');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('should format full time correctly', () => {
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
const result = formatResetTime(resetTime);
expect(result).toMatch(/1 hour 30 minutes at \d{1,2}:\d{2} [AP]M/);
});
it('should format terse time correctly', () => {
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
expect(formatResetTime(resetTime, 'terse')).toBe('1h 30m');
expect(formatResetTime(resetTime, true)).toBe('1h 30m');
});
it('should format column time correctly', () => {
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
const result = formatResetTime(resetTime, 'column');
expect(result).toMatch(/\d{1,2}:\d{2} [AP]M \(1h 30m\)/);
});
it('should handle zero or negative diff by returning empty string', () => {
const resetTime = new Date(NOW.getTime() - 1000).toISOString();
expect(formatResetTime(resetTime)).toBe('');
});
});
describe('formatBytes', () => {
it('should format bytes into KB', () => {
expect(formatBytes(12345)).toBe('12.1 KB');
+13 -45
View File
@@ -98,58 +98,26 @@ export function stripReferenceContent(text: string): string {
return text.replace(pattern, '').trim();
}
export const formatResetTime = (
resetTime: string | undefined,
format: 'terse' | 'column' | 'full' | boolean = false,
): string => {
if (!resetTime) return '';
const resetDate = new Date(resetTime);
if (isNaN(resetDate.getTime())) return '';
const diff = resetDate.getTime() - Date.now();
export const formatResetTime = (resetTime: string): string => {
const diff = new Date(resetTime).getTime() - Date.now();
if (diff <= 0) return '';
const totalMinutes = Math.ceil(diff / (1000 * 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const isTerse = format === 'terse' || format === true;
const isColumn = format === 'column';
const fmt = (val: number, unit: 'hour' | 'minute') =>
new Intl.NumberFormat('en', {
style: 'unit',
unit,
unitDisplay: 'narrow',
}).format(val);
if (isTerse || isColumn) {
const hoursStr = hours > 0 ? `${hours}h` : '';
const minutesStr = minutes > 0 ? `${minutes}m` : '';
const duration =
hoursStr && minutesStr
? `${hoursStr} ${minutesStr}`
: hoursStr || minutesStr;
if (isColumn) {
const timeStr = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
}).format(resetDate);
return duration ? `${timeStr} (${duration})` : timeStr;
}
return duration;
if (hours > 0 && minutes > 0) {
return `resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`;
} else if (hours > 0) {
return `resets in ${fmt(hours, 'hour')}`;
}
let duration = '';
if (hours > 0) {
duration = `${hours} hour${hours > 1 ? 's' : ''}`;
if (minutes > 0) {
duration += ` ${minutes} minute${minutes > 1 ? 's' : ''}`;
}
} else {
duration = `${minutes} minute${minutes > 1 ? 's' : ''}`;
}
const timeStr = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
}).format(resetDate);
return `${duration} at ${timeStr}`;
return `resets in ${fmt(minutes, 'minute')}`;
};
+26 -130
View File
@@ -22,82 +22,13 @@ import WebSocket from 'ws';
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
const MAX_BUFFER_SIZE = 100;
/** Type guard: Array.isArray doesn't narrow readonly arrays in TS 5.8 */
function isHeaderRecord(
h: http.OutgoingHttpHeaders | readonly string[],
): h is http.OutgoingHttpHeaders {
return !Array.isArray(h);
}
function isRequestOptions(value: unknown): value is http.RequestOptions {
return (
typeof value === 'object' &&
value !== null &&
!(value instanceof URL) &&
!Array.isArray(value)
);
}
function isIncomingMessageCallback(
value: unknown,
): value is (res: http.IncomingMessage) => void {
return typeof value === 'function';
}
type HttpRequestArgs =
| []
| [
url: string | URL | http.RequestOptions,
options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
callback?: (res: http.IncomingMessage) => void,
];
function callHttpRequest(
originalFn: typeof http.request,
args: HttpRequestArgs,
): http.ClientRequest {
if (args.length === 0) {
return originalFn({});
}
if (args.length === 1) {
const first = args[0];
if (typeof first === 'string' || first instanceof URL) {
return originalFn(first);
}
if (isRequestOptions(first)) {
return originalFn(first);
}
return originalFn({});
}
if (args.length === 2) {
const first = args[0];
const second = args[1];
if (typeof first === 'string' || first instanceof URL) {
if (isIncomingMessageCallback(second)) {
return originalFn(first, second);
}
if (isRequestOptions(second)) {
return originalFn(first, second);
}
}
if (isRequestOptions(first) && isIncomingMessageCallback(second)) {
return originalFn(first, second);
}
}
if (args.length === 3) {
const first = args[0];
const second = args[1];
const third = args[2];
if (
(typeof first === 'string' || first instanceof URL) &&
isRequestOptions(second) &&
isIncomingMessageCallback(third)
) {
return originalFn(first, second, third);
}
}
return originalFn({});
}
export interface NetworkLog {
id: string;
timestamp: number;
@@ -433,7 +364,7 @@ export class ActivityLogger extends EventEmitter {
const wrapRequest = (
originalFn: typeof http.request,
args: HttpRequestArgs,
args: unknown[],
protocol: string,
) => {
const firstArg = args[0];
@@ -442,10 +373,8 @@ export class ActivityLogger extends EventEmitter {
options = firstArg;
} else if (firstArg instanceof URL) {
options = firstArg;
} else if (firstArg && typeof firstArg === 'object') {
options = isRequestOptions(firstArg) ? firstArg : {};
} else {
options = {};
options = (firstArg ?? {}) as http.RequestOptions;
}
let url = '';
@@ -464,9 +393,9 @@ export class ActivityLogger extends EventEmitter {
`${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`;
}
if (url.includes('127.0.0.1') || url.includes('localhost')) {
return callHttpRequest(originalFn, args);
}
if (url.includes('127.0.0.1') || url.includes('localhost'))
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
return originalFn.apply(http, args as any);
const rawHeaders =
typeof options === 'object' &&
@@ -481,23 +410,24 @@ export class ActivityLogger extends EventEmitter {
if (headers[ACTIVITY_ID_HEADER]) {
delete headers[ACTIVITY_ID_HEADER];
return callHttpRequest(originalFn, args);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
return originalFn.apply(http, args as any);
}
const id = Math.random().toString(36).substring(7);
this.requestStartTimes.set(id, Date.now());
const req = callHttpRequest(originalFn, args);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
const req = originalFn.apply(http, args as any);
const requestChunks: Buffer[] = [];
const oldWrite = req.write;
const oldEnd = req.end;
req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) {
req.write = function (chunk: unknown, ...etc: unknown[]) {
if (chunk) {
const encoding =
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
? etc[0]
: undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
? chunk
@@ -508,21 +438,19 @@ export class ActivityLogger extends EventEmitter {
),
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
return (oldWrite as any).apply(this, [chunk, ...etc]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
return oldWrite.apply(this, [chunk, ...etc] as any);
};
req.end = function (
this: http.ClientRequest,
chunkOrCb?: string | Uint8Array | (() => void),
chunk: unknown,
...etc: unknown[]
) {
const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb;
if (chunk) {
if (chunk && typeof chunk !== 'function') {
const encoding =
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
? etc[0]
: undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
? chunk
@@ -545,7 +473,7 @@ export class ActivityLogger extends EventEmitter {
pending: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
return (oldEnd as any).apply(this, [chunkOrCb, ...etc]);
return (oldEnd as any).apply(this, [chunk, ...etc]);
};
req.on('response', (res: http.IncomingMessage) => {
@@ -617,44 +545,12 @@ export class ActivityLogger extends EventEmitter {
return req;
};
Object.defineProperty(http, 'request', {
value: (
url: string | URL | http.RequestOptions,
options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
callback?: (res: http.IncomingMessage) => void,
): http.ClientRequest => {
const args: HttpRequestArgs =
callback !== undefined
? [url, options, callback]
: options !== undefined
? [url, options]
: [url];
return wrapRequest(originalRequest, args, 'http:');
},
writable: true,
configurable: true,
});
Object.defineProperty(https, 'request', {
value: (
url: string | URL | http.RequestOptions,
options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
callback?: (res: http.IncomingMessage) => void,
): http.ClientRequest => {
const args: HttpRequestArgs =
callback !== undefined
? [url, options, callback]
: options !== undefined
? [url, options]
: [url];
return wrapRequest(
originalHttpsRequest as typeof http.request,
args,
'https:',
);
},
writable: true,
configurable: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(http as any).request = (...args: unknown[]) =>
wrapRequest(originalRequest, args, 'http:');
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(https as any).request = (...args: unknown[]) =>
wrapRequest(originalHttpsRequest as typeof http.request, args, 'https:');
}
logConsole(payload: ConsoleLogPayload) {
-2
View File
@@ -53,8 +53,6 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
@@ -4,21 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { GeneralistAgent } from './generalist-agent.js';
import { makeFakeConfig } from '../test-utils/config.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { AgentRegistry } from './registry.js';
describe('GeneralistAgent', () => {
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should create a valid generalist agent definition', () => {
const config = makeFakeConfig();
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
@@ -47,10 +47,7 @@ describe('Fallback Integration', () => {
const requestedModel = PREVIEW_GEMINI_MODEL;
// 3. Apply model selection
const result = applyModelSelection(config, {
model: requestedModel,
isChatModel: true,
});
const result = applyModelSelection(config, { model: requestedModel });
// 4. Expect fallback to Flash
expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
@@ -222,10 +222,7 @@ describe('policyHelpers', () => {
selectedModel: 'gemini-pro',
});
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: true,
});
const result = applyModelSelection(config, { model: 'gemini-pro' });
expect(result.model).toBe('gemini-pro');
expect(result.maxAttempts).toBeUndefined();
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
@@ -246,10 +243,7 @@ describe('policyHelpers', () => {
selectedModel: 'gemini-flash',
});
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: true,
});
const result = applyModelSelection(config, { model: 'gemini-pro' });
expect(result.model).toBe('gemini-flash');
expect(result.config).toEqual({
@@ -259,33 +253,14 @@ describe('policyHelpers', () => {
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
model: 'gemini-pro',
isChatModel: true,
});
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
model: 'gemini-flash',
isChatModel: true,
});
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-flash');
});
it('does not call setActiveModel if isChatModel is false', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
generateContentConfig: {},
});
mockAvailabilityService.selectFirstAvailable.mockReturnValue({
selectedModel: 'gemini-pro',
});
applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: false,
});
expect(config.setActiveModel).not.toHaveBeenCalled();
});
it('consumes sticky attempt if indicated and isChatModel is true', () => {
it('consumes sticky attempt if indicated', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
@@ -296,36 +271,10 @@ describe('policyHelpers', () => {
attempts: 1,
});
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: true,
});
const result = applyModelSelection(config, { model: 'gemini-pro' });
expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
'gemini-pro',
);
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
expect(result.maxAttempts).toBe(1);
});
it('consumes sticky attempt if indicated but does not call setActiveModel if isChatModel is false', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
generateContentConfig: {},
});
mockAvailabilityService.selectFirstAvailable.mockReturnValue({
selectedModel: 'gemini-pro',
attempts: 1,
});
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: false,
});
expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
'gemini-pro',
);
expect(config.setActiveModel).not.toHaveBeenCalled();
expect(result.maxAttempts).toBe(1);
});
@@ -342,7 +291,7 @@ describe('policyHelpers', () => {
const result = applyModelSelection(
config,
{ model: 'gemini-pro', isChatModel: true },
{ model: 'gemini-pro' },
{
consumeAttempt: false,
},
@@ -350,7 +299,6 @@ describe('policyHelpers', () => {
expect(
mockAvailabilityService.consumeStickyAttempt,
).not.toHaveBeenCalled();
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
expect(result.maxAttempts).toBe(1);
});
});
@@ -214,9 +214,7 @@ export function applyModelSelection(
generateContentConfig = fallbackResolved.generateContentConfig;
}
if (modelConfigKey.isChatModel) {
config.setActiveModel(finalModel);
}
config.setActiveModel(finalModel);
if (selection.attempts && options.consumeAttempt !== false) {
config.getModelAvailabilityService().consumeStickyAttempt(finalModel);
@@ -28,11 +28,6 @@ vi.mock('node:fs', () => ({
readFile: vi.fn(),
rm: vi.fn(),
},
createWriteStream: vi.fn(() => ({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
})),
}));
vi.mock('node:os');
vi.mock('node:path');
-24
View File
@@ -8,7 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import type { ConfigParameters, SandboxConfig } from './config.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from './config.js';
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
import { ApprovalMode } from '../policy/types.js';
@@ -260,29 +259,6 @@ describe('Server Config (config.ts)', () => {
usageStatisticsEnabled: false,
};
describe('maxAttempts', () => {
it('should default to DEFAULT_MAX_ATTEMPTS', () => {
const config = new Config(baseParams);
expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
});
it('should use provided maxAttempts if <= DEFAULT_MAX_ATTEMPTS', () => {
const config = new Config({
...baseParams,
maxAttempts: 5,
});
expect(config.getMaxAttempts()).toBe(5);
});
it('should cap maxAttempts at DEFAULT_MAX_ATTEMPTS', () => {
const config = new Config({
...baseParams,
maxAttempts: 20,
});
expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
});
});
beforeEach(() => {
// Reset mocks if necessary
vi.clearAllMocks();
+4 -51
View File
@@ -132,11 +132,6 @@ import { UserHintService } from './userHintService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
import { loadPoliciesFromToml } from '../policy/toml-loader.js';
import { CheckerRunner } from '../safety/checker-runner.js';
import { ContextBuilder } from '../safety/context-builder.js';
import { CheckerRegistry } from '../safety/registry.js';
import { ConsecaSafetyChecker } from '../safety/conseca/conseca.js';
export interface AccessibilitySettings {
/** @deprecated Use ui.loadingPhrases instead. */
enableLoadingPhrases?: boolean;
@@ -296,7 +291,6 @@ export interface ExtensionInstallMetadata {
allowPreRelease?: boolean;
}
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import type { FileFilteringOptions } from './constants.js';
import {
DEFAULT_FILE_FILTERING_OPTIONS,
@@ -476,13 +470,11 @@ export interface ConfigParameters {
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
policyEngineConfig?: PolicyEngineConfig;
directWebFetch?: boolean;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
output?: OutputSettings;
disableModelRouterForAuth?: AuthType[];
continueOnFailedApiCall?: boolean;
retryFetchErrors?: boolean;
maxAttempts?: number;
enableShellOutputEfficiency?: boolean;
shellToolInactivityTimeout?: number;
fakeResponses?: string;
@@ -518,7 +510,6 @@ export interface ConfigParameters {
adminSkillsEnabled?: boolean;
agents?: AgentSettings;
}>;
enableConseca?: boolean;
}
export class Config {
@@ -546,7 +537,6 @@ export class Config {
private workspaceContext: WorkspaceContext;
private readonly debugMode: boolean;
private readonly question: string | undefined;
readonly enableConseca: boolean;
private readonly coreTools: string[] | undefined;
/** @deprecated Use Policy Engine instead */
@@ -643,7 +633,6 @@ export class Config {
readonly interactive: boolean;
private readonly ptyInfo: string;
private readonly trustedFolder: boolean | undefined;
private readonly directWebFetch: boolean;
private readonly useRipgrep: boolean;
private readonly enableInteractiveShell: boolean;
private readonly skipNextSpeakerCheck: boolean;
@@ -666,7 +655,6 @@ export class Config {
private readonly outputSettings: OutputSettings;
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
private readonly maxAttempts: number;
private readonly enableShellOutputEfficiency: boolean;
private readonly shellToolInactivityTimeout: number;
readonly fakeResponses?: string;
@@ -838,7 +826,6 @@ export class Config {
this.interactive = params.interactive ?? false;
this.ptyInfo = params.ptyInfo ?? 'child_process';
this.trustedFolder = params.trustedFolder;
this.directWebFetch = params.directWebFetch ?? false;
this.useRipgrep = params.useRipgrep ?? true;
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
@@ -875,35 +862,13 @@ export class Config {
this.recordResponses = params.recordResponses;
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.enableConseca = params.enableConseca ?? false;
// Initialize Safety Infrastructure
const contextBuilder = new ContextBuilder(this);
const checkersPath = this.targetDir;
// The checkersPath is used to resolve external checkers. Since we do not have any external checkers currently, it is set to the targetDir.
const checkerRegistry = new CheckerRegistry(checkersPath);
const checkerRunner = new CheckerRunner(contextBuilder, checkerRegistry, {
checkersPath,
timeout: 30000, // 30 seconds to allow for LLM-based checkers
this.policyEngine = new PolicyEngine({
...params.policyEngineConfig,
approvalMode:
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
});
this.policyUpdateConfirmationRequest =
params.policyUpdateConfirmationRequest;
this.policyEngine = new PolicyEngine(
{
...params.policyEngineConfig,
approvalMode:
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
},
checkerRunner,
);
// Register Conseca if enabled
if (this.enableConseca) {
debugLogger.log('[SAFETY] Registering Conseca Safety Checker');
ConsecaSafetyChecker.getInstance().setConfig(this);
}
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
this.acknowledgedAgentsService = new AcknowledgedAgentsService();
this.skillManager = new SkillManager();
@@ -911,10 +876,6 @@ export class Config {
format: params.output?.format ?? OutputFormat.TEXT,
};
this.retryFetchErrors = params.retryFetchErrors ?? false;
this.maxAttempts = Math.min(
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
DEFAULT_MAX_ATTEMPTS,
);
this.disableYoloMode = params.disableYoloMode ?? false;
this.rawOutput = params.rawOutput ?? false;
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
@@ -2124,10 +2085,6 @@ export class Config {
return this.approvedPlanPath;
}
getDirectWebFetch(): boolean {
return this.directWebFetch;
}
setApprovedPlanPath(path: string | undefined): void {
this.approvedPlanPath = path;
}
@@ -2451,10 +2408,6 @@ export class Config {
return this.retryFetchErrors;
}
getMaxAttempts(): number {
return this.maxAttempts;
}
getEnableShellOutputEfficiency(): boolean {
return this.enableShellOutputEfficiency;
}
-12
View File
@@ -14,7 +14,6 @@ import {
GOOGLE_ACCOUNTS_FILENAME,
isSubpath,
resolveToRealPath,
normalizePath,
} from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
@@ -143,17 +142,6 @@ export class Storage {
return path.join(this.targetDir, GEMINI_DIR);
}
/**
* Checks if the current workspace storage location is the same as the global/user storage location.
* This handles symlinks and platform-specific path normalization.
*/
isWorkspaceHomeDir(): boolean {
return (
normalizePath(resolveToRealPath(this.targetDir)) ===
normalizePath(resolveToRealPath(homedir()))
);
}
getAgentsDir(): string {
return path.join(this.targetDir, AGENTS_DIR_NAME);
}
@@ -95,9 +95,6 @@ export type SerializableConfirmationDetails =
serverName: string;
toolName: string;
toolDisplayName: string;
toolArgs?: Record<string, unknown>;
toolDescription?: string;
toolParameterSchema?: unknown;
}
| {
type: 'ask_user';
+25 -47
View File
@@ -641,7 +641,7 @@ describe('BaseLlmClient', () => {
);
contentOptions = {
modelConfigKey: { model: 'test-model', isChatModel: false },
modelConfigKey: { model: 'test-model' },
contents: [{ role: 'user', parts: [{ text: 'Give me a color.' }] }],
abortSignal: abortController.signal,
promptId: 'content-prompt-id',
@@ -650,17 +650,12 @@ describe('BaseLlmClient', () => {
jsonOptions = {
...defaultOptions,
modelConfigKey: {
...defaultOptions.modelConfigKey,
isChatModel: true,
},
promptId: 'json-prompt-id',
};
});
it('should mark model as healthy on success', async () => {
const successfulModel = 'gemini-pro';
mockConfig.getActiveModel.mockReturnValue(successfulModel);
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
selectedModel: successfulModel,
skipped: [],
@@ -671,7 +666,7 @@ describe('BaseLlmClient', () => {
await client.generateContent({
...contentOptions,
modelConfigKey: { model: successfulModel, isChatModel: false },
modelConfigKey: { model: successfulModel },
role: LlmRole.UTILITY_TOOL,
});
@@ -683,55 +678,44 @@ describe('BaseLlmClient', () => {
it('marks the final attempted model healthy after a retry with availability enabled', async () => {
const firstModel = 'gemini-pro';
const fallbackModel = 'gemini-flash';
let activeModel = firstModel;
mockConfig.getActiveModel.mockImplementation(() => activeModel);
mockConfig.setActiveModel.mockImplementation((m) => {
activeModel = m;
});
vi.mocked(mockAvailabilityService.selectFirstAvailable)
.mockReturnValueOnce({ selectedModel: firstModel, skipped: [] })
.mockReturnValueOnce({ selectedModel: fallbackModel, skipped: [] });
// Mock generateContent to fail once and then succeed
mockGenerateContent
.mockResolvedValueOnce(createMockResponse(''))
.mockResolvedValueOnce(createMockResponse('retry-me'))
.mockResolvedValueOnce(createMockResponse('final-response'));
// 1. First call starts. applyModelSelection(firstModel) -> currentModel = firstModel.
// 2. apiCall() runs. getActiveModel() === firstModel. call(firstModel). returns ''.
// 3. retry triggers.
// 4. Second call starts. applyModelSelection(firstModel).
// selectFirstAvailable -> fallbackModel.
// setActiveModel(fallbackModel) -> activeModel = fallbackModel.
// returns fallbackModel.
// 5. apiCall() runs. getActiveModel() === fallbackModel. call(fallbackModel). returns 'final-response'.
// Run the real retryWithBackoff (with fake timers) to exercise the retry path
vi.useFakeTimers();
vi.mocked(retryWithBackoff).mockImplementation(async (fn) => {
// First call
let res = (await fn()) as GenerateContentResponse;
if (res.candidates?.[0]?.content?.parts?.[0]?.text === '') {
// Second call
activeModel = fallbackModel;
mockConfig.setActiveModel(fallbackModel);
res = (await fn()) as GenerateContentResponse;
}
mockAvailabilityService.markHealthy(activeModel);
return res;
});
const result = await client.generateContent({
const retryPromise = client.generateContent({
...contentOptions,
modelConfigKey: { model: firstModel, isChatModel: true },
modelConfigKey: { model: firstModel },
maxAttempts: 2,
role: LlmRole.UTILITY_TOOL,
});
expect(result).toEqual(createMockResponse('final-response'));
await vi.runAllTimersAsync();
await retryPromise;
await client.generateContent({
...contentOptions,
modelConfigKey: { model: firstModel },
maxAttempts: 2,
role: LlmRole.UTILITY_TOOL,
});
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(firstModel);
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(fallbackModel);
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
fallbackModel,
);
expect(mockGenerateContent).toHaveBeenLastCalledWith(
expect.objectContaining({ model: fallbackModel }),
expect.any(String),
LlmRole.UTILITY_TOOL,
);
});
it('should consume sticky attempt if selection has attempts', async () => {
@@ -770,7 +754,6 @@ describe('BaseLlmClient', () => {
it('should mark healthy and honor availability selection when using generateJson', async () => {
const availableModel = 'gemini-json-pro';
mockConfig.getActiveModel.mockReturnValue(availableModel);
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
selectedModel: availableModel,
skipped: [],
@@ -787,15 +770,10 @@ describe('BaseLlmClient', () => {
return result;
});
const result = await client.generateJson({
...jsonOptions,
modelConfigKey: {
...jsonOptions.modelConfigKey,
isChatModel: false,
},
});
const result = await client.generateJson(jsonOptions);
expect(result).toEqual({ color: 'violet' });
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(availableModel);
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
availableModel,
);
+3 -6
View File
@@ -280,22 +280,19 @@ export class BaseLlmClient {
() => currentModel,
);
let initialActiveModel = this.config.getActiveModel();
try {
const apiCall = () => {
// Ensure we use the current active model
// in case a fallback occurred in a previous attempt.
const activeModel = this.config.getActiveModel();
if (activeModel !== initialActiveModel) {
initialActiveModel = activeModel;
if (activeModel !== currentModel) {
currentModel = activeModel;
// Re-resolve config if model changed during retry
const { model: resolvedModel, generateContentConfig } =
const { generateContentConfig } =
this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: activeModel,
});
currentModel = resolvedModel;
currentGenerateContentConfig = generateContentConfig;
}
const finalConfig: GenerateContentConfig = {
+7 -11
View File
@@ -957,21 +957,17 @@ export class GeminiClient {
() => currentAttemptModel,
);
let initialActiveModel = this.config.getActiveModel();
const apiCall = () => {
// AvailabilityService
const active = this.config.getActiveModel();
if (active !== initialActiveModel) {
initialActiveModel = active;
if (active !== currentAttemptModel) {
currentAttemptModel = active;
// Re-resolve config if model changed
const { model: resolvedModel, generateContentConfig } =
this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: active,
});
currentAttemptModel = resolvedModel;
currentAttemptGenerateContentConfig = generateContentConfig;
const newConfig = this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: currentAttemptModel,
});
currentAttemptGenerateContentConfig = newConfig.generateContentConfig;
}
const requestConfig: GenerateContentConfig = {
@@ -75,7 +75,6 @@ export async function executeToolWithHooks(
shellExecutionConfig?: ShellExecutionConfig,
setPidCallback?: (pid: number) => void,
config?: Config,
originalRequestName?: string,
): Promise<ToolResult> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const toolInput = (invocation.params || {}) as Record<string, unknown>;
@@ -91,7 +90,6 @@ export async function executeToolWithHooks(
toolName,
toolInput,
mcpContext,
originalRequestName,
);
// Check if hook requested to stop entire agent execution
@@ -198,7 +196,6 @@ export async function executeToolWithHooks(
error: toolResult.error,
},
mcpContext,
originalRequestName,
);
// Check if hook requested to stop entire agent execution
@@ -245,12 +242,6 @@ export async function executeToolWithHooks(
toolResult.llmContent = wrappedContext;
}
}
// Check if the hook requested a tail tool call
const tailToolCallRequest = afterOutput?.getTailToolCallRequest();
if (tailToolCallRequest) {
toolResult.tailToolCallRequest = tailToolCallRequest;
}
}
return toolResult;
@@ -153,7 +153,6 @@ describe('GeminiChat', () => {
}),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getMaxAttempts: vi.fn().mockReturnValue(10),
getUserTier: vi.fn().mockReturnValue(undefined),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => {
+7 -3
View File
@@ -18,7 +18,11 @@ import type {
} from '@google/genai';
import { toParts } from '../code_assist/converter.js';
import { createUserContent, FinishReason } from '@google/genai';
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
import {
retryWithBackoff,
isRetryableError,
DEFAULT_MAX_ATTEMPTS,
} from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
import {
@@ -631,12 +635,12 @@ export class GeminiChat {
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
signal: abortSignal,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
maxAttempts: availabilityMaxAttempts,
getAvailabilityContext,
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
maxAttempts: availabilityMaxAttempts ?? DEFAULT_MAX_ATTEMPTS,
delayMs,
error: error instanceof Error ? error.message : String(error),
model: lastModelToUse,
@@ -94,7 +94,6 @@ describe('GeminiChat Network Retries', () => {
getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn() }),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false), // Default false
getMaxAttempts: vi.fn().mockReturnValue(10),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => ({
model: modelConfigKey.model,
@@ -22,11 +22,6 @@ import { LlmRole } from '../telemetry/types.js';
vi.mock('node:fs', () => ({
appendFileSync: vi.fn(),
createWriteStream: vi.fn(() => ({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
})),
}));
describe('RecordingContentGenerator', () => {
@@ -76,16 +76,12 @@ export class HookEventHandler {
toolName: string,
toolInput: Record<string, unknown>,
mcpContext?: McpToolContext,
originalRequestName?: string,
): Promise<AggregatedHookResult> {
const input: BeforeToolInput = {
...this.createBaseInput(HookEventName.BeforeTool),
tool_name: toolName,
tool_input: toolInput,
...(mcpContext && { mcp_context: mcpContext }),
...(originalRequestName && {
original_request_name: originalRequestName,
}),
};
const context: HookEventContext = { toolName };
@@ -101,7 +97,6 @@ export class HookEventHandler {
toolInput: Record<string, unknown>,
toolResponse: Record<string, unknown>,
mcpContext?: McpToolContext,
originalRequestName?: string,
): Promise<AggregatedHookResult> {
const input: AfterToolInput = {
...this.createBaseInput(HookEventName.AfterTool),
@@ -109,9 +104,6 @@ export class HookEventHandler {
tool_input: toolInput,
tool_response: toolResponse,
...(mcpContext && { mcp_context: mcpContext }),
...(originalRequestName && {
original_request_name: originalRequestName,
}),
};
const context: HookEventContext = { toolName };
-4
View File
@@ -368,14 +368,12 @@ export class HookSystem {
toolName: string,
toolInput: Record<string, unknown>,
mcpContext?: McpToolContext,
originalRequestName?: string,
): Promise<DefaultHookOutput | undefined> {
try {
const result = await this.hookEventHandler.fireBeforeToolEvent(
toolName,
toolInput,
mcpContext,
originalRequestName,
);
return result.finalOutput;
} catch (error) {
@@ -393,7 +391,6 @@ export class HookSystem {
error: unknown;
},
mcpContext?: McpToolContext,
originalRequestName?: string,
): Promise<DefaultHookOutput | undefined> {
try {
const result = await this.hookEventHandler.fireAfterToolEvent(
@@ -401,7 +398,6 @@ export class HookSystem {
toolInput,
toolResponse as Record<string, unknown>,
mcpContext,
originalRequestName,
);
return result.finalOutput;
} catch (error) {
-37
View File
@@ -253,33 +253,6 @@ export class DefaultHookOutput implements HookOutput {
shouldClearContext(): boolean {
return false;
}
/**
* Optional request to execute another tool immediately after this one.
* The result of this tail call will replace the original tool's response.
*/
getTailToolCallRequest():
| {
name: string;
args: Record<string, unknown>;
}
| undefined {
if (
this.hookSpecificOutput &&
'tailToolCallRequest' in this.hookSpecificOutput
) {
const request = this.hookSpecificOutput['tailToolCallRequest'];
if (
typeof request === 'object' &&
request !== null &&
!Array.isArray(request)
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return request as { name: string; args: Record<string, unknown> };
}
}
return undefined;
}
}
/**
@@ -457,7 +430,6 @@ export interface BeforeToolInput extends HookInput {
tool_name: string;
tool_input: Record<string, unknown>;
mcp_context?: McpToolContext; // Only present for MCP tools
original_request_name?: string;
}
/**
@@ -478,7 +450,6 @@ export interface AfterToolInput extends HookInput {
tool_input: Record<string, unknown>;
tool_response: Record<string, unknown>;
mcp_context?: McpToolContext; // Only present for MCP tools
original_request_name?: string;
}
/**
@@ -488,14 +459,6 @@ export interface AfterToolOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'AfterTool';
additionalContext?: string;
/**
* Optional request to execute another tool immediately after this one.
* The result of this tail call will replace the original tool's response.
*/
tailToolCallRequest?: {
name: string;
args: Record<string, unknown>;
};
};
}
@@ -17,7 +17,6 @@ vi.mock('node:fs', () => ({
writeFile: vi.fn(),
unlink: vi.fn(),
mkdir: vi.fn(),
rename: vi.fn(),
},
}));
@@ -39,7 +38,6 @@ describe('FileTokenStorage', () => {
writeFile: ReturnType<typeof vi.fn>;
unlink: ReturnType<typeof vi.fn>;
mkdir: ReturnType<typeof vi.fn>;
rename: ReturnType<typeof vi.fn>;
};
const existingCredentials: OAuthCredentials = {
serverName: 'existing-server',
@@ -107,48 +105,12 @@ describe('FileTokenStorage', () => {
expect(result).toEqual(credentials);
});
it('should throw error with file path when file is corrupted', async () => {
it('should throw error for corrupted files', async () => {
mockFs.readFile.mockResolvedValue('corrupted-data');
try {
await storage.getCredentials('test-server');
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
});
});
describe('auth type switching', () => {
it('should throw error when trying to save credentials with corrupted file', async () => {
// Simulate corrupted file on first read
mockFs.readFile.mockResolvedValue('corrupted-data');
// Try to save new credentials (simulating switch from OAuth to API key)
const newCredentials: OAuthCredentials = {
serverName: 'new-auth-server',
token: {
accessToken: 'new-api-key',
tokenType: 'ApiKey',
},
updatedAt: Date.now(),
};
// Should throw error with file path
try {
await storage.setCredentials(newCredentials);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
await expect(storage.getCredentials('test-server')).rejects.toThrow(
'Token file corrupted',
);
});
});
@@ -87,12 +87,7 @@ export class FileTokenStorage extends BaseTokenStorage {
'Unsupported state or unable to authenticate data',
)
) {
// Decryption failed - this can happen when switching between auth types
// or if the file is genuinely corrupted.
throw new Error(
`Corrupted token file detected at: ${this.tokenFilePath}\n` +
`Please delete or rename this file to resolve the issue.`,
);
throw new Error('Token file corrupted');
}
throw error;
}
@@ -1,6 +0,0 @@
[[safety_checker]]
toolName = "*"
priority = 100
[safety_checker.checker]
type = "in-process"
name = "conseca"
+9 -172
View File
@@ -431,63 +431,6 @@ describe('PolicyEngine', () => {
});
describe('MCP server wildcard patterns', () => {
it('should match global wildcard (*)', async () => {
engine = new PolicyEngine({
rules: [
{ toolName: '*', decision: PolicyDecision.ALLOW, priority: 10 },
],
});
expect(
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__tool' }, 'my-server')).decision,
).toBe(PolicyDecision.ALLOW);
});
it('should match any MCP tool when toolName is *__*', async () => {
engine = new PolicyEngine({
rules: [
{ toolName: '*__*', decision: PolicyDecision.ALLOW, priority: 10 },
],
defaultDecision: PolicyDecision.DENY,
});
expect((await engine.check({ name: 'mcp__tool' }, 'mcp')).decision).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'other__tool' }, 'other')).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
});
it('should match specific tool across all servers when using *__tool', async () => {
engine = new PolicyEngine({
rules: [
{
toolName: '*__search',
decision: PolicyDecision.ALLOW,
priority: 10,
},
],
defaultDecision: PolicyDecision.DENY,
});
expect((await engine.check({ name: 'ws__search' }, 'ws')).decision).toBe(
PolicyDecision.ALLOW,
);
expect((await engine.check({ name: 'gh__search' }, 'gh')).decision).toBe(
PolicyDecision.ALLOW,
);
expect((await engine.check({ name: 'gh__list' }, 'gh')).decision).toBe(
PolicyDecision.DENY,
);
});
it('should match MCP server wildcard patterns', async () => {
const rules: PolicyRule[] = [
{
@@ -506,35 +449,26 @@ describe('PolicyEngine', () => {
// Should match my-server tools
expect(
(await engine.check({ name: 'my-server__tool1' }, 'my-server'))
.decision,
(await engine.check({ name: 'my-server__tool1' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__another_tool' }, 'my-server'))
(await engine.check({ name: 'my-server__another_tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// Should match blocked-server tools
expect(
(
await engine.check(
{ name: 'blocked-server__tool1' },
'blocked-server',
)
).decision,
(await engine.check({ name: 'blocked-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(
await engine.check(
{ name: 'blocked-server__dangerous' },
'blocked-server',
)
).decision,
(await engine.check({ name: 'blocked-server__dangerous' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
// Should not match other patterns
expect(
(await engine.check({ name: 'other-server__tool' }, 'other-server'))
(await engine.check({ name: 'other-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.ASK_USER);
expect(
@@ -563,11 +497,11 @@ describe('PolicyEngine', () => {
// Specific tool deny should override server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, 'my-server'))
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'my-server__safe-tool' }, 'my-server'))
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
});
@@ -2328,39 +2262,6 @@ describe('PolicyEngine', () => {
],
expected: [],
},
{
name: 'should handle global wildcard * in getExcludedTools',
rules: [
{
toolName: '*',
decision: PolicyDecision.DENY,
priority: 10,
},
],
expected: ['*'],
},
{
name: 'should handle MCP category wildcard *__* in getExcludedTools',
rules: [
{
toolName: '*__*',
decision: PolicyDecision.DENY,
priority: 10,
},
],
expected: ['*__*'],
},
{
name: 'should handle tool wildcard *__search in getExcludedTools',
rules: [
{
toolName: '*__search',
decision: PolicyDecision.DENY,
priority: 10,
},
],
expected: ['*__search'],
},
];
it.each(testCases)(
@@ -2557,68 +2458,4 @@ describe('PolicyEngine', () => {
expect(checkers[0].priority).toBe(2.5);
});
});
describe('Tool Annotations', () => {
it('should match tools by semantic annotations', async () => {
engine = new PolicyEngine({
rules: [
{
toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ALLOW,
priority: 10,
},
],
defaultDecision: PolicyDecision.DENY,
});
const readOnlyTool = { name: 'read', args: {} };
const readOnlyMeta = { readOnlyHint: true, extra: 'info' };
const writeTool = { name: 'write', args: {} };
const writeMeta = { readOnlyHint: false };
expect(
(await engine.check(readOnlyTool, undefined, readOnlyMeta)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check(writeTool, undefined, writeMeta)).decision,
).toBe(PolicyDecision.DENY);
expect((await engine.check(writeTool, undefined, {})).decision).toBe(
PolicyDecision.DENY,
);
});
it('should support scoped annotation rules', async () => {
engine = new PolicyEngine({
rules: [
{
toolName: '*__*',
toolAnnotations: { experimental: true },
decision: PolicyDecision.DENY,
priority: 20,
},
{
toolName: '*__*',
decision: PolicyDecision.ALLOW,
priority: 10,
},
],
});
expect(
(
await engine.check({ name: 'mcp__test' }, 'mcp', {
experimental: true,
})
).decision,
).toBe(PolicyDecision.DENY);
expect(
(
await engine.check({ name: 'mcp__stable' }, 'mcp', {
experimental: false,
})
).decision,
).toBe(PolicyDecision.ALLOW);
});
});
});
+20 -98
View File
@@ -27,73 +27,19 @@ import {
import { getToolAliases } from '../tools/tool-names.js';
function isWildcardPattern(name: string): boolean {
return name === '*' || name.includes('*');
return name.endsWith('__*');
}
/**
* Checks if a tool call matches a wildcard pattern.
* Supports global (*) and composite (server__*, *__tool, *__*) patterns.
*/
function matchesWildcard(
pattern: string,
toolName: string,
serverName: string | undefined,
): boolean {
if (pattern === '*') {
return true;
}
if (pattern.includes('__')) {
return matchesCompositePattern(pattern, toolName, serverName);
}
return toolName === pattern;
function getWildcardPrefix(pattern: string): string {
return pattern.slice(0, -3);
}
/**
* Matches composite patterns like "server__*", "*__tool", or "*__*".
*/
function matchesCompositePattern(
pattern: string,
toolName: string,
serverName: string | undefined,
): boolean {
const parts = pattern.split('__');
if (parts.length !== 2) return false;
const [patternServer, patternTool] = parts;
// 1. Identify the tool's components
const { actualServer, actualTool } = getToolMetadata(toolName, serverName);
// 2. Composite patterns require a server context
if (actualServer === undefined) {
function matchesWildcard(pattern: string, toolName: string): boolean {
if (!isWildcardPattern(pattern)) {
return false;
}
// 3. Robustness: if serverName is provided, toolName MUST be qualified by it.
// This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious".
if (serverName !== undefined && !toolName.startsWith(serverName + '__')) {
return false;
}
// 4. Match components
const serverMatch = patternServer === '*' || patternServer === actualServer;
const toolMatch = patternTool === '*' || patternTool === actualTool;
return serverMatch && toolMatch;
}
/**
* Extracts the server and unqualified tool name from a tool call context.
*/
function getToolMetadata(toolName: string, serverName: string | undefined) {
const sepIndex = toolName.indexOf('__');
const isQualified = sepIndex !== -1;
return {
actualServer:
serverName ?? (isQualified ? toolName.substring(0, sepIndex) : undefined),
actualTool: isQualified ? toolName.substring(sepIndex + 2) : toolName,
};
const prefix = getWildcardPrefix(pattern);
return toolName.startsWith(prefix + '__');
}
function ruleMatches(
@@ -102,7 +48,6 @@ function ruleMatches(
stringifiedArgs: string | undefined,
serverName: string | undefined,
currentApprovalMode: ApprovalMode,
toolAnnotations?: Record<string, unknown>,
): boolean {
// Check if rule applies to current approval mode
if (rule.modes && rule.modes.length > 0) {
@@ -114,13 +59,17 @@ function ruleMatches(
// Check tool name if specified
if (rule.toolName) {
// Support wildcard patterns: "serverName__*" matches "serverName__anyTool"
if (rule.toolName === '*') {
// Match all tools
} else if (isWildcardPattern(rule.toolName)) {
if (
!toolCall.name ||
!matchesWildcard(rule.toolName, toolCall.name, serverName)
) {
if (isWildcardPattern(rule.toolName)) {
const prefix = getWildcardPrefix(rule.toolName);
if (serverName !== undefined) {
// Robust check: if serverName is provided, it MUST match the prefix exactly.
// This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious".
if (serverName !== prefix) {
return false;
}
}
// Always verify the prefix, even if serverName matched
if (!toolCall.name || !matchesWildcard(rule.toolName, toolCall.name)) {
return false;
}
} else if (toolCall.name !== rule.toolName) {
@@ -128,18 +77,6 @@ function ruleMatches(
}
}
// Check annotations if specified
if (rule.toolAnnotations) {
if (!toolAnnotations) {
return false;
}
for (const [key, value] of Object.entries(rule.toolAnnotations)) {
if (toolAnnotations[key] !== value) {
return false;
}
}
}
// Check args pattern if specified
if (rule.argsPattern) {
// If rule has an args pattern but tool has no args, no match
@@ -220,7 +157,6 @@ export class PolicyEngine {
dir_path: string | undefined,
allowRedirection?: boolean,
rule?: PolicyRule,
toolAnnotations?: Record<string, unknown>,
): Promise<CheckResult> {
if (!command) {
return {
@@ -311,7 +247,6 @@ export class PolicyEngine {
const subResult = await this.check(
{ name: toolName, args: { command: subCmd, dir_path } },
serverName,
toolAnnotations,
);
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
@@ -369,7 +304,6 @@ export class PolicyEngine {
async check(
toolCall: FunctionCall,
serverName: string | undefined,
toolAnnotations?: Record<string, unknown>,
): Promise<CheckResult> {
let stringifiedArgs: string | undefined;
// Compute stringified args once before the loop
@@ -422,14 +356,7 @@ export class PolicyEngine {
for (const rule of this.rules) {
const match = toolCallsToTry.some((tc) =>
ruleMatches(
rule,
tc,
stringifiedArgs,
serverName,
this.approvalMode,
toolAnnotations,
),
ruleMatches(rule, tc, stringifiedArgs, serverName, this.approvalMode),
);
if (match) {
@@ -446,7 +373,6 @@ export class PolicyEngine {
shellDirPath,
rule.allowRedirection,
rule,
toolAnnotations,
);
decision = shellResult.decision;
if (shellResult.rule) {
@@ -473,9 +399,6 @@ export class PolicyEngine {
this.defaultDecision,
serverName,
shellDirPath,
undefined,
undefined,
toolAnnotations,
);
decision = shellResult.decision;
matchedRule = shellResult.rule;
@@ -494,7 +417,6 @@ export class PolicyEngine {
stringifiedArgs,
serverName,
this.approvalMode,
toolAnnotations,
)
) {
debugLogger.debug(
@@ -675,7 +597,7 @@ export class PolicyEngine {
for (const processed of processedTools) {
if (
isWildcardPattern(processed) &&
matchesWildcard(processed, toolName, undefined)
matchesWildcard(processed, toolName)
) {
// It's covered by a higher-priority wildcard rule.
// If that wildcard rule resulted in exclusion, this tool should also be excluded.
@@ -89,52 +89,6 @@ priority = 100
expect(result.errors).toHaveLength(0);
});
it('should parse toolAnnotations from TOML', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
toolName = "annotated-tool"
toolAnnotations = { readOnlyHint = true, custom = "value" }
decision = "allow"
priority = 70
`);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('annotated-tool');
expect(result.rules[0].toolAnnotations).toEqual({
readOnlyHint: true,
custom: 'value',
});
expect(result.errors).toHaveLength(0);
});
it('should transform mcpName = "*" to wildcard toolName', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
mcpName = "*"
decision = "ask_user"
priority = 10
`);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('*__*');
expect(result.rules[0].decision).toBe(PolicyDecision.ASK_USER);
expect(result.errors).toHaveLength(0);
});
it('should transform mcpName = "*" and specific toolName to wildcard prefix', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
mcpName = "*"
toolName = "search"
decision = "allow"
priority = 10
`);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('*__search');
expect(result.errors).toHaveLength(0);
});
it('should transform commandRegex to argsPattern', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
-4
View File
@@ -46,7 +46,6 @@ const PolicyRuleSchema = z.object({
'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
}),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
toolAnnotations: z.record(z.any()).optional(),
allow_redirection: z.boolean().optional(),
deny_message: z.string().optional(),
});
@@ -62,7 +61,6 @@ const SafetyCheckerRuleSchema = z.object({
commandRegex: z.string().optional(),
priority: z.number().int().default(0),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
toolAnnotations: z.record(z.any()).optional(),
checker: z.discriminatedUnion('type', [
z.object({
type: z.literal('in-process'),
@@ -385,7 +383,6 @@ export async function loadPoliciesFromToml(
decision: rule.decision,
priority: transformPriority(rule.priority, tier),
modes: rule.modes,
toolAnnotations: rule.toolAnnotations,
allowRedirection: rule.allow_redirection,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
denyMessage: rule.deny_message,
@@ -470,7 +467,6 @@ export async function loadPoliciesFromToml(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
checker: checker.checker as SafetyCheckerConfig,
modes: checker.modes,
toolAnnotations: checker.toolAnnotations,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
};

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