mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 08:40:58 -07:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f1970df81 | |||
| c4fe8f4ff1 | |||
| b36e4eb1eb | |||
| 5f034e58af | |||
| f72359efef | |||
| 84ce53aafa | |||
| b19b026f30 | |||
| eb9223b6a4 | |||
| 65d26e73a2 | |||
| 0080589939 | |||
| 34a47a51f4 | |||
| f5dd1068f6 | |||
| c202cf3145 | |||
| 5baad108d9 | |||
| 63e9d5d15f | |||
| 3776c4d613 | |||
| 2af5a3a01e | |||
| 0d034b8c18 | |||
| 49d55d972e | |||
| 7a02d7261a | |||
| 9c11ff2d58 | |||
| b3ecac7086 | |||
| 6d3fff2ea4 | |||
| be2ebd1772 | |||
| cb4e1e684d | |||
| b7a3243334 | |||
| 8257ec447a | |||
| 9590a092ae | |||
| 49533cd106 | |||
| a2174751de | |||
| 8b762111a8 | |||
| c03d96b46c | |||
| ea1f19aa52 | |||
| 2eb1c92347 | |||
| ef02cec2cd | |||
| f9fc9335f5 | |||
| 9813531f81 | |||
| 55571de066 | |||
| 740f0e4c3d | |||
| b37e67451a | |||
| 262138cad5 | |||
| 5920750c24 | |||
| f5b1245f51 | |||
| f2ca0bb38d | |||
| 4e9bafdacd | |||
| 41bbe6ca0a | |||
| eb5492f9b5 | |||
| 37f128a109 | |||
| 79753ec5ec | |||
| e151b4890b | |||
| e6b43cb846 | |||
| 2ae5e1ae20 | |||
| 67d9b76e81 | |||
| 4a3c7ae8b7 | |||
| bd2744031f | |||
| 92a5f725a1 | |||
| 4494f9e062 | |||
| ece001f264 | |||
| d3cfbdb3b7 | |||
| 7a132512cf | |||
| 6dae3a5402 | |||
| 0a3ecf3a75 | |||
| 9081743a7f | |||
| 89d4556c45 | |||
| 5d0570b113 | |||
| eb94284256 | |||
| cc2798018b | |||
| c9f9a7f67a | |||
| fd65416a2f | |||
| bce1caefd0 | |||
| 80057c5208 | |||
| 14219bb57d | |||
| ef957a368d | |||
| a3e5b564f7 | |||
| 1b98c1f806 | |||
| 9e41b2cd89 | |||
| 3fb1937247 | |||
| 07056c8f16 | |||
| 08dca3e1d6 | |||
| bcc0f27594 | |||
| 262e8384d4 | |||
| e73288f25f | |||
| aebc107d2c | |||
| 469cbca67f | |||
| cb7fca01b2 | |||
| 81ac5be30b | |||
| 81ccd80c6d | |||
| 01906a9205 |
@@ -25,7 +25,7 @@ You are an expert at fixing behavioral evaluations.
|
||||
the same scenario. We don't want to lose test fidelity by making the prompts too
|
||||
direct (i.e.: easy).
|
||||
- Your primary mechanism for improving the agent's behavior is to make changes to
|
||||
tool instructions, prompt.ts, and/or modules that contribute to the prompt.
|
||||
tool instructions, system prompt (snippets.ts), and/or modules that contribute to the prompt.
|
||||
- If prompt and description changes are unsuccessful, use logs and debugging to
|
||||
confirm that everything is working as expected.
|
||||
- If unable to fix the test, you can make recommendations for architecture changes
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"experimental": {
|
||||
"toolOutputMasking": {
|
||||
"enabled": true
|
||||
}
|
||||
"plan": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,25 +14,34 @@ repository's standards.
|
||||
|
||||
Follow these steps to create a Pull Request:
|
||||
|
||||
1. **Branch Management**: Check the current branch to avoid working directly
|
||||
on `main`.
|
||||
1. **Branch Management**: **CRITICAL:** Ensure you are NOT working on the
|
||||
`main` branch.
|
||||
- Run `git branch --show-current`.
|
||||
- If the current branch is `main`, create and switch to a new descriptive
|
||||
branch:
|
||||
- If the current branch is `main`, you MUST create and switch to a new
|
||||
descriptive branch:
|
||||
```bash
|
||||
git checkout -b <new-branch-name>
|
||||
```
|
||||
|
||||
2. **Locate Template**: Search for a pull request template in the repository.
|
||||
2. **Commit Changes**: Verify that all intended changes are committed.
|
||||
- Run `git status` to check for unstaged or uncommitted changes.
|
||||
- If there are uncommitted changes, stage and commit them with a descriptive
|
||||
message before proceeding. NEVER commit directly to `main`.
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "type(scope): description"
|
||||
```
|
||||
|
||||
3. **Locate Template**: Search for a pull request template in the repository.
|
||||
- Check `.github/pull_request_template.md`
|
||||
- Check `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
- If multiple templates exist (e.g., in `.github/PULL_REQUEST_TEMPLATE/`),
|
||||
ask the user which one to use or select the most appropriate one based on
|
||||
the context (e.g., `bug_fix.md` vs `feature.md`).
|
||||
|
||||
3. **Read Template**: Read the content of the identified template file.
|
||||
4. **Read Template**: Read the content of the identified template file.
|
||||
|
||||
4. **Draft Description**: Create a PR description that strictly follows the
|
||||
5. **Draft Description**: Create a PR description that strictly follows the
|
||||
template's structure.
|
||||
- **Headings**: Keep all headings from the template.
|
||||
- **Checklists**: Review each item. Mark with `[x]` if completed. If an item
|
||||
@@ -44,14 +53,24 @@ Follow these steps to create a Pull Request:
|
||||
- **Related Issues**: Link any issues fixed or related to this PR (e.g.,
|
||||
"Fixes #123").
|
||||
|
||||
5. **Preflight Check**: Before creating the PR, run the workspace preflight
|
||||
6. **Preflight Check**: Before creating the PR, run the workspace preflight
|
||||
script to ensure all build, lint, and test checks pass.
|
||||
```bash
|
||||
npm run preflight
|
||||
```
|
||||
If any checks fail, address the issues before proceeding to create the PR.
|
||||
|
||||
6. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
|
||||
7. **Push Branch**: Push the current branch to the remote repository.
|
||||
**CRITICAL SAFETY RAIL:** Double-check your branch name before pushing.
|
||||
NEVER push if the current branch is `main`.
|
||||
```bash
|
||||
# Verify current branch is NOT main
|
||||
git branch --show-current
|
||||
# Push non-interactively
|
||||
git push -u origin HEAD
|
||||
```
|
||||
|
||||
8. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
|
||||
issues with multi-line Markdown, write the description to a temporary file
|
||||
first.
|
||||
```bash
|
||||
@@ -68,6 +87,7 @@ Follow these steps to create a Pull Request:
|
||||
|
||||
## Principles
|
||||
|
||||
- **Safety First**: NEVER push to `main`. This is your highest priority.
|
||||
- **Compliance**: Never ignore the PR template. It exists for a reason.
|
||||
- **Completeness**: Fill out all relevant sections.
|
||||
- **Accuracy**: Don't check boxes for tasks you haven't done.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/* global process, console, require */
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const { Octokit } = require('@octokit/rest');
|
||||
|
||||
/**
|
||||
|
||||
@@ -356,11 +356,17 @@ jobs:
|
||||
clean-script: 'clean'
|
||||
|
||||
test_windows:
|
||||
name: 'Slow Test - Win'
|
||||
name: 'Slow Test - Win - ${{ matrix.shard }}'
|
||||
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:
|
||||
shard:
|
||||
- 'cli'
|
||||
- 'others'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
@@ -411,7 +417,14 @@ jobs:
|
||||
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
|
||||
UV_THREADPOOL_SIZE: '32'
|
||||
NODE_ENV: 'test'
|
||||
run: 'npm run test:ci -- --coverage.enabled=false'
|
||||
run: |
|
||||
if ("${{ matrix.shard }}" -eq "cli") {
|
||||
npm run test:ci --workspace @google/gemini-cli -- --coverage.enabled=false
|
||||
} else {
|
||||
# Explicitly list non-cli packages to ensure they are sharded correctly
|
||||
npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false
|
||||
npm run test:scripts
|
||||
}
|
||||
shell: 'pwsh'
|
||||
|
||||
- name: 'Bundle'
|
||||
|
||||
@@ -4,7 +4,7 @@ name: 'Generate Release Notes'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: ['created']
|
||||
types: ['published']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
|
||||
@@ -52,6 +52,10 @@ powerful tool for developers.
|
||||
|
||||
## Development Conventions
|
||||
|
||||
- **Legacy Snippets:** `packages/core/src/prompts/snippets.legacy.ts` is a
|
||||
snapshot of an older system prompt. Avoid changing the prompting verbiage to
|
||||
preserve its historical behavior; however, structural changes to ensure
|
||||
compilation or simplify the code are permitted.
|
||||
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
|
||||
signing the Google CLA.
|
||||
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
|
||||
@@ -63,6 +67,9 @@ powerful tool for developers.
|
||||
and `packages/core` (Backend logic).
|
||||
- **Imports:** Use specific imports and avoid restricted relative imports
|
||||
between packages (enforced by ESLint).
|
||||
- **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`),
|
||||
include the Apache-2.0 license header with the current year. (e.g.,
|
||||
`Copyright 2026 Google LLC`). This is enforced by ESLint.
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ available combinations.
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
||||
| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
|
||||
| Toggle current background shell visibility. | `Ctrl + B` |
|
||||
| Toggle background shell list. | `Ctrl + L` |
|
||||
| Kill the active background shell. | `Ctrl + K` |
|
||||
@@ -130,7 +131,8 @@ available combinations.
|
||||
- `!` on an empty prompt: Enter or exit shell mode.
|
||||
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press
|
||||
`Esc`, `Backspace`, or any printable key to close it. Press `?` again to close
|
||||
the panel and insert a `?` into the prompt.
|
||||
the panel and insert a `?` into the prompt. You can hide only the hint text
|
||||
via `ui.showShortcutsHint`, without changing this keyboard behavior.
|
||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||
mode.
|
||||
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||
@@ -139,6 +141,7 @@ available combinations.
|
||||
single-line input, navigate backward or forward through prompt history.
|
||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||
the numbered radio option and confirm when the full number is entered.
|
||||
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
|
||||
buffer mode: Expand to view full content inline. Double-click again to
|
||||
collapse.
|
||||
- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`)
|
||||
inline when the cursor is over the placeholder.
|
||||
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
|
||||
view full content inline. Double-click again to collapse.
|
||||
|
||||
+90
-14
@@ -1,4 +1,4 @@
|
||||
# Plan Mode (experimental) <!-- omit in toc -->
|
||||
# Plan Mode (experimental)
|
||||
|
||||
Plan Mode is a safe, read-only mode for researching and designing complex
|
||||
changes. It prevents modifications while you research, design and plan an
|
||||
@@ -30,13 +30,15 @@ implementation strategy.
|
||||
- [The Planning Workflow](#the-planning-workflow)
|
||||
- [Exiting Plan Mode](#exiting-plan-mode)
|
||||
- [Tool Restrictions](#tool-restrictions)
|
||||
- [Customizing Planning with Skills](#customizing-planning-with-skills)
|
||||
- [Customizing Policies](#customizing-policies)
|
||||
|
||||
## Starting in Plan Mode
|
||||
|
||||
You can configure Gemini CLI to start directly in Plan Mode by default:
|
||||
|
||||
1. Type `/settings` in the CLI.
|
||||
2. Search for `Approval Mode`.
|
||||
2. Search for `Default Approval Mode`.
|
||||
3. Set the value to `Plan`.
|
||||
|
||||
Other ways to start in Plan Mode:
|
||||
@@ -46,8 +48,8 @@ Other ways to start in Plan Mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"approvalMode": "plan"
|
||||
"general": {
|
||||
"defaultApprovalMode": "plan"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -68,8 +70,10 @@ You can enter Plan Mode in three ways:
|
||||
1. **Requirements:** The agent clarifies goals using `ask_user`.
|
||||
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
|
||||
the codebase and validate assumptions.
|
||||
3. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
4. **Review:** You review the plan.
|
||||
3. **Design:** The agent proposes alternative approaches with a recommended
|
||||
solution for you to choose from.
|
||||
4. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
5. **Review:** You review the plan.
|
||||
- **Approve:** Exit Plan Mode and start implementation (switching to
|
||||
Auto-Edit or Default approval mode).
|
||||
- **Iterate:** Provide feedback to refine the plan.
|
||||
@@ -95,12 +99,84 @@ These are the only allowed tools:
|
||||
`postgres_read_schema`) are allowed.
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
||||
files in the `~/.gemini/tmp/<project>/plans/` directory.
|
||||
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
|
||||
resources in a read-only manner)
|
||||
|
||||
[`list_directory`]: ../tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: ../tools/file-system.md#2-read_file-readfile
|
||||
[`grep_search`]: ../tools/file-system.md#5-grep_search-searchtext
|
||||
[`write_file`]: ../tools/file-system.md#3-write_file-writefile
|
||||
[`glob`]: ../tools/file-system.md#4-glob-findfiles
|
||||
[`google_web_search`]: ../tools/web-search.md
|
||||
[`replace`]: ../tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: ../tools/mcp-server.md
|
||||
### Customizing Planning with Skills
|
||||
|
||||
You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI
|
||||
approaches planning for specific types of tasks. When a skill is activated
|
||||
during Plan Mode, its specialized instructions and procedural workflows will
|
||||
guide the research and design phases.
|
||||
|
||||
For example:
|
||||
|
||||
- A **"Database Migration"** skill could ensure the plan includes data safety
|
||||
checks and rollback strategies.
|
||||
- A **"Security Audit"** skill could prompt the agent to look for specific
|
||||
vulnerabilities during codebase exploration.
|
||||
- A **"Frontend Design"** skill could guide the agent to use specific UI
|
||||
components and accessibility standards in its proposal.
|
||||
|
||||
To use a skill in Plan Mode, you can explicitly ask the agent to "use the
|
||||
[skill-name] skill to plan..." or the agent may autonomously activate it based
|
||||
on the task description.
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
Plan Mode is designed to be read-only by default to ensure safety during the
|
||||
research phase. However, you may occasionally need to allow specific tools to
|
||||
assist in your planning.
|
||||
|
||||
Because user policies (Tier 2) have a higher base priority than built-in
|
||||
policies (Tier 1), you can override Plan Mode's default restrictions by creating
|
||||
a rule in your `~/.gemini/policies/` directory.
|
||||
|
||||
#### Example: Allow `git status` and `git diff` in Plan Mode
|
||||
|
||||
This rule allows you to check the repository status and see changes while in
|
||||
Plan Mode.
|
||||
|
||||
`~/.gemini/policies/git-research.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = ["git status", "git diff"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research sub-agents in Plan Mode
|
||||
|
||||
You can enable [experimental research sub-agents] like `codebase_investigator`
|
||||
to help gather architecture details during the planning phase.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
Tell the agent it can use these tools in your prompt, for example: _"You can
|
||||
check ongoing changes in git."_
|
||||
|
||||
For more information on how the policy engine works, see the [Policy Engine
|
||||
Guide].
|
||||
|
||||
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
||||
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
|
||||
[`write_file`]: /docs/tools/file-system.md#3-write_file-writefile
|
||||
[`glob`]: /docs/tools/file-system.md#4-glob-findfiles
|
||||
[`google_web_search`]: /docs/tools/web-search.md
|
||||
[`replace`]: /docs/tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: /docs/tools/mcp-server.md
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[experimental research sub-agents]: /docs/core/subagents.md
|
||||
[Policy Engine Guide]: /docs/core/policy-engine.md
|
||||
|
||||
+28
-19
@@ -22,13 +22,14 @@ they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------ | ---------------------------------- | ------------------------------------------------------------- | ------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| 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` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| 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` |
|
||||
|
||||
### Output
|
||||
|
||||
@@ -43,10 +44,12 @@ they appear in the UI.
|
||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
@@ -95,14 +98,13 @@ they appear in the UI.
|
||||
|
||||
### Tools
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
|
||||
### Security
|
||||
|
||||
@@ -115,12 +117,19 @@ they appear in the UI.
|
||||
| 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
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| --------------------------------- | ------------------------------ | --------------------------------------------- | ------- |
|
||||
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -119,9 +119,17 @@ For example:
|
||||
|
||||
Approval modes allow the policy engine to apply different sets of rules based on
|
||||
the CLI's operational mode. A rule can be associated with one or more modes
|
||||
(e.g., `yolo`, `autoEdit`). The rule will only be active if the CLI is running
|
||||
in one of its specified modes. If a rule has no modes specified, it is always
|
||||
active.
|
||||
(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is
|
||||
running in one of its specified modes. If a rule has no modes specified, it is
|
||||
always active.
|
||||
|
||||
- `default`: The standard interactive mode where most write tools require
|
||||
confirmation.
|
||||
- `autoEdit`: Optimized for automated code editing; some write tools may be
|
||||
auto-approved.
|
||||
- `plan`: A strict, read-only mode for research and design. See [Customizing
|
||||
Plan Mode Policies].
|
||||
- `yolo`: A mode where all tools are auto-approved (use with extreme caution).
|
||||
|
||||
## Rule matching
|
||||
|
||||
@@ -303,3 +311,5 @@ out-of-the-box experience.
|
||||
- In **`yolo`** mode, a high-priority rule allows all tools.
|
||||
- In **`autoEdit`** mode, rules allow certain write operations to happen without
|
||||
prompting.
|
||||
|
||||
[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies
|
||||
|
||||
@@ -106,6 +106,17 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Enable Vim keybindings
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.defaultApprovalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
read-only mode. 'yolo' is not supported yet.
|
||||
- **Default:** `"default"`
|
||||
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
|
||||
|
||||
- **`general.devtools`** (boolean):
|
||||
- **Description:** Enable DevTools inspector on launch.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.enableAutoUpdate`** (boolean):
|
||||
- **Description:** Enable automatic updates.
|
||||
- **Default:** `true`
|
||||
@@ -184,6 +195,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.inlineThinkingMode`** (enum):
|
||||
- **Description:** Display model thinking inline: off or full.
|
||||
- **Default:** `"off"`
|
||||
- **Values:** `"off"`, `"full"`
|
||||
|
||||
- **`ui.showStatusInTitle`** (boolean):
|
||||
- **Description:** Show Gemini CLI model thoughts in the terminal window title
|
||||
during the working phase
|
||||
@@ -204,6 +220,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Hide helpful tips in the UI
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showShortcutsHint`** (boolean):
|
||||
- **Description:** Show the "? for shortcuts" hint above the input.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.hideBanner`** (boolean):
|
||||
- **Description:** Hide the application banner
|
||||
- **Default:** `false`
|
||||
@@ -672,13 +692,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.approvalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
read-only mode. 'yolo' is not supported yet.
|
||||
- **Default:** `"default"`
|
||||
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
|
||||
|
||||
- **`tools.core`** (array):
|
||||
- **Description:** Restrict the set of built-in tools with an allowlist. Match
|
||||
semantics mirror tools.allowed; see the built-in tools documentation for
|
||||
@@ -839,6 +852,28 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.toolOutputMasking.enabled`** (boolean):
|
||||
- **Description:** Enables tool output masking to save tokens.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.toolProtectionThreshold`** (number):
|
||||
- **Description:** Minimum number of tokens to protect from masking (most
|
||||
recent tool outputs).
|
||||
- **Default:** `50000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.minPrunableTokensThreshold`** (number):
|
||||
- **Description:** Minimum prunable tokens required to trigger a masking pass.
|
||||
- **Default:** `30000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.protectLatestTurn`** (boolean):
|
||||
- **Description:** Ensures the absolute latest turn is never masked,
|
||||
regardless of token count.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents. Warning: Experimental
|
||||
feature, uses YOLO mode for subagents
|
||||
@@ -855,6 +890,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionRegistry`** (boolean):
|
||||
- **Description:** Enable extension registry explore UI.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionReloading`** (boolean):
|
||||
- **Description:** Enables extension loading/unloading within the CLI session.
|
||||
- **Default:** `false`
|
||||
|
||||
@@ -63,6 +63,7 @@ const external = [
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
'keytar',
|
||||
'gemini-cli-devtools',
|
||||
];
|
||||
|
||||
const baseConfig = {
|
||||
|
||||
+36
-5
@@ -23,6 +23,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
// Determine the monorepo root (assuming eslint.config.js is at the root)
|
||||
const projectRoot = __dirname;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
@@ -37,7 +38,6 @@ export default tseslint.config(
|
||||
'dist/**',
|
||||
'evals/**',
|
||||
'packages/test-utils/**',
|
||||
'packages/core/src/skills/builtin/skill-creator/scripts/*.cjs',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
@@ -193,6 +193,14 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rules that only apply to product code
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
ignores: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-type-assertion': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Allow os.homedir() in tests and paths.ts where it is used to implement the helper
|
||||
files: [
|
||||
@@ -243,7 +251,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./**/*.{tsx,ts,js}'],
|
||||
files: ['./**/*.{tsx,ts,js,cjs}'],
|
||||
plugins: {
|
||||
headers,
|
||||
import: importPlugin,
|
||||
@@ -260,8 +268,8 @@ export default tseslint.config(
|
||||
].join('\n'),
|
||||
patterns: {
|
||||
year: {
|
||||
pattern: '202[5-6]',
|
||||
defaultValue: '2026',
|
||||
pattern: `202[5-${currentYear.toString().slice(-1)}]`,
|
||||
defaultValue: currentYear.toString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -269,7 +277,6 @@ export default tseslint.config(
|
||||
'import/enforce-node-protocol-usage': ['error', 'always'],
|
||||
},
|
||||
},
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['./scripts/**/*.js', 'esbuild.config.js'],
|
||||
languageOptions: {
|
||||
@@ -290,6 +297,30 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
'no-console': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-redeclare': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/vscode-ide-companion/esbuild.js'],
|
||||
languageOptions: {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('Edits location eval', () => {
|
||||
/**
|
||||
* Ensure that Gemini CLI always updates existing test files, if present,
|
||||
* instead of creating a new one.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should update existing test file instead of creating a new one',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'test-location-repro',
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
test: 'vitest run',
|
||||
},
|
||||
devDependencies: {
|
||||
vitest: '^1.0.0',
|
||||
typescript: '^5.0.0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'src/math.ts': `
|
||||
export function add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
export function subtract(a: number, b: number): number {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
export function multiply(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
`,
|
||||
'src/math.test.ts': `
|
||||
import { expect, test } from 'vitest';
|
||||
import { add, subtract } from './math';
|
||||
|
||||
test('add adds two numbers', () => {
|
||||
expect(add(2, 3)).toBe(5);
|
||||
});
|
||||
|
||||
test('subtract subtracts two numbers', () => {
|
||||
expect(subtract(5, 3)).toBe(2);
|
||||
});
|
||||
`,
|
||||
'src/utils.ts': `
|
||||
export function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
`,
|
||||
'src/utils.test.ts': `
|
||||
import { expect, test } from 'vitest';
|
||||
import { capitalize } from './utils';
|
||||
|
||||
test('capitalize capitalizes the first letter', () => {
|
||||
expect(capitalize('hello')).toBe('Hello');
|
||||
});
|
||||
`,
|
||||
},
|
||||
prompt: 'Fix the bug in src/math.ts. Do not run the code.',
|
||||
timeout: 180000,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const replaceCalls = toolLogs.filter(
|
||||
(t) => t.toolRequest.name === 'replace',
|
||||
);
|
||||
const writeFileCalls = toolLogs.filter(
|
||||
(t) => t.toolRequest.name === 'write_file',
|
||||
);
|
||||
|
||||
expect(replaceCalls.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
writeFileCalls.some((file) =>
|
||||
file.toolRequest.args.includes('.test.ts'),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const targetFiles = replaceCalls.map((t) => {
|
||||
try {
|
||||
return JSON.parse(t.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('DEBUG: targetFiles', targetFiles);
|
||||
|
||||
expect(
|
||||
new Set(targetFiles).size,
|
||||
'Expected only two files changed',
|
||||
).greaterThanOrEqual(2);
|
||||
expect(targetFiles.some((f) => f?.endsWith('src/math.ts'))).toBe(true);
|
||||
expect(targetFiles.some((f) => f?.endsWith('src/math.test.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import {
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from '../integration-tests/test-helper.js';
|
||||
|
||||
describe('Hierarchical Memory', () => {
|
||||
const TEST_PREFIX = 'Hierarchical memory test: ';
|
||||
|
||||
const conflictResolutionTest =
|
||||
'Agent follows hierarchy for contradictory instructions';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: conflictResolutionTest,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
// We simulate the hierarchical memory by including the tags in the prompt
|
||||
// since setting up real global/extension/project files in the eval rig is complex.
|
||||
// The system prompt logic will append these tags when it finds them in userMemory.
|
||||
prompt: `
|
||||
<global_context>
|
||||
When asked for my favorite fruit, always say "Apple".
|
||||
</global_context>
|
||||
|
||||
<extension_context>
|
||||
When asked for my favorite fruit, always say "Banana".
|
||||
</extension_context>
|
||||
|
||||
<project_context>
|
||||
When asked for my favorite fruit, always say "Cherry".
|
||||
</project_context>
|
||||
|
||||
What is my favorite fruit? Tell me just the name of the fruit.`,
|
||||
assert: async (rig) => {
|
||||
const stdout = rig._lastRunStdout!;
|
||||
assertModelHasOutput(stdout);
|
||||
expect(stdout).toMatch(/Cherry/i);
|
||||
expect(stdout).not.toMatch(/Apple/i);
|
||||
expect(stdout).not.toMatch(/Banana/i);
|
||||
},
|
||||
});
|
||||
|
||||
const provenanceAwarenessTest = 'Agent is aware of memory provenance';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: provenanceAwarenessTest,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `
|
||||
<global_context>
|
||||
Instruction A: Always be helpful.
|
||||
</global_context>
|
||||
|
||||
<extension_context>
|
||||
Instruction B: Use a professional tone.
|
||||
</extension_context>
|
||||
|
||||
<project_context>
|
||||
Instruction C: Adhere to the project's coding style.
|
||||
</project_context>
|
||||
|
||||
Which instruction came from the global context, which from the extension context, and which from the project context?
|
||||
Provide the answer as an XML block like this:
|
||||
<results>
|
||||
<global>Instruction ...</global>
|
||||
<extension>Instruction ...</extension>
|
||||
<project>Instruction ...</project>
|
||||
</results>`,
|
||||
assert: async (rig) => {
|
||||
const stdout = rig._lastRunStdout!;
|
||||
assertModelHasOutput(stdout);
|
||||
expect(stdout).toMatch(/<global>.*Instruction A/i);
|
||||
expect(stdout).toMatch(/<extension>.*Instruction B/i);
|
||||
expect(stdout).toMatch(/<project>.*Instruction C/i);
|
||||
},
|
||||
});
|
||||
|
||||
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: extensionVsGlobalTest,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `
|
||||
<global_context>
|
||||
Set the theme to "Light".
|
||||
</global_context>
|
||||
|
||||
<extension_context>
|
||||
Set the theme to "Dark".
|
||||
</extension_context>
|
||||
|
||||
What theme should I use? Tell me just the name of the theme.`,
|
||||
assert: async (rig) => {
|
||||
const stdout = rig._lastRunStdout!;
|
||||
assertModelHasOutput(stdout);
|
||||
expect(stdout).toMatch(/Dark/i);
|
||||
expect(stdout).not.toMatch(/Light/i);
|
||||
},
|
||||
});
|
||||
});
|
||||
+11
-11
@@ -14,7 +14,7 @@ import {
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -36,7 +36,7 @@ describe('save_memory', () => {
|
||||
},
|
||||
});
|
||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -57,7 +57,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -79,7 +79,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringTemporaryInformation =
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -104,7 +104,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -125,7 +125,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -147,7 +147,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringDbSchemaLocation =
|
||||
"Agent ignores workspace's database schema location";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringDbSchemaLocation,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -178,7 +178,7 @@ describe('save_memory', () => {
|
||||
|
||||
const rememberingCodingStyle =
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -200,7 +200,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringBuildArtifactLocation =
|
||||
'Agent ignores workspace build artifact location';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringBuildArtifactLocation,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -230,7 +230,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringMainEntryPoint,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -260,7 +260,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingBirthday = "Agent remembers user's birthday";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('Shell Efficiency', () => {
|
||||
const getCommand = (call: any): string | undefined => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return typeof args === 'string' ? args : (args as any)['command'];
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use --silent/--quiet flags when installing packages',
|
||||
prompt: 'Install the "lodash" package using npm.',
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasEfficiencyFlag = shellCalls.some((call) => {
|
||||
const cmd = getCommand(call);
|
||||
return (
|
||||
cmd &&
|
||||
cmd.includes('npm install') &&
|
||||
(cmd.includes('--silent') ||
|
||||
cmd.includes('--quiet') ||
|
||||
cmd.includes('-q'))
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
hasEfficiencyFlag,
|
||||
`Expected agent to use efficiency flags for npm install. Commands used: ${shellCalls
|
||||
.map(getCommand)
|
||||
.join(', ')}`,
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use --no-pager with git commands',
|
||||
prompt: 'Show the git log.',
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasNoPager = shellCalls.some((call) => {
|
||||
const cmd = getCommand(call);
|
||||
return cmd && cmd.includes('git') && cmd.includes('--no-pager');
|
||||
});
|
||||
|
||||
expect(
|
||||
hasNoPager,
|
||||
`Expected agent to use --no-pager with git. Commands used: ${shellCalls
|
||||
.map(getCommand)
|
||||
.join(', ')}`,
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
shell: {
|
||||
enableShellOutputEfficiency: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: 'Install the "lodash" package using npm.',
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasEfficiencyFlag = shellCalls.some((call) => {
|
||||
const cmd = getCommand(call);
|
||||
return (
|
||||
cmd &&
|
||||
cmd.includes('npm install') &&
|
||||
(cmd.includes('--silent') ||
|
||||
cmd.includes('--quiet') ||
|
||||
cmd.includes('-q'))
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
hasEfficiencyFlag,
|
||||
'Agent used efficiency flags even though enableShellOutputEfficiency was disabled',
|
||||
).toBe(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import * as os from 'node:os';
|
||||
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
|
||||
import { Config } from '../packages/core/src/config/config.js';
|
||||
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
|
||||
import { createMockMessageBus } from '../packages/core/src/test-utils/mock-message-bus.js';
|
||||
|
||||
// Mock Config to provide necessary context
|
||||
class MockConfig {
|
||||
@@ -66,7 +67,7 @@ describe('ripgrep-real-direct', () => {
|
||||
await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n');
|
||||
|
||||
const config = new MockConfig(tempDir) as unknown as Config;
|
||||
tool = new RipGrepTool(config);
|
||||
tool = new RipGrepTool(config, createMockMessageBus());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -108,4 +109,24 @@ describe('ripgrep-real-direct', () => {
|
||||
expect(result.llmContent).toContain('script.js');
|
||||
expect(result.llmContent).not.toContain('file1.txt');
|
||||
});
|
||||
|
||||
it('should support context parameters', async () => {
|
||||
// Create a file with multiple lines
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'context.txt'),
|
||||
'line1\nline2\nline3 match\nline4\nline5\n',
|
||||
);
|
||||
|
||||
const invocation = tool.build({
|
||||
pattern: 'match',
|
||||
context: 1,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('context.txt');
|
||||
expect(result.llmContent).toContain('L2- line2');
|
||||
expect(result.llmContent).toContain('L3: line3 match');
|
||||
expect(result.llmContent).toContain('L4- line4');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,5 +20,8 @@ export default defineConfig({
|
||||
maxThreads: 16,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
GEMINI_TEST_TYPE: 'integration',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+324
-1124
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -64,7 +64,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink": "npm:@jrichman/ink@6.4.10",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -90,6 +90,7 @@
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -125,7 +126,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink": "npm:@jrichman/ink@6.4.10",
|
||||
"latest-version": "^9.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"simple-git": "^3.28.0"
|
||||
@@ -137,6 +138,7 @@
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"gemini-cli-devtools": "^0.2.1",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -117,6 +117,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const agentSettings = persistedState._agentSettings;
|
||||
const config = await this.getConfig(agentSettings, sdkTask.id);
|
||||
const contextId: string =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(metadata['_contextId'] as string) || sdkTask.contextId;
|
||||
const runtimeTask = await Task.create(
|
||||
sdkTask.id,
|
||||
@@ -140,6 +141,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
agentSettingsInput?: AgentSettings,
|
||||
eventBus?: ExecutionEventBus,
|
||||
): Promise<TaskWrapper> {
|
||||
// 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(
|
||||
@@ -290,6 +292,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const contextId: string =
|
||||
userMessage.contextId ||
|
||||
sdkTask?.contextId ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(sdkTask?.metadata?.['_contextId'] as string) ||
|
||||
uuidv4();
|
||||
|
||||
@@ -385,6 +388,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
}
|
||||
} else {
|
||||
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = userMessage.metadata?.[
|
||||
'coderAgent'
|
||||
] as AgentSettings;
|
||||
|
||||
@@ -378,6 +378,7 @@ export class Task {
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
this.pendingToolConfirmationDetails.set(
|
||||
tc.request.callId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
tc.confirmationDetails as ToolCallConfirmationDetails,
|
||||
);
|
||||
}
|
||||
@@ -411,7 +412,7 @@ export class Task {
|
||||
);
|
||||
toolCalls.forEach((tc: ToolCall) => {
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
|
||||
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
@@ -465,12 +466,14 @@ export class Task {
|
||||
T extends ToolCall | AnyDeclarativeTool,
|
||||
K extends UnionKeys<T>,
|
||||
>(from: T, ...fields: K[]): 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) {
|
||||
ret[field] = from[field];
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return ret as Partial<T>;
|
||||
}
|
||||
|
||||
@@ -493,6 +496,7 @@ export class Task {
|
||||
);
|
||||
|
||||
if (tc.tool) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
serializableToolCall.tool = this._pickFields(
|
||||
tc.tool,
|
||||
'name',
|
||||
@@ -622,8 +626,11 @@ export class Task {
|
||||
request.args['new_string']
|
||||
) {
|
||||
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 } };
|
||||
@@ -719,6 +726,7 @@ export class Task {
|
||||
case GeminiEventType.Error:
|
||||
default: {
|
||||
// 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.message ?? 'Unknown error from LLM stream';
|
||||
@@ -807,6 +815,7 @@ export class Task {
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
const payload = part.data['newContent']
|
||||
? ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
newContent: part.data['newContent'] as string,
|
||||
} as ToolConfirmationPayload)
|
||||
: undefined;
|
||||
|
||||
@@ -85,6 +85,7 @@ export class InitCommand implements Command {
|
||||
if (!context.agentExecutor) {
|
||||
throw new Error('Agent executor not found in context.');
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentExecutor = context.agentExecutor as CoderAgentExecutor;
|
||||
|
||||
const agentSettings: AgentSettings = {
|
||||
|
||||
@@ -41,9 +41,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
return mockConfig;
|
||||
}),
|
||||
loadServerHierarchicalMemory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
|
||||
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: { global: '', extension: '', project: '' },
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -77,6 +77,7 @@ export async function loadConfig(
|
||||
cwd: workspaceDir,
|
||||
telemetry: {
|
||||
enabled: settings.telemetry?.enabled,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
target: settings.telemetry?.target as TelemetryTarget,
|
||||
otlpEndpoint:
|
||||
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
|
||||
|
||||
@@ -93,6 +93,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
|
||||
|
||||
try {
|
||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = JSON.parse(configContent) as ExtensionConfig;
|
||||
if (!config.name || !config.version) {
|
||||
logger.error(
|
||||
@@ -107,6 +108,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
|
||||
.map((contextFileName) => path.join(extensionDir, contextFileName))
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
@@ -140,6 +142,7 @@ export function loadInstallMetadata(
|
||||
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
|
||||
try {
|
||||
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
|
||||
return metadata;
|
||||
} catch (e) {
|
||||
|
||||
@@ -67,6 +67,7 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
try {
|
||||
if (fs.existsSync(USER_SETTINGS_PATH)) {
|
||||
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsedUserSettings = JSON.parse(
|
||||
stripJsonComments(userContent),
|
||||
) as Settings;
|
||||
@@ -89,6 +90,7 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(projectContent),
|
||||
) as Settings;
|
||||
@@ -139,10 +141,12 @@ function resolveEnvVarsInObject<T>(obj: T): T {
|
||||
}
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return resolveEnvVarsInString(obj) as unknown as T;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ async function handleExecuteCommand(
|
||||
const eventHandler = (event: AgentExecutionEvent) => {
|
||||
const jsonRpcResponse = {
|
||||
jsonrpc: '2.0',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
id: 'taskId' in event ? event.taskId : (event as Message).messageId,
|
||||
result: event,
|
||||
};
|
||||
@@ -206,6 +207,7 @@ export async function createApp() {
|
||||
expressApp.post('/tasks', async (req, res) => {
|
||||
try {
|
||||
const taskId = uuidv4();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = req.body.agentSettings as
|
||||
| AgentSettings
|
||||
| undefined;
|
||||
|
||||
@@ -95,6 +95,7 @@ export class GCSTaskStore implements TaskStore {
|
||||
await this.ensureBucketInitialized();
|
||||
const taskId = task.id;
|
||||
const persistedState = getPersistedState(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
task.metadata as PersistedTaskMetadata,
|
||||
);
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ export const METADATA_KEY = '__persistedState';
|
||||
export function getPersistedState(
|
||||
metadata: PersistedTaskMetadata,
|
||||
): PersistedStateMetadata | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type {
|
||||
Task as SDKTask,
|
||||
TaskStatusUpdateEvent,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
GeminiClient,
|
||||
HookSystem,
|
||||
PolicyDecision,
|
||||
tmpdir,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
|
||||
import type { Config, Storage } from '@google/gemini-cli-core';
|
||||
@@ -24,6 +26,8 @@ import { expect, vi } from 'vitest';
|
||||
export function createMockConfig(
|
||||
overrides: Partial<Config> = {},
|
||||
): Partial<Config> {
|
||||
const tmpDir = tmpdir();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const mockConfig = {
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTool: vi.fn(),
|
||||
@@ -38,11 +42,12 @@ export function createMockConfig(
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
isPathWithinWorkspace: () => true,
|
||||
}),
|
||||
getTargetDir: () => '/test',
|
||||
getTargetDir: () => tmpDir,
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
getProjectTempCheckpointsDir: () => '/tmp/checkpoints',
|
||||
getProjectTempDir: () => tmpDir,
|
||||
getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'),
|
||||
} as Storage,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
@@ -145,6 +150,7 @@ export function assertUniqueFinalEventIsLast(
|
||||
events: SendStreamingMessageSuccessResponse[],
|
||||
) {
|
||||
// Final event is input-required & final
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent;
|
||||
expect(finalEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'state-change',
|
||||
@@ -154,9 +160,11 @@ export function assertUniqueFinalEventIsLast(
|
||||
|
||||
// There is only one event with final and its the last
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final),
|
||||
).toBe(events.length - 1);
|
||||
}
|
||||
@@ -165,11 +173,13 @@ export function assertTaskCreationAndWorkingStatus(
|
||||
events: SendStreamingMessageSuccessResponse[],
|
||||
) {
|
||||
// Initial task creation event
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const taskEvent = events[0].result as SDKTask;
|
||||
expect(taskEvent.kind).toBe('task');
|
||||
expect(taskEvent.status.state).toBe('submitted');
|
||||
|
||||
// Status update: working
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const workingEvent = events[1].result as TaskStatusUpdateEvent;
|
||||
expect(workingEvent.kind).toBe('status-update');
|
||||
expect(workingEvent.status.state).toBe('working');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -34,10 +34,11 @@
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-escapes": "^7.3.0",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cli-spinners": "^2.9.2",
|
||||
"clipboardy": "^5.0.0",
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^8.0.3",
|
||||
@@ -46,7 +47,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink": "npm:@jrichman/ink@6.4.10",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
@@ -54,8 +55,8 @@
|
||||
"mnemonist": "^0.40.3",
|
||||
"open": "^10.1.2",
|
||||
"prompts": "^2.4.2",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"react": "^19.2.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"string-width": "^8.1.0",
|
||||
@@ -64,29 +65,21 @@
|
||||
"tar": "^7.5.2",
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"pretty-format": "^30.0.2",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
|
||||
@@ -71,6 +71,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
extensionManager,
|
||||
name,
|
||||
setting,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
@@ -79,6 +80,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
await configureExtension(
|
||||
extensionManager,
|
||||
name,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
@@ -86,6 +88,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
else {
|
||||
await configureAllExtensions(
|
||||
extensionManager,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,9 @@ export const disableCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleDisable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -105,7 +105,9 @@ export const enableCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleEnable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -99,10 +99,15 @@ export const installCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleInstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
source: argv['source'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ref: argv['ref'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
autoUpdate: argv['auto-update'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
allowPreRelease: argv['pre-release'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -79,7 +79,9 @@ export const linkCommand: CommandModule = {
|
||||
.check((_) => true),
|
||||
handler: async (argv) => {
|
||||
await handleLink({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: argv['path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -62,6 +62,7 @@ export const listCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleList({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
outputFormat: argv['output-format'] as 'text' | 'json',
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -98,7 +98,9 @@ export const newCommand: CommandModule = {
|
||||
},
|
||||
handler: async (args) => {
|
||||
await handleNew({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: args['path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
template: args['template'] as string | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -71,6 +71,7 @@ export const uninstallCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleUninstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
names: argv['names'] as string[],
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -155,7 +155,9 @@ export const updateCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleUpdate({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
all: argv['all'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -100,6 +100,7 @@ export const validateCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (args) => {
|
||||
await handleValidate({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: args['path'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -70,6 +70,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
|
||||
return claudeHook;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const hook = claudeHook as Record<string, unknown>;
|
||||
const migrated: Record<string, unknown> = {};
|
||||
|
||||
@@ -107,10 +108,12 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
|
||||
return {};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = claudeConfig as Record<string, unknown>;
|
||||
const geminiHooks: Record<string, unknown> = {};
|
||||
|
||||
// Check if there's a hooks section
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const hooksSection = config['hooks'] as Record<string, unknown> | undefined;
|
||||
if (!hooksSection || typeof hooksSection !== 'object') {
|
||||
return {};
|
||||
@@ -130,6 +133,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
|
||||
return def;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const definition = def as Record<string, unknown>;
|
||||
const migratedDef: Record<string, unknown> = {};
|
||||
|
||||
@@ -179,6 +183,7 @@ export async function handleMigrateFromClaude() {
|
||||
sourceFile = claudeLocalSettingsPath;
|
||||
try {
|
||||
const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
unknown
|
||||
@@ -192,6 +197,7 @@ export async function handleMigrateFromClaude() {
|
||||
sourceFile = claudeSettingsPath;
|
||||
try {
|
||||
const content = fs.readFileSync(claudeSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
unknown
|
||||
@@ -259,6 +265,7 @@ export const migrateCommand: CommandModule = {
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const args = argv as unknown as MigrateArgs;
|
||||
if (args.fromClaude) {
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
@@ -219,24 +219,38 @@ export const addCommand: CommandModule = {
|
||||
.middleware((argv) => {
|
||||
// Handle -- separator args as server args if present
|
||||
if (argv['--']) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const existingArgs = (argv['args'] as Array<string | number>) || [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['args'] = [...existingArgs, ...(argv['--'] as string[])];
|
||||
}
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await addMcpServer(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['commandOrUrl'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['args'] as Array<string | number>,
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
transport: argv['transport'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
env: argv['env'] as string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
header: argv['header'] as string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
timeout: argv['timeout'] as number | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
trust: argv['trust'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
description: argv['description'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
includeTools: argv['includeTools'] as string[] | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
excludeTools: argv['excludeTools'] as string[] | undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -55,7 +55,9 @@ export const removeCommand: CommandModule = {
|
||||
choices: ['user', 'project'],
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
await removeMcpServer(argv['name'] as string, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -53,6 +53,7 @@ export const disableCommand: CommandModule = {
|
||||
? SettingScope.Workspace
|
||||
: SettingScope.User;
|
||||
await handleDisable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
scope,
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ export const enableCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleEnable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -102,9 +102,13 @@ export const installCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleInstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
source: argv['source'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: argv['path'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -84,8 +84,11 @@ export const linkCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleLink({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: argv['path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function handleList(args: { all?: boolean }) {
|
||||
const config = await loadCliConfig(
|
||||
settings.merged,
|
||||
'skills-list-session',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
{
|
||||
debug: false,
|
||||
} as Partial<CliArgs> as CliArgs,
|
||||
@@ -72,6 +73,7 @@ export const listCommand: CommandModule = {
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
await handleList({ all: argv['all'] as boolean });
|
||||
await exitCli();
|
||||
},
|
||||
|
||||
@@ -64,7 +64,9 @@ export const uninstallCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleUninstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -141,6 +141,22 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
|
||||
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
|
||||
})),
|
||||
isHeadlessMode: vi.fn((opts) => {
|
||||
if (process.env['VITEST'] === 'true') {
|
||||
return (
|
||||
!!opts?.prompt ||
|
||||
(!!process.stdin && !process.stdin.isTTY) ||
|
||||
(!!process.stdout && !process.stdout.isTTY)
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!opts?.prompt ||
|
||||
process.env['CI'] === 'true' ||
|
||||
process.env['GITHUB_ACTIONS'] === 'true' ||
|
||||
(!!process.stdin && !process.stdin.isTTY) ||
|
||||
(!!process.stdout && !process.stdout.isTTY)
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -154,6 +170,8 @@ vi.mock('./extension-manager.js', () => {
|
||||
// Global setup to ensure clean environment for all tests in this file
|
||||
const originalArgv = process.argv;
|
||||
const originalGeminiModel = process.env['GEMINI_MODEL'];
|
||||
const originalStdoutIsTTY = process.stdout.isTTY;
|
||||
const originalStdinIsTTY = process.stdin.isTTY;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env['GEMINI_MODEL'];
|
||||
@@ -162,6 +180,18 @@ beforeEach(() => {
|
||||
ExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
// Default to interactive mode for tests unless otherwise specified
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -171,6 +201,16 @@ afterEach(() => {
|
||||
} else {
|
||||
delete process.env['GEMINI_MODEL'];
|
||||
}
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: originalStdoutIsTTY,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalStdinIsTTY,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
@@ -249,6 +289,16 @@ describe('parseArguments', () => {
|
||||
});
|
||||
|
||||
describe('positional arguments and @commands', () => {
|
||||
beforeEach(() => {
|
||||
// Default to headless mode for these tests as they mostly expect one-shot behavior
|
||||
process.stdin.isTTY = false;
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
@@ -379,8 +429,12 @@ describe('parseArguments', () => {
|
||||
);
|
||||
|
||||
it('should include a startup message when converting positional query to interactive prompt', async () => {
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
process.stdin.isTTY = true;
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
process.argv = ['node', 'script.js', 'hello'];
|
||||
|
||||
try {
|
||||
@@ -389,7 +443,7 @@ describe('parseArguments', () => {
|
||||
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
|
||||
);
|
||||
} finally {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
// beforeEach handles resetting
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1732,14 +1786,29 @@ describe('loadCliConfig model selection', () => {
|
||||
});
|
||||
|
||||
describe('loadCliConfig folderTrust', () => {
|
||||
let originalVitest: string | undefined;
|
||||
let originalIntegrationTest: string | undefined;
|
||||
|
||||
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([]);
|
||||
|
||||
originalVitest = process.env['VITEST'];
|
||||
originalIntegrationTest = process.env['GEMINI_CLI_INTEGRATION_TEST'];
|
||||
delete process.env['VITEST'];
|
||||
delete process.env['GEMINI_CLI_INTEGRATION_TEST'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVitest !== undefined) {
|
||||
process.env['VITEST'] = originalVitest;
|
||||
}
|
||||
if (originalIntegrationTest !== undefined) {
|
||||
process.env['GEMINI_CLI_INTEGRATION_TEST'] = originalIntegrationTest;
|
||||
}
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -1798,10 +1867,11 @@ describe('loadCliConfig with includeDirectories', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should combine and resolve paths from settings and CLI arguments', async () => {
|
||||
it.skip('should combine and resolve paths from settings and CLI arguments', async () => {
|
||||
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
|
||||
process.argv = [
|
||||
'node',
|
||||
|
||||
'script.js',
|
||||
'--include-directories',
|
||||
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
|
||||
@@ -2555,7 +2625,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should use approvalMode from settings when no CLI flags are set', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'auto_edit' },
|
||||
general: { defaultApprovalMode: 'auto_edit' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
@@ -2567,7 +2637,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should prioritize --approval-mode flag over settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'default' },
|
||||
general: { defaultApprovalMode: 'default' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
@@ -2579,7 +2649,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should prioritize --yolo flag over settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--yolo'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'auto_edit' },
|
||||
general: { defaultApprovalMode: 'auto_edit' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
@@ -2589,7 +2659,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'plan' },
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
@@ -2600,7 +2670,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'plan' },
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
experimental: { plan: false },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
@@ -2779,6 +2849,16 @@ describe('Output format', () => {
|
||||
describe('parseArguments with positional prompt', () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Default to headless mode for these tests as they mostly expect one-shot behavior
|
||||
process.stdin.isTTY = false;
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
@@ -32,17 +32,17 @@ import {
|
||||
ASK_USER_TOOL_NAME,
|
||||
getVersion,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
type HierarchicalMemory,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
getAdminErrorMessage,
|
||||
isHeadlessMode,
|
||||
Config,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HookDefinition,
|
||||
HookEventName,
|
||||
OutputFormat,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -280,6 +280,7 @@ export async function parseArguments(
|
||||
.check((argv) => {
|
||||
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
|
||||
// This guard safely checks if any positional argument was provided.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const query = argv['query'] as string | string[] | undefined;
|
||||
const hasPositionalQuery = Array.isArray(query)
|
||||
? query.length > 0
|
||||
@@ -297,6 +298,7 @@ export async function parseArguments(
|
||||
if (
|
||||
argv['outputFormat'] &&
|
||||
!['text', 'json', 'stream-json'].includes(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['outputFormat'] as string,
|
||||
)
|
||||
) {
|
||||
@@ -345,6 +347,7 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Normalize query args: handle both quoted "@path file" and unquoted @path file
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const queryArg = (result as { query?: string | string[] | undefined }).query;
|
||||
const q: string | undefined = Array.isArray(queryArg)
|
||||
? queryArg.join(' ')
|
||||
@@ -352,7 +355,7 @@ export async function parseArguments(
|
||||
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
if (q && !result['prompt']) {
|
||||
if (process.stdin.isTTY) {
|
||||
if (!isHeadlessMode()) {
|
||||
startupMessages.push(
|
||||
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
|
||||
);
|
||||
@@ -368,6 +371,7 @@ export async function parseArguments(
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result as unknown as CliArgs;
|
||||
}
|
||||
|
||||
@@ -436,7 +440,11 @@ export async function loadCliConfig(
|
||||
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
const folderTrust =
|
||||
process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true' ||
|
||||
process.env['VITEST'] === 'true'
|
||||
? false
|
||||
: (settings.security?.folderTrust?.enabled ?? false);
|
||||
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false;
|
||||
|
||||
// Set the context filename in the server's memoryTool module BEFORE loading memory
|
||||
@@ -472,6 +480,7 @@ export async function loadCliConfig(
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
@@ -479,7 +488,7 @@ export async function loadCliConfig(
|
||||
|
||||
const experimentalJitContext = settings.experimental?.jitContext ?? false;
|
||||
|
||||
let memoryContent = '';
|
||||
let memoryContent: string | HierarchicalMemory = '';
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
|
||||
@@ -510,8 +519,8 @@ export async function loadCliConfig(
|
||||
const rawApprovalMode =
|
||||
argv.approvalMode ||
|
||||
(argv.yolo ? 'yolo' : undefined) ||
|
||||
((settings.tools?.approvalMode as string) !== 'yolo'
|
||||
? settings.tools.approvalMode
|
||||
((settings.general?.defaultApprovalMode as string) !== 'yolo'
|
||||
? settings.general?.defaultApprovalMode
|
||||
: undefined);
|
||||
|
||||
if (rawApprovalMode) {
|
||||
@@ -575,6 +584,7 @@ export async function loadCliConfig(
|
||||
let telemetrySettings;
|
||||
try {
|
||||
telemetrySettings = await resolveTelemetrySettings({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
env: process.env as unknown as Record<string, string | undefined>,
|
||||
settings: settings.telemetry,
|
||||
});
|
||||
@@ -592,7 +602,9 @@ export async function loadCliConfig(
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.experimentalAcp ||
|
||||
(process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand);
|
||||
(!isHeadlessMode({ prompt: argv.prompt }) &&
|
||||
!argv.query &&
|
||||
!argv.isCommand);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
@@ -784,6 +796,8 @@ export async function loadCliConfig(
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
sessionLearnings: settings.general?.sessionLearnings?.enabled,
|
||||
sessionLearningsOutputPath: settings.general?.sessionLearnings?.outputPath,
|
||||
ideMode,
|
||||
disableLoopDetection: settings.model?.disableLoopDetection,
|
||||
compressionThreshold: settings.model?.compressionThreshold,
|
||||
@@ -802,6 +816,7 @@ export async function loadCliConfig(
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
output: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
},
|
||||
fakeResponses: argv.fakeResponses,
|
||||
|
||||
@@ -16,10 +16,11 @@ import {
|
||||
vi,
|
||||
afterEach,
|
||||
} from 'vitest';
|
||||
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
|
||||
import { GEMINI_DIR, type Config } from '@google/gemini-cli-core';
|
||||
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
|
||||
import { createTestMergedSettings, SettingScope } from './settings.js';
|
||||
|
||||
describe('ExtensionManager theme loading', () => {
|
||||
@@ -29,7 +30,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
tempHomeDir = await fs.promises.mkdtemp(
|
||||
path.join(fs.realpathSync('/tmp'), 'gemini-cli-test-'),
|
||||
path.join(tmpdir(), 'gemini-cli-test-'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -85,6 +86,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const mockConfig = {
|
||||
getEnableExtensionReloading: () => false,
|
||||
getMcpClientManager: () => ({
|
||||
@@ -170,6 +172,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const mockConfig = {
|
||||
getWorkingDir: () => tempHomeDir,
|
||||
shouldLoadMemoryFromIncludeDirectories: () => false,
|
||||
|
||||
@@ -188,7 +188,10 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
)
|
||||
) {
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER);
|
||||
await trustedFolders.setValue(
|
||||
this.workspaceDir,
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`,
|
||||
@@ -727,6 +730,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
if (Object.keys(hookEnv).length > 0) {
|
||||
for (const eventName of Object.keys(hooks)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const eventHooks = hooks[eventName as HookEventName];
|
||||
if (eventHooks) {
|
||||
for (const definition of eventHooks) {
|
||||
@@ -823,13 +827,16 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
try {
|
||||
const configContent = await fs.promises.readFile(configFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
|
||||
if (!rawConfig.name || !rawConfig.version) {
|
||||
throw new Error(
|
||||
`Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = recursivelyHydrateStrings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
rawConfig as unknown as JsonObject,
|
||||
{
|
||||
extensionPath: extensionDir,
|
||||
@@ -875,6 +882,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
// Hydrate variables in the hooks configuration
|
||||
const hydratedHooks = recursivelyHydrateStrings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
rawHooks.hooks as unknown as JsonObject,
|
||||
{
|
||||
...context,
|
||||
@@ -885,6 +893,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
return hydratedHooks;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined; // File not found is not an error here.
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function loadInstallMetadata(
|
||||
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
|
||||
try {
|
||||
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
|
||||
return metadata;
|
||||
} catch (_e) {
|
||||
|
||||
@@ -105,6 +105,7 @@ export class ExtensionRegistryClient {
|
||||
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so that subsequent calls can try again
|
||||
|
||||
@@ -5,23 +5,20 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs';
|
||||
import { getMissingSettings } from './extensionSettings.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
import {
|
||||
KeychainTokenStorage,
|
||||
debugLogger,
|
||||
type ExtensionInstallMetadata,
|
||||
type GeminiCLIExtension,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||
import { ExtensionManager } from '../extension-manager.js';
|
||||
import { createTestMergedSettings } from '../settings.js';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = await importOriginal<any>();
|
||||
@@ -29,11 +26,23 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
...actual,
|
||||
default: {
|
||||
...actual.default,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
|
||||
existsSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
},
|
||||
existsSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
promises: {
|
||||
...actual.promises,
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
cp: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -49,183 +58,101 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
log: vi.fn(),
|
||||
},
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(), // Mock emitFeedback
|
||||
emitFeedback: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emitConsoleLog: vi.fn(),
|
||||
},
|
||||
loadSkillsFromDir: vi.fn().mockResolvedValue([]),
|
||||
loadAgentsFromDirectory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ agents: [], errors: [] }),
|
||||
logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined),
|
||||
logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined),
|
||||
logExtensionUninstall: vi.fn().mockResolvedValue(undefined),
|
||||
logExtensionEnable: vi.fn().mockResolvedValue(undefined),
|
||||
logExtensionDisable: vi.fn().mockResolvedValue(undefined),
|
||||
Config: vi.fn().mockImplementation(() => ({
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(true),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock os.homedir because ExtensionStorage uses it
|
||||
vi.mock('./consent.js', () => ({
|
||||
maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./extensionSettings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./extensionSettings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getEnvContents: vi.fn().mockResolvedValue({}),
|
||||
getMissingSettings: vi.fn(), // We will mock this implementation per test
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow
|
||||
loadTrustedFolders: vi.fn().mockReturnValue({
|
||||
setValue: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' },
|
||||
}));
|
||||
|
||||
// Mock ExtensionStorage to avoid real FS paths
|
||||
vi.mock('./storage.js', () => ({
|
||||
ExtensionStorage: class {
|
||||
constructor(public name: string) {}
|
||||
getExtensionDir() {
|
||||
return `/mock/extensions/${this.name}`;
|
||||
}
|
||||
static getUserExtensionsDir() {
|
||||
return '/mock/extensions';
|
||||
}
|
||||
static createTmpDir() {
|
||||
return Promise.resolve('/mock/tmp');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
const mockedOs = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: vi.fn(),
|
||||
homedir: vi.fn().mockReturnValue('/mock/home'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('extensionUpdates', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let extensionDir: string;
|
||||
let mockKeychainData: Record<string, Record<string, string>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockKeychainData = {};
|
||||
// Default fs mocks
|
||||
vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
|
||||
|
||||
// Mock Keychain
|
||||
vi.mocked(KeychainTokenStorage).mockImplementation(
|
||||
(serviceName: string) => {
|
||||
if (!mockKeychainData[serviceName]) {
|
||||
mockKeychainData[serviceName] = {};
|
||||
}
|
||||
const keychainData = mockKeychainData[serviceName];
|
||||
return {
|
||||
getSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (key: string) => keychainData[key] || null,
|
||||
),
|
||||
setSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(async (key: string, value: string) => {
|
||||
keychainData[key] = value;
|
||||
}),
|
||||
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
|
||||
delete keychainData[key];
|
||||
}),
|
||||
listSecrets: vi
|
||||
.fn()
|
||||
.mockImplementation(async () => Object.keys(keychainData)),
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
} as unknown as KeychainTokenStorage;
|
||||
},
|
||||
);
|
||||
// Allow directories to exist by default to satisfy Config/WorkspaceContext checks
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
||||
|
||||
// Setup Temp Dirs
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
|
||||
|
||||
// Mock ExtensionStorage to rely on our temp extension dir
|
||||
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
|
||||
extensionDir,
|
||||
);
|
||||
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
|
||||
// getEnvFilePath in extensionSettings.ts:
|
||||
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
|
||||
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
|
||||
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
tempWorkspaceDir = '/mock/workspace';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getMissingSettings', () => {
|
||||
it('should return empty list if all settings are present', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
|
||||
],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
// Setup User Env
|
||||
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
|
||||
fs.writeFileSync(userEnvPath, 'VAR1=val1');
|
||||
|
||||
// Setup Keychain
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext ${extensionId}`,
|
||||
);
|
||||
await userKeychain.setSecret('VAR2', 'val2');
|
||||
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should identify missing non-sensitive settings', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0].name).toBe('s1');
|
||||
});
|
||||
|
||||
it('should identify missing sensitive settings', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
|
||||
],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0].name).toBe('s2');
|
||||
});
|
||||
|
||||
it('should respect settings present in workspace', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
// Setup Workspace Env
|
||||
const workspaceEnvPath = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
);
|
||||
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
|
||||
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExtensionManager integration', () => {
|
||||
it('should warn about missing settings after update', async () => {
|
||||
// Mock ExtensionManager methods to avoid FS/Network usage
|
||||
// 1. Setup Data
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.1.0',
|
||||
@@ -239,31 +166,30 @@ describe('extensionUpdates', () => {
|
||||
};
|
||||
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: extensionDir,
|
||||
source: '/mock/source',
|
||||
type: 'local',
|
||||
autoUpdate: true,
|
||||
};
|
||||
|
||||
// 2. Setup Manager
|
||||
const manager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null, // Simulate non-interactive
|
||||
requestSetting: null,
|
||||
});
|
||||
|
||||
// Mock methods called by installOrUpdateExtension
|
||||
// 3. Mock Internal Manager Methods
|
||||
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
|
||||
vi.spyOn(manager, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata,
|
||||
path: extensionDir,
|
||||
// Mocks for other required props
|
||||
path: '/mock/extensions/test-ext',
|
||||
contextFiles: [],
|
||||
mcpServers: {},
|
||||
hooks: undefined,
|
||||
@@ -275,23 +201,28 @@ describe('extensionUpdates', () => {
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
|
||||
// Mock loadExtension to return something so the method doesn't crash at the end
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue(
|
||||
{} as unknown as GeminiCLIExtension,
|
||||
);
|
||||
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
|
||||
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({
|
||||
name: 'test-ext',
|
||||
version: '1.1.0',
|
||||
} as GeminiCLIExtension);
|
||||
|
||||
// Mock fs.promises for the operations inside installOrUpdateExtension
|
||||
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
|
||||
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
|
||||
try {
|
||||
await manager.installOrUpdateExtension(installMetadata, previousConfig);
|
||||
} catch (_) {
|
||||
// Ignore errors from copyExtension or others, we just want to verify the warning
|
||||
}
|
||||
// 4. Mock External Helpers
|
||||
// This is the key fix: we explicitly mock `getMissingSettings` to return
|
||||
// the result we expect, avoiding any real FS or logic execution during the update.
|
||||
vi.mocked(getMissingSettings).mockResolvedValue([
|
||||
{
|
||||
name: 's1',
|
||||
description: 'd1',
|
||||
envVar: 'VAR1',
|
||||
},
|
||||
]);
|
||||
|
||||
// 5. Execute
|
||||
await manager.installOrUpdateExtension(installMetadata, previousConfig);
|
||||
|
||||
// 6. Assert
|
||||
expect(debugLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Extension "test-ext" has missing settings: s1',
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function fetchJson<T>(
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks).toString();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
resolve(JSON.parse(data) as T);
|
||||
});
|
||||
})
|
||||
|
||||
@@ -52,9 +52,11 @@ export function recursivelyHydrateStrings<T>(
|
||||
values: VariableContext,
|
||||
): T {
|
||||
if (typeof obj === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return hydrateString(obj, values) as unknown as T;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return obj.map((item) =>
|
||||
recursivelyHydrateStrings(item, values),
|
||||
) as unknown as T;
|
||||
@@ -64,11 +66,13 @@ export function recursivelyHydrateStrings<T>(
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
newObj[key] = recursivelyHydrateStrings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(obj as Record<string, unknown>)[key],
|
||||
values,
|
||||
);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return newObj as T;
|
||||
}
|
||||
return obj;
|
||||
|
||||
@@ -91,6 +91,7 @@ export enum Command {
|
||||
TOGGLE_YOLO = 'app.toggleYolo',
|
||||
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
|
||||
SHOW_MORE_LINES = 'app.showMoreLines',
|
||||
EXPAND_PASTE = 'app.expandPaste',
|
||||
FOCUS_SHELL_INPUT = 'app.focusShellInput',
|
||||
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
|
||||
CLEAR_SCREEN = 'app.clearScreen',
|
||||
@@ -289,6 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'o', ctrl: true },
|
||||
{ key: 's', ctrl: true },
|
||||
],
|
||||
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
@@ -399,6 +401,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.TOGGLE_YOLO,
|
||||
Command.CYCLE_APPROVAL_MODE,
|
||||
Command.SHOW_MORE_LINES,
|
||||
Command.EXPAND_PASTE,
|
||||
Command.TOGGLE_BACKGROUND_SHELL,
|
||||
Command.TOGGLE_BACKGROUND_SHELL_LIST,
|
||||
Command.KILL_BACKGROUND_SHELL,
|
||||
@@ -499,6 +502,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||
[Command.EXPAND_PASTE]:
|
||||
'Expand or collapse a paste placeholder when cursor is over placeholder.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]:
|
||||
'Confirm selection in background shell list.',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
|
||||
|
||||
@@ -358,6 +358,7 @@ export class McpServerEnablementManager {
|
||||
private async readConfig(): Promise<McpServerEnablementConfig> {
|
||||
try {
|
||||
const content = await fs.readFile(this.configFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return JSON.parse(content) as McpServerEnablementConfig;
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -23,6 +23,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
|
||||
}
|
||||
|
||||
if (def.type === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if (def.enum) return z.enum(def.enum as [string, ...string[]]);
|
||||
return z.string();
|
||||
}
|
||||
@@ -40,7 +41,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
|
||||
let schema;
|
||||
if (def.properties) {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
for (const [key, propDef] of Object.entries(def.properties) as any) {
|
||||
let propSchema = buildZodSchemaFromJsonSchema(propDef);
|
||||
if (
|
||||
@@ -86,9 +87,11 @@ function buildEnumSchema(
|
||||
}
|
||||
const values = options.map((opt) => opt.value);
|
||||
if (values.every((v) => typeof v === 'string')) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return z.enum(values as [string, ...string[]]);
|
||||
} else if (values.every((v) => typeof v === 'number')) {
|
||||
return z.union(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
values.map((v) => z.literal(v)) as [
|
||||
z.ZodLiteral<number>,
|
||||
z.ZodLiteral<number>,
|
||||
@@ -97,6 +100,7 @@ function buildEnumSchema(
|
||||
);
|
||||
} else {
|
||||
return z.union(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
values.map((v) => z.literal(v)) as [
|
||||
z.ZodLiteral<unknown>,
|
||||
z.ZodLiteral<unknown>,
|
||||
|
||||
@@ -1936,6 +1936,40 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate tools.approvalMode to general.defaultApprovalMode', () => {
|
||||
const userSettingsContent = {
|
||||
tools: {
|
||||
approvalMode: 'plan',
|
||||
},
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue');
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings, true);
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'general',
|
||||
expect.objectContaining({ defaultApprovalMode: 'plan' }),
|
||||
);
|
||||
|
||||
// Verify removal
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'tools',
|
||||
expect.not.objectContaining({ approvalMode: 'plan' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate all 4 inverted boolean settings', () => {
|
||||
const userSettingsContent = {
|
||||
general: {
|
||||
|
||||
@@ -213,6 +213,7 @@ function setNestedProperty(
|
||||
}
|
||||
const next = current[key];
|
||||
if (typeof next === 'object' && next !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = next as Record<string, unknown>;
|
||||
} else {
|
||||
// This path is invalid, so we stop.
|
||||
@@ -254,6 +255,7 @@ export function mergeSettings(
|
||||
// 3. User Settings
|
||||
// 4. Workspace Settings
|
||||
// 5. System Settings (as overrides)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
schemaDefaults,
|
||||
@@ -274,6 +276,7 @@ export function mergeSettings(
|
||||
export function createTestMergedSettings(
|
||||
overrides: Partial<Settings> = {},
|
||||
): MergedSettings {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
getDefaultsFromSchema(),
|
||||
@@ -355,6 +358,7 @@ export class LoadedSettings {
|
||||
|
||||
// The final admin settings are the defaults overridden by remote settings.
|
||||
// Any admin settings from files are ignored.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
merged.admin = customDeepMerge(
|
||||
(path: string[]) => getMergeStrategyForPath(['admin', ...path]),
|
||||
adminDefaults,
|
||||
@@ -617,6 +621,7 @@ export function loadSettings(
|
||||
return { settings: {} };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const settingsObject = rawSettings as Record<string, unknown>;
|
||||
|
||||
// Validate settings structure with Zod
|
||||
@@ -850,6 +855,7 @@ export function migrateDeprecatedSettings(
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
if (uiSettings) {
|
||||
const newUi = { ...uiSettings };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const accessibilitySettings = newUi['accessibility'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
@@ -880,6 +886,7 @@ export function migrateDeprecatedSettings(
|
||||
| undefined;
|
||||
if (contextSettings) {
|
||||
const newContext = { ...contextSettings };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const fileFilteringSettings = newContext['fileFiltering'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
@@ -904,6 +911,36 @@ export function migrateDeprecatedSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate tools settings
|
||||
const toolsSettings = settings.tools as Record<string, unknown> | undefined;
|
||||
if (toolsSettings) {
|
||||
if (toolsSettings['approvalMode'] !== undefined) {
|
||||
foundDeprecated.push('tools.approvalMode');
|
||||
|
||||
const generalSettings =
|
||||
(settings.general as Record<string, unknown> | undefined) || {};
|
||||
const newGeneral = { ...generalSettings };
|
||||
|
||||
// Only set defaultApprovalMode if it's not already set
|
||||
if (newGeneral['defaultApprovalMode'] === undefined) {
|
||||
newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode'];
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removeDeprecated) {
|
||||
const newTools = { ...toolsSettings };
|
||||
delete newTools['approvalMode'];
|
||||
loadedSettings.setValue(scope, 'tools', newTools);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
const experimentalModified = migrateExperimentalSettings(
|
||||
settings,
|
||||
@@ -1000,6 +1037,7 @@ function migrateExperimentalSettings(
|
||||
...(settings.agents as Record<string, unknown> | undefined),
|
||||
};
|
||||
const agentsOverrides = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
|
||||
};
|
||||
let modified = false;
|
||||
@@ -1011,6 +1049,7 @@ function migrateExperimentalSettings(
|
||||
const old = experimentalSettings[oldKey];
|
||||
if (old) {
|
||||
foundDeprecated?.push(`experimental.${oldKey}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
migrateFn(old as Record<string, unknown>);
|
||||
modified = true;
|
||||
}
|
||||
@@ -1019,6 +1058,7 @@ function migrateExperimentalSettings(
|
||||
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
|
||||
migrateExperimental('codebaseInvestigatorSettings', (old) => {
|
||||
const override = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(agentsOverrides['codebase_investigator'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
@@ -1027,6 +1067,7 @@ function migrateExperimentalSettings(
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
|
||||
const runConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(override['runConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['maxNumTurns'] !== undefined)
|
||||
@@ -1037,16 +1078,19 @@ function migrateExperimentalSettings(
|
||||
|
||||
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
|
||||
const modelConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(override['modelConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
|
||||
if (old['thinkingBudget'] !== undefined) {
|
||||
const generateContentConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(modelConfig['generateContentConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
const thinkingConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(generateContentConfig['thinkingConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
@@ -1064,6 +1108,7 @@ function migrateExperimentalSettings(
|
||||
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
|
||||
migrateExperimental('cliHelpAgentSettings', (old) => {
|
||||
const override = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
|
||||
@@ -186,6 +186,9 @@ describe('SettingsSchema', () => {
|
||||
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -224,7 +227,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().advanced.properties.autoConfigureMemory
|
||||
.showInDialog,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should infer Settings type correctly', () => {
|
||||
@@ -328,6 +331,28 @@ describe('SettingsSchema', () => {
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
|
||||
it('should have showShortcutsHint setting in schema', () => {
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint).toBeDefined();
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.category).toBe(
|
||||
'UI',
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.default).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.requiresRestart,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.description,
|
||||
).toBe('Show the "? for shortcuts" hint above the input.');
|
||||
});
|
||||
|
||||
it('should have enableAgents setting in schema', () => {
|
||||
const setting = getSettingsSchema().experimental.properties.enableAgents;
|
||||
expect(setting).toBeDefined();
|
||||
|
||||
@@ -179,6 +179,33 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable Vim keybindings',
|
||||
showInDialog: true,
|
||||
},
|
||||
defaultApprovalMode: {
|
||||
type: 'enum',
|
||||
label: 'Default Approval Mode',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 'default',
|
||||
description: oneLine`
|
||||
The default approval mode for tool execution.
|
||||
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
|
||||
and 'plan' is read-only mode. 'yolo' is not supported yet.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'auto_edit', label: 'Auto Edit' },
|
||||
{ value: 'plan', label: 'Plan' },
|
||||
],
|
||||
},
|
||||
devtools: {
|
||||
type: 'boolean',
|
||||
label: 'DevTools',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable DevTools inspector on launch.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableAutoUpdate: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Auto Update',
|
||||
@@ -295,6 +322,37 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
description: 'Settings for automatic session cleanup.',
|
||||
},
|
||||
sessionLearnings: {
|
||||
type: 'object',
|
||||
label: 'Session Learnings',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for session learning summaries.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Session Learnings',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically generate a session-learnings.md file when the session ends.',
|
||||
showInDialog: true,
|
||||
},
|
||||
outputPath: {
|
||||
type: 'string',
|
||||
label: 'Output Path',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Directory where session-learnings files should be saved. Defaults to project root.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
@@ -383,6 +441,19 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide the window title bar',
|
||||
showInDialog: true,
|
||||
},
|
||||
inlineThinkingMode: {
|
||||
type: 'enum',
|
||||
label: 'Inline Thinking',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 'off',
|
||||
description: 'Display model thinking inline: off or full.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'full', label: 'Full' },
|
||||
],
|
||||
},
|
||||
showStatusInTitle: {
|
||||
type: 'boolean',
|
||||
label: 'Show Thoughts in Title',
|
||||
@@ -422,6 +493,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showShortcutsHint: {
|
||||
type: 'boolean',
|
||||
label: 'Show Shortcuts Hint',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Show the "? for shortcuts" hint above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
@@ -1061,24 +1141,7 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: 'default',
|
||||
description: oneLine`
|
||||
The default approval mode for tool execution.
|
||||
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
|
||||
and 'plan' is read-only mode. 'yolo' is not supported yet.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'auto_edit', label: 'Auto Edit' },
|
||||
{ value: 'plan', label: 'Plan' },
|
||||
],
|
||||
},
|
||||
|
||||
core: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
@@ -1390,7 +1453,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Automatically configure Node.js memory limits',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
dnsResolutionOrder: {
|
||||
type: 'string',
|
||||
@@ -1439,7 +1502,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: true,
|
||||
ignoreInDocs: false,
|
||||
default: {},
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
@@ -1450,9 +1513,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
toolProtectionThreshold: {
|
||||
type: 'number',
|
||||
@@ -1514,6 +1577,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable requesting and fetching of extension settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionRegistry: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Registry Explore UI',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable extension registry explore UI.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionReloading: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Reloading',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import {
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
@@ -13,10 +15,14 @@ import {
|
||||
ideContextStore,
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
isHeadlessMode,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
const { promises: fsPromises } = fs;
|
||||
|
||||
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
|
||||
|
||||
export function getUserSettingsDir(): string {
|
||||
@@ -41,6 +47,7 @@ export function isTrustLevel(
|
||||
): value is TrustLevel {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.values(TrustLevel).includes(value as TrustLevel)
|
||||
);
|
||||
}
|
||||
@@ -67,6 +74,13 @@ export interface TrustResult {
|
||||
|
||||
const realPathCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Parses the trusted folders JSON content, stripping comments.
|
||||
*/
|
||||
function parseTrustedFoldersJson(content: string): unknown {
|
||||
return JSON.parse(stripJsonComments(content));
|
||||
}
|
||||
|
||||
/**
|
||||
* FOR TESTING PURPOSES ONLY.
|
||||
* Clears the real path cache.
|
||||
@@ -150,19 +164,68 @@ export class LoadedTrustedFolders {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setValue(path: string, trustLevel: TrustLevel): void {
|
||||
const originalTrustLevel = this.user.config[path];
|
||||
this.user.config[path] = trustLevel;
|
||||
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
|
||||
if (this.errors.length > 0) {
|
||||
const errorMessages = this.errors.map(
|
||||
(error) => `Error in ${error.path}: ${error.message}`,
|
||||
);
|
||||
throw new FatalConfigError(
|
||||
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const dirPath = path.dirname(this.user.path);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
await fsPromises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
// lockfile requires the file to exist
|
||||
if (!fs.existsSync(this.user.path)) {
|
||||
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
const release = await lock(this.user.path, {
|
||||
retries: {
|
||||
retries: 10,
|
||||
minTimeout: 100,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
saveTrustedFolders(this.user);
|
||||
} catch (e) {
|
||||
// Revert the in-memory change if the save failed.
|
||||
if (originalTrustLevel === undefined) {
|
||||
delete this.user.config[path];
|
||||
} else {
|
||||
this.user.config[path] = originalTrustLevel;
|
||||
// Re-read the file to handle concurrent updates
|
||||
const content = await fsPromises.readFile(this.user.path, 'utf-8');
|
||||
let config: Record<string, TrustLevel>;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
|
||||
error,
|
||||
);
|
||||
config = {};
|
||||
}
|
||||
throw e;
|
||||
|
||||
const originalTrustLevel = config[folderPath];
|
||||
config[folderPath] = trustLevel;
|
||||
this.user.config[folderPath] = trustLevel;
|
||||
|
||||
try {
|
||||
saveTrustedFolders({ ...this.user, config });
|
||||
} catch (e) {
|
||||
// Revert the in-memory change if the save failed.
|
||||
if (originalTrustLevel === undefined) {
|
||||
delete this.user.config[folderPath];
|
||||
} else {
|
||||
this.user.config[folderPath] = originalTrustLevel;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,10 +253,8 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
try {
|
||||
if (fs.existsSync(userPath)) {
|
||||
const content = fs.readFileSync(userPath, 'utf-8');
|
||||
const parsed = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
@@ -241,11 +302,26 @@ export function saveTrustedFolders(
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
trustedFoldersFile.path,
|
||||
JSON.stringify(trustedFoldersFile.config, null, 2),
|
||||
{ encoding: 'utf-8', mode: 0o600 },
|
||||
);
|
||||
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
|
||||
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tempPath, content, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(tempPath, trustedFoldersFile.path);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it was created but rename failed
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is folder trust feature enabled per the current applied settings */
|
||||
@@ -282,6 +358,10 @@ export function isWorkspaceTrusted(
|
||||
workspaceDir: string = process.cwd(),
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
): TrustResult {
|
||||
if (isHeadlessMode()) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
if (!isFolderTrustEnabled(settings)) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
@@ -86,9 +86,11 @@ export function defer<T = object, U = object>(
|
||||
...commandModule,
|
||||
handler: (argv: ArgumentsCamelCase<U>) => {
|
||||
setDeferredCommand({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
handler: commandModule.handler as (
|
||||
argv: ArgumentsCamelCase,
|
||||
) => void | Promise<void>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv: argv as unknown as ArgumentsCamelCase,
|
||||
commandName: parentCommandName || 'unknown',
|
||||
});
|
||||
|
||||
@@ -238,18 +238,15 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
}));
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
let originalIsTTY: boolean | undefined;
|
||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||
[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Store and clear sandbox-related env variables to ensure a consistent test environment
|
||||
originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX'];
|
||||
originalEnvSandbox = process.env['SANDBOX'];
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
delete process.env['SANDBOX'];
|
||||
vi.stubEnv('GEMINI_SANDBOX', '');
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
|
||||
initialUnhandledRejectionListeners =
|
||||
process.listeners('unhandledRejection');
|
||||
@@ -260,18 +257,6 @@ describe('gemini.tsx main function', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env variables
|
||||
if (originalEnvGeminiSandbox !== undefined) {
|
||||
process.env['GEMINI_SANDBOX'] = originalEnvGeminiSandbox;
|
||||
} else {
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
}
|
||||
if (originalEnvSandbox !== undefined) {
|
||||
process.env['SANDBOX'] = originalEnvSandbox;
|
||||
} else {
|
||||
delete process.env['SANDBOX'];
|
||||
}
|
||||
|
||||
const currentListeners = process.listeners('unhandledRejection');
|
||||
currentListeners.forEach((listener) => {
|
||||
if (!initialUnhandledRejectionListeners.includes(listener)) {
|
||||
@@ -282,6 +267,7 @@ describe('gemini.tsx main function', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).isTTY = originalIsTTY;
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1209,7 +1195,12 @@ describe('startInteractiveUI', () => {
|
||||
registerTelemetryConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1308,7 +1299,7 @@ describe('startInteractiveUI', () => {
|
||||
|
||||
// Verify all startup tasks were called
|
||||
expect(getVersion).toHaveBeenCalledTimes(1);
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(3);
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Verify cleanup handler is registered with unmount function
|
||||
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
|
||||
|
||||
+33
-18
@@ -57,8 +57,8 @@ import {
|
||||
writeToStderr,
|
||||
disableMouseEvents,
|
||||
enableMouseEvents,
|
||||
enterAlternateScreen,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
ExitCodes,
|
||||
@@ -89,6 +89,7 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { useTerminalSize } from './ui/hooks/useTerminalSize.js';
|
||||
import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
@@ -214,9 +215,13 @@ export async function startInteractiveUI(
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
useKittyKeyboardProtocol();
|
||||
const { columns, rows } = useTerminalSize();
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider
|
||||
@@ -234,6 +239,7 @@ export async function startInteractiveUI(
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
key={`${columns}-${rows}`}
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
@@ -250,6 +256,17 @@ export async function startInteractiveUI(
|
||||
);
|
||||
};
|
||||
|
||||
if (isShpool) {
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
// shpool is a persistence tool that restores terminal state by replaying it.
|
||||
// This delay gives shpool time to finish its restoration replay and send
|
||||
// the actual terminal size (often via an immediate SIGWINCH) before we
|
||||
// render the first TUI frame. Without this, the first frame may be
|
||||
// garbled or rendered at an incorrect size, which disabling incremental
|
||||
// rendering alone cannot fix for the initial frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -273,10 +290,19 @@ export async function startInteractiveUI(
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false && useAlternateBuffer,
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
},
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
@@ -518,11 +544,11 @@ export async function main() {
|
||||
|
||||
adminControlsListner.setConfig(config);
|
||||
|
||||
if (config.isInteractive() && config.getDebugMode()) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
if (config.isInteractive() && settings.merged.general.devtools) {
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
registerActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
// Register config for telemetry shutdown
|
||||
@@ -590,18 +616,6 @@ export async function main() {
|
||||
// input showing up in the output.
|
||||
process.stdin.setRawMode(true);
|
||||
|
||||
if (
|
||||
shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(settings),
|
||||
config.getScreenReader(),
|
||||
)
|
||||
) {
|
||||
enterAlternateScreen();
|
||||
disableLineWrapping();
|
||||
|
||||
// Ink will cleanup so there is no need for us to manually cleanup.
|
||||
}
|
||||
|
||||
// This cleanup isn't strictly needed but may help in certain situations.
|
||||
process.on('SIGTERM', () => {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
@@ -819,6 +833,7 @@ function setupAdminControlsListener() {
|
||||
let config: Config | undefined;
|
||||
|
||||
const messageHandler = (msg: unknown) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = msg as {
|
||||
type?: string;
|
||||
settings?: AdminControlsSettings;
|
||||
|
||||
@@ -38,9 +38,9 @@ import type { LoadedSettings } from './config/settings.js';
|
||||
// Mock core modules
|
||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||
|
||||
const mockRegisterActivityLogger = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./utils/activityLogger.js', () => ({
|
||||
registerActivityLogger: mockRegisterActivityLogger,
|
||||
const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./utils/devtoolsService.js', () => ({
|
||||
setupInitialActivityLogger: mockSetupInitialActivityLogger,
|
||||
}));
|
||||
|
||||
const mockCoreEvents = vi.hoisted(() => ({
|
||||
@@ -286,7 +286,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-activity-logger',
|
||||
});
|
||||
|
||||
expect(mockRegisterActivityLogger).toHaveBeenCalledWith(mockConfig);
|
||||
expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-activity-logger-off',
|
||||
});
|
||||
|
||||
expect(mockRegisterActivityLogger).not.toHaveBeenCalled();
|
||||
expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
|
||||
@@ -72,10 +72,10 @@ export async function runNonInteractive({
|
||||
});
|
||||
|
||||
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
registerActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
@@ -250,6 +250,7 @@ export async function runNonInteractive({
|
||||
// Otherwise, slashCommandResult falls through to the default prompt
|
||||
// handling.
|
||||
if (slashCommandResult) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = slashCommandResult as Part[];
|
||||
}
|
||||
}
|
||||
@@ -271,6 +272,7 @@ export async function runNonInteractive({
|
||||
error || 'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = processedQuery as Part[];
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
} catch (error) {
|
||||
if (
|
||||
!signal.aborted &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(error as { code?: string })?.code !== 'ENOENT'
|
||||
) {
|
||||
coreEvents.emitFeedback(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// The waitFor from vitest doesn't properly wrap in act(), so we have to
|
||||
// implement our own like the one in @testing-library/react
|
||||
@@ -13,7 +14,7 @@ import { act } from 'react';
|
||||
// for React state updates.
|
||||
export async function waitFor(
|
||||
assertion: () => void,
|
||||
{ timeout = 1000, interval = 50 } = {},
|
||||
{ timeout = 2000, interval = 50 } = {},
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -27,7 +28,11 @@ export async function waitFor(
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
if (vi.isFakeTimers()) {
|
||||
await vi.advanceTimersByTimeAsync(interval);
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
const invalidCharsRegex = /[\b\x1b]/;
|
||||
|
||||
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
const { isNot } = this as any;
|
||||
let pass = true;
|
||||
const invalidLines: Array<{ line: number; content: string }> = [];
|
||||
@@ -50,6 +50,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
expect.extend({
|
||||
toHaveOnlyValidCharacters,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -38,12 +38,14 @@ export const createMockCommandContext = (
|
||||
},
|
||||
services: {
|
||||
config: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
settings: {
|
||||
merged: defaultMergedSettings,
|
||||
setValue: vi.fn(),
|
||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
@@ -52,6 +54,7 @@ export const createMockCommandContext = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any, // Cast because Logger is a class.
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
@@ -70,6 +73,7 @@ export const createMockCommandContext = (
|
||||
} as any,
|
||||
session: {
|
||||
sessionShellAllowlist: new Set<string>(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
lastPromptTokenCount: 0,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createTestMergedSettings } from '../config/settings.js';
|
||||
* Creates a mocked Config object with default values and allows overrides.
|
||||
*/
|
||||
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
({
|
||||
getSandbox: vi.fn(() => undefined),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
@@ -152,6 +153,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
@@ -162,9 +164,11 @@ export function createMockSettings(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): LoadedSettings {
|
||||
const merged = createTestMergedSettings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(overrides['merged'] as Partial<Settings>) || {},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -52,6 +52,7 @@ export const render = (
|
||||
terminalWidth?: number,
|
||||
): ReturnType<typeof inkRender> => {
|
||||
let renderResult: ReturnType<typeof inkRender> =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
undefined as unknown as ReturnType<typeof inkRender>;
|
||||
act(() => {
|
||||
renderResult = inkRender(tree);
|
||||
@@ -113,14 +114,19 @@ const getMockConfigInternal = (): Config => {
|
||||
return mockConfigInternal;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const configProxy = new Proxy({} as Config, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'getTargetDir') {
|
||||
return () =>
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
|
||||
}
|
||||
if (prop === 'getUseBackgroundColor') {
|
||||
return () => true;
|
||||
}
|
||||
const internal = getMockConfigInternal();
|
||||
if (prop in internal) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return internal[prop as keyof typeof internal];
|
||||
}
|
||||
throw new Error(`mockConfig does not have property ${String(prop)}`);
|
||||
@@ -148,6 +154,12 @@ const baseMockUiState = {
|
||||
activePtyId: undefined,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
@@ -197,7 +209,6 @@ const mockUIActions: UIActions = {
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
setIsBackgroundShellListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
handleWarning: vi.fn(),
|
||||
handleRestart: vi.fn(),
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
};
|
||||
@@ -210,6 +221,7 @@ export const renderWithProviders = (
|
||||
uiState: providedUiState,
|
||||
width,
|
||||
mouseEventsEnabled = false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
config = configProxy as unknown as Config,
|
||||
useAlternateBuffer = true,
|
||||
uiActions,
|
||||
@@ -231,17 +243,20 @@ export const renderWithProviders = (
|
||||
appState?: AppState;
|
||||
} = {},
|
||||
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const baseState: UIState = new Proxy(
|
||||
{ ...baseMockUiState, ...providedUiState },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop in target) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return target[prop as keyof typeof target];
|
||||
}
|
||||
// For properties not in the base mock or provided state,
|
||||
// we'll check the original proxy to see if it's a defined but
|
||||
// unprovided property, and if not, throw.
|
||||
if (prop in baseMockUiState) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return baseMockUiState[prop as keyof typeof baseMockUiState];
|
||||
}
|
||||
throw new Error(`mockUiState does not have property ${String(prop)}`);
|
||||
@@ -347,7 +362,9 @@ export function renderHook<Result, Props>(
|
||||
rerender: (props?: Props) => void;
|
||||
unmount: () => void;
|
||||
} {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result = { current: undefined as unknown as Result };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
let currentProps = options?.initialProps as Props;
|
||||
|
||||
function TestComponent({
|
||||
@@ -378,6 +395,7 @@ export function renderHook<Result, Props>(
|
||||
|
||||
function rerender(props?: Props) {
|
||||
if (arguments.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
currentProps = props as Props;
|
||||
}
|
||||
act(() => {
|
||||
@@ -411,6 +429,7 @@ export function renderHookWithProviders<Result, Props>(
|
||||
rerender: (props?: Props) => void;
|
||||
unmount: () => void;
|
||||
} {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result = { current: undefined as unknown as Result };
|
||||
|
||||
let setPropsFn: ((props: Props) => void) | undefined;
|
||||
@@ -432,6 +451,7 @@ export function renderHookWithProviders<Result, Props>(
|
||||
act(() => {
|
||||
renderResult = renderWithProviders(
|
||||
<Wrapper>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
|
||||
<TestComponent initialProps={options.initialProps as Props} />
|
||||
</Wrapper>,
|
||||
options,
|
||||
@@ -441,6 +461,7 @@ export function renderHookWithProviders<Result, Props>(
|
||||
function rerender(newProps?: Props) {
|
||||
act(() => {
|
||||
if (arguments.length > 0 && setPropsFn) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
setPropsFn(newProps as Props);
|
||||
} else if (forceUpdateFn) {
|
||||
forceUpdateFn();
|
||||
|
||||
@@ -51,13 +51,17 @@ export const createMockSettings = (
|
||||
} = overrides;
|
||||
|
||||
const loaded = new LoadedSettings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(system as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(user as any) || {
|
||||
path: '',
|
||||
settings: settingsOverrides,
|
||||
originalSettings: settingsOverrides,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
isTrusted ?? true,
|
||||
errors || [],
|
||||
@@ -71,6 +75,7 @@ export const createMockSettings = (
|
||||
// Assign any function overrides (e.g., vi.fn() for methods)
|
||||
for (const key in overrides) {
|
||||
if (typeof overrides[key] === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(loaded as any)[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -84,7 +84,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import { type LoadedSettings, mergeSettings } from '../config/settings.js';
|
||||
import { mergeSettings, type LoadedSettings } from '../config/settings.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
UIActionsContext,
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||
|
||||
// Mock useStdout to capture terminal title writes
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -133,7 +134,6 @@ vi.mock('./hooks/useGeminiStream.js');
|
||||
vi.mock('./hooks/vim.js');
|
||||
vi.mock('./hooks/useFocus.js');
|
||||
vi.mock('./hooks/useBracketedPaste.js');
|
||||
vi.mock('./hooks/useKeypress.js');
|
||||
vi.mock('./hooks/useLoadingIndicator.js');
|
||||
vi.mock('./hooks/useFolderTrust.js');
|
||||
vi.mock('./hooks/useIdeTrustListener.js');
|
||||
@@ -145,13 +145,30 @@ vi.mock('./contexts/SessionContext.js');
|
||||
vi.mock('./components/shared/text-buffer.js');
|
||||
vi.mock('./hooks/useLogger.js');
|
||||
vi.mock('./hooks/useInputHistoryStore.js');
|
||||
vi.mock('./hooks/atCommandProcessor.js');
|
||||
vi.mock('./hooks/useHookDisplayState.js');
|
||||
vi.mock('./hooks/useBanner.js', () => ({
|
||||
useBanner: vi.fn((bannerData) => ({
|
||||
bannerText: (
|
||||
bannerData.warningText ||
|
||||
bannerData.defaultText ||
|
||||
''
|
||||
).replace(/\\n/g, '\n'),
|
||||
})),
|
||||
}));
|
||||
vi.mock('./hooks/useShellInactivityStatus.js', () => ({
|
||||
useShellInactivityStatus: vi.fn(() => ({
|
||||
shouldShowFocusHint: false,
|
||||
inactivityStatus: 'none',
|
||||
})),
|
||||
}));
|
||||
vi.mock('./hooks/useTerminalTheme.js', () => ({
|
||||
useTerminalTheme: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
|
||||
// Mock external utilities
|
||||
vi.mock('../utils/events.js');
|
||||
@@ -180,7 +197,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { useKeypress } from './hooks/useKeypress.js';
|
||||
import { measureElement } from 'ink';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import {
|
||||
@@ -215,13 +232,15 @@ describe('AppContainer State Management', () => {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
} = {}) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
<KeypressProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -251,10 +270,10 @@ describe('AppContainer State Management', () => {
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
const mockedUseLogger = useLogger as Mock;
|
||||
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
||||
const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock;
|
||||
|
||||
const DEFAULT_GEMINI_STREAM_MOCK = {
|
||||
streamingState: 'idle',
|
||||
@@ -384,6 +403,10 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
mockedUseHookDisplayState.mockReturnValue([]);
|
||||
mockedUseTerminalTheme.mockReturnValue(undefined);
|
||||
mockedUseShellInactivityStatus.mockReturnValue({
|
||||
shouldShowFocusHint: false,
|
||||
inactivityStatus: 'none',
|
||||
});
|
||||
|
||||
// Mock Config
|
||||
mockConfig = makeFakeConfig();
|
||||
@@ -950,7 +973,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert that the context value is as expected
|
||||
expect(capturedUIState.proQuotaRequest).toBeNull();
|
||||
expect(capturedUIState.quota.proQuotaRequest).toBeNull();
|
||||
});
|
||||
unmount!();
|
||||
});
|
||||
@@ -975,7 +998,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert: The mock request is correctly passed through the context
|
||||
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
|
||||
expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest);
|
||||
});
|
||||
unmount!();
|
||||
});
|
||||
@@ -1246,8 +1269,15 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Shell Focus Action Required', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
// Use real implementation for these tests to verify title updates
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./hooks/useShellInactivityStatus.js')
|
||||
>('./hooks/useShellInactivityStatus.js');
|
||||
mockedUseShellInactivityStatus.mockImplementation(
|
||||
actual.useShellInactivityStatus,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1741,47 +1771,36 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
// Helper function to reduce boilerplate in tests
|
||||
const setupKeypressTest = async () => {
|
||||
const renderResult = renderAppContainer();
|
||||
stdin = renderResult.stdin;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
rerender = () => renderResult.rerender(getAppContainer());
|
||||
rerender = () => {
|
||||
renderResult.rerender(getAppContainer());
|
||||
};
|
||||
unmount = renderResult.unmount;
|
||||
};
|
||||
|
||||
const pressKey = (key: Partial<Key>, times = 1) => {
|
||||
const pressKey = (sequence: string, times = 1) => {
|
||||
for (let i = 0; i < times; i++) {
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'c',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
stdin.write(sequence);
|
||||
});
|
||||
rerender();
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the keypress handler from the AppContainer
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock slash command handler
|
||||
mockHandleSlashCommand = vi.fn();
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
@@ -1826,7 +1845,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
@@ -1836,7 +1855,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should quit on second press', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'c', ctrl: true }, 2);
|
||||
pressKey('\x03', 2); // Ctrl+C
|
||||
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
@@ -1851,7 +1870,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer past the reset threshold
|
||||
@@ -1859,7 +1878,7 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||
});
|
||||
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -1869,7 +1888,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should quit on second press if buffer is empty', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true }, 2);
|
||||
pressKey('\x04', 2); // Ctrl+D
|
||||
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
@@ -1880,7 +1899,7 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
|
||||
it('should NOT quit if buffer is not empty', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
@@ -1890,30 +1909,12 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
// Capture return value
|
||||
let result = true;
|
||||
const originalPressKey = (key: Partial<Key>) => {
|
||||
act(() => {
|
||||
result = handleGlobalKeypress({
|
||||
name: 'd',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
|
||||
// AppContainer's handler should return true if it reaches it
|
||||
expect(result).toBe(true);
|
||||
// But it should only be called once, so count is 1, not quitting yet.
|
||||
// Should only be called once, so count is 1, not quitting yet.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
// Now count is 2, it should quit.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
@@ -1927,7 +1928,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true });
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer past the reset threshold
|
||||
@@ -1935,7 +1936,7 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||
});
|
||||
|
||||
pressKey({ name: 'd', ctrl: true });
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -1953,7 +1954,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should focus shell input on Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
pressKey('\t');
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
unmount();
|
||||
@@ -1963,11 +1964,11 @@ describe('AppContainer State Management', () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
// Focus first
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
pressKey('\t');
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Unfocus via Shift+Tab
|
||||
pressKey({ name: 'tab', shift: true });
|
||||
pressKey('\x1b[Z');
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
@@ -1986,13 +1987,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Focus it
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'tab',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
} as Key);
|
||||
renderResult.stdin.write('\t');
|
||||
});
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
@@ -2027,7 +2022,7 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Tab
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
pressKey('\t');
|
||||
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
@@ -2055,7 +2050,7 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
@@ -2084,7 +2079,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
@@ -2097,11 +2092,14 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
const setupCopyModeTest = async (isAlternateMode = false) => {
|
||||
const setupCopyModeTest = async (
|
||||
isAlternateMode = false,
|
||||
childHandler?: Mock,
|
||||
) => {
|
||||
// Update settings for this test run
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const testSettings = {
|
||||
@@ -2115,23 +2113,39 @@ describe('AppContainer State Management', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const renderResult = renderAppContainer({ settings: testSettings });
|
||||
function TestChild() {
|
||||
useKeypress(childHandler || (() => {}), {
|
||||
isActive: !!childHandler,
|
||||
priority: true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const getTree = (settings: LoadedSettings) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
const renderResult = render(getTree(testSettings));
|
||||
stdin = renderResult.stdin;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
rerender = () =>
|
||||
renderResult.rerender(getAppContainer({ settings: testSettings }));
|
||||
rerender = () => renderResult.rerender(getTree(testSettings));
|
||||
unmount = renderResult.unmount;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2157,15 +2171,7 @@ describe('AppContainer State Management', () => {
|
||||
mocks.mockStdout.write.mockClear(); // Clear initial enable call
|
||||
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2184,30 +2190,14 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Turn it on (disable mouse)
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
|
||||
// Turn it off (enable mouse)
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'any', // Any key should exit copy mode
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
stdin.write('a'); // Any key should exit copy mode
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2220,15 +2210,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Enter copy mode
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2236,15 +2218,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Press any other key
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'a',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
stdin.write('a');
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2252,6 +2226,37 @@ describe('AppContainer State Management', () => {
|
||||
expect(enableMouseEvents).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should have higher priority than other priority listeners when enabled', async () => {
|
||||
// 1. Initial state with a child component's priority listener (already subscribed)
|
||||
// It should NOT handle Ctrl+S so we can enter copy mode.
|
||||
const childHandler = vi.fn().mockReturnValue(false);
|
||||
await setupCopyModeTest(true, childHandler);
|
||||
|
||||
// 2. Enter copy mode
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
// 3. Verify we are in copy mode
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
|
||||
// 4. Press any key
|
||||
childHandler.mockClear();
|
||||
// Now childHandler should return true for other keys, simulating a greedy listener
|
||||
childHandler.mockReturnValue(true);
|
||||
|
||||
act(() => {
|
||||
stdin.write('a');
|
||||
});
|
||||
rerender();
|
||||
|
||||
// 5. Verify that the exit handler took priority and childHandler was NOT called
|
||||
expect(childHandler).not.toHaveBeenCalled();
|
||||
expect(enableMouseEvents).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2734,4 +2739,67 @@ describe('AppContainer State Management', () => {
|
||||
compUnmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Handling', () => {
|
||||
it('shows permission dialog when checkPermissions returns paths', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => (unmount = renderAppContainer().unmount));
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
await act(async () =>
|
||||
capturedUIActions.handleFinalSubmit('read @file.txt'),
|
||||
);
|
||||
|
||||
expect(capturedUIState.permissionConfirmationRequest).not.toBeNull();
|
||||
expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([
|
||||
'/test/file.txt',
|
||||
]);
|
||||
await act(async () => unmount!());
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'handles permissions when allowed is %s',
|
||||
async (allowed) => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
|
||||
const addReadOnlyPathSpy = vi.spyOn(
|
||||
mockConfig.getWorkspaceContext(),
|
||||
'addReadOnlyPath',
|
||||
);
|
||||
const { submitQuery } = mockedUseGeminiStream();
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => (unmount = renderAppContainer().unmount));
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
await act(async () =>
|
||||
capturedUIActions.handleFinalSubmit('read @file.txt'),
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
capturedUIState.permissionConfirmationRequest?.onComplete({
|
||||
allowed,
|
||||
}),
|
||||
);
|
||||
|
||||
if (allowed) {
|
||||
expect(addReadOnlyPathSpy).toHaveBeenCalledWith('/test/file.txt');
|
||||
} else {
|
||||
expect(addReadOnlyPathSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(submitQuery).toHaveBeenCalledWith('read @file.txt');
|
||||
expect(capturedUIState.permissionConfirmationRequest).toBeNull();
|
||||
await act(async () => unmount!());
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
type HistoryItemToolGroup,
|
||||
AuthState,
|
||||
type ConfirmationRequest,
|
||||
type PermissionConfirmationRequest,
|
||||
type QuotaStats,
|
||||
} from './types.js';
|
||||
import { checkPermissions } from './hooks/atCommandProcessor.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import {
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
refreshServerHierarchicalMemory,
|
||||
flattenMemory,
|
||||
type MemoryChangedPayload,
|
||||
writeToStdout,
|
||||
disableMouseEvents,
|
||||
@@ -86,7 +90,6 @@ import { calculatePromptWidths } from './components/InputPrompt.js';
|
||||
import { useApp, useStdout, useStdin } from 'ink';
|
||||
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import * as fs from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import { computeTerminalTitle } from '../utils/windowTitle.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
@@ -98,13 +101,14 @@ import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { KeypressPriority } from './contexts/KeypressContext.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
|
||||
import { appEvents, AppEvent } from '../utils/events.js';
|
||||
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
|
||||
import { type UpdateObject } from './utils/updateCheck.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
@@ -141,6 +145,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
||||
import { isITerm2 } from './utils/terminalUtils.js';
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
@@ -248,6 +253,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
// We are in the interactive CLI, update how we request consent and settings.
|
||||
extensionManager.setRequestConsent((description) =>
|
||||
@@ -319,6 +325,16 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [currentModel, setCurrentModel] = useState(config.getModel());
|
||||
|
||||
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
|
||||
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
|
||||
const remaining = config.getQuotaRemaining();
|
||||
const limit = config.getQuotaLimit();
|
||||
const resetTime = config.getQuotaResetTime();
|
||||
return remaining !== undefined ||
|
||||
limit !== undefined ||
|
||||
resetTime !== undefined
|
||||
? { remaining, limit, resetTime }
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||
|
||||
@@ -348,7 +364,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
(async () => {
|
||||
// Note: the program will not work if this fails so let errors be
|
||||
// handled by the global catch.
|
||||
await config.initialize();
|
||||
if (!config.isInitialized()) {
|
||||
await config.initialize();
|
||||
}
|
||||
setConfigInitialized(true);
|
||||
startupProfiler.flush(config);
|
||||
|
||||
@@ -421,9 +439,23 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setCurrentModel(config.getModel());
|
||||
};
|
||||
|
||||
const handleQuotaChanged = (payload: {
|
||||
remaining: number | undefined;
|
||||
limit: number | undefined;
|
||||
resetTime?: string;
|
||||
}) => {
|
||||
setQuotaStats({
|
||||
remaining: payload.remaining,
|
||||
limit: payload.limit,
|
||||
resetTime: payload.resetTime,
|
||||
});
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
|
||||
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
|
||||
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
@@ -466,15 +498,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
try {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPreferredEditor = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
() => settings.merged.general.preferredEditor as EditorType,
|
||||
[settings.merged.general.preferredEditor],
|
||||
);
|
||||
@@ -484,7 +509,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
viewport: { height: 10, width: inputWidth },
|
||||
stdin,
|
||||
setRawMode,
|
||||
isValidPath,
|
||||
escapePastedPaths: true,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
});
|
||||
@@ -844,6 +869,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const [authConsentRequest, setAuthConsentRequest] =
|
||||
useState<ConfirmationRequest | null>(null);
|
||||
const [permissionConfirmationRequest, setPermissionConfirmationRequest] =
|
||||
useState<PermissionConfirmationRequest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsentRequest = (payload: ConsentRequestPayload) => {
|
||||
@@ -874,12 +901,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const { memoryContent, fileCount } =
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
|
||||
const flattenedMemory = flattenMemory(memoryContent);
|
||||
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Memory refreshed successfully. ${
|
||||
memoryContent.length > 0
|
||||
? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
|
||||
flattenedMemory.length > 0
|
||||
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).`
|
||||
: 'No memory content found.'
|
||||
}`,
|
||||
},
|
||||
@@ -887,7 +916,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
);
|
||||
if (config.getDebugMode()) {
|
||||
debugLogger.log(
|
||||
`[DEBUG] Refreshed memory content in config: ${memoryContent.substring(
|
||||
`[DEBUG] Refreshed memory content in config: ${flattenedMemory.substring(
|
||||
0,
|
||||
200,
|
||||
)}...`,
|
||||
@@ -1078,11 +1107,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
);
|
||||
|
||||
const handleFinalSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
async (submittedValue: string) => {
|
||||
const isSlash = isSlashCommand(submittedValue.trim());
|
||||
const isIdle = streamingState === StreamingState.Idle;
|
||||
|
||||
if (isSlash || (isIdle && isMcpReady)) {
|
||||
if (!isSlash) {
|
||||
const permissions = await checkPermissions(submittedValue, config);
|
||||
if (permissions.length > 0) {
|
||||
setPermissionConfirmationRequest({
|
||||
files: permissions,
|
||||
onComplete: (result) => {
|
||||
setPermissionConfirmationRequest(null);
|
||||
if (result.allowed) {
|
||||
permissions.forEach((p) =>
|
||||
config.getWorkspaceContext().addReadOnlyPath(p),
|
||||
);
|
||||
}
|
||||
void submitQuery(submittedValue);
|
||||
},
|
||||
});
|
||||
addInput(submittedValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
void submitQuery(submittedValue);
|
||||
} else {
|
||||
// Check messageQueue.length === 0 to only notify on the first queued item
|
||||
@@ -1103,6 +1151,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isMcpReady,
|
||||
streamingState,
|
||||
messageQueue.length,
|
||||
config,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1137,11 +1186,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
useLayoutEffect(() => {
|
||||
if (mainControlsRef.current) {
|
||||
const fullFooterMeasurement = measureElement(mainControlsRef.current);
|
||||
if (
|
||||
fullFooterMeasurement.height > 0 &&
|
||||
fullFooterMeasurement.height !== controlsHeight
|
||||
) {
|
||||
setControlsHeight(fullFooterMeasurement.height);
|
||||
const roundedHeight = Math.round(fullFooterMeasurement.height);
|
||||
if (roundedHeight > 0 && roundedHeight !== controlsHeight) {
|
||||
setControlsHeight(roundedHeight);
|
||||
}
|
||||
}
|
||||
}, [buffer, terminalWidth, terminalHeight, controlsHeight]);
|
||||
@@ -1221,7 +1268,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!showPrivacyNotice &&
|
||||
geminiClient?.isInitialized?.()
|
||||
) {
|
||||
handleFinalSubmit(initialPrompt);
|
||||
void handleFinalSubmit(initialPrompt);
|
||||
initialPromptSubmitted.current = true;
|
||||
}
|
||||
}, [
|
||||
@@ -1269,7 +1316,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
>();
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
|
||||
const [warningMessage, setWarningMessage] = useState<string | null>(null);
|
||||
|
||||
const [transientMessage, showTransientMessage] = useTimedMessage<{
|
||||
text: string;
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS);
|
||||
|
||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
||||
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||
@@ -1281,41 +1332,42 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
|
||||
|
||||
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleWarning = useCallback((message: string) => {
|
||||
setWarningMessage(message);
|
||||
if (warningTimeoutRef.current) {
|
||||
clearTimeout(warningTimeoutRef.current);
|
||||
}
|
||||
warningTimeoutRef.current = setTimeout(() => {
|
||||
setWarningMessage(null);
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const handleTransientMessage = (payload: {
|
||||
message: string;
|
||||
type: TransientMessageType;
|
||||
}) => {
|
||||
showTransientMessage({ text: payload.message, type: payload.type });
|
||||
};
|
||||
|
||||
// Handle timeout cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (warningTimeoutRef.current) {
|
||||
clearTimeout(warningTimeoutRef.current);
|
||||
}
|
||||
const handleSelectionWarning = () => {
|
||||
showTransientMessage({
|
||||
text: 'Press Ctrl-S to enter selection mode to copy text.',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
};
|
||||
const handlePasteTimeout = () => {
|
||||
showTransientMessage({
|
||||
text: 'Paste Timed out. Possibly due to slow connection.',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
};
|
||||
|
||||
appEvents.on(AppEvent.TransientMessage, handleTransientMessage);
|
||||
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
|
||||
return () => {
|
||||
appEvents.off(AppEvent.TransientMessage, handleTransientMessage);
|
||||
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePasteTimeout = () => {
|
||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
||||
};
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
};
|
||||
}, [handleWarning]);
|
||||
}, [showTransientMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ideNeedsRestart) {
|
||||
@@ -1415,17 +1467,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (result.userSelection === 'yes') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/ide install');
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
true,
|
||||
);
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
} else if (result.userSelection === 'dismiss') {
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
true,
|
||||
);
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
}
|
||||
setIdePromptAnswered(true);
|
||||
},
|
||||
@@ -1440,13 +1484,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key): boolean => {
|
||||
if (copyModeEnabled) {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
// We don't want to process any other keys if we're in copy mode.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Debug log keystrokes if enabled
|
||||
if (settings.merged.general.debugKeystrokeLogging) {
|
||||
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
|
||||
@@ -1477,13 +1514,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
if (settings.merged.general.devtools) {
|
||||
void (async () => {
|
||||
const { toggleDevToolsPanel } = await import(
|
||||
'../utils/devtoolsService.js'
|
||||
);
|
||||
await toggleDevToolsPanel(
|
||||
config,
|
||||
showErrorDetails,
|
||||
() => setShowErrorDetails((prev) => !prev),
|
||||
() => setShowErrorDetails(true),
|
||||
);
|
||||
})();
|
||||
} else {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
}
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
|
||||
const undoMessage = isITerm2()
|
||||
? 'Undo has been moved to Option + Z'
|
||||
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
|
||||
handleWarning(undoMessage);
|
||||
showTransientMessage({
|
||||
text: undoMessage,
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
|
||||
setShowFullTodos((prev) => !prev);
|
||||
@@ -1523,7 +1577,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (lastOutputTimeRef.current === capturedTime) {
|
||||
setEmbeddedShellFocused(false);
|
||||
} else {
|
||||
handleWarning('Use Shift+Tab to unfocus');
|
||||
showTransientMessage({
|
||||
text: 'Use Shift+Tab to unfocus',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
return false;
|
||||
@@ -1594,7 +1651,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
refreshStatic,
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
isAlternateBuffer,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
@@ -1603,12 +1659,28 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setIsBackgroundShellListOpen,
|
||||
lastOutputTimeRef,
|
||||
tabFocusTimeoutRef,
|
||||
handleWarning,
|
||||
showTransientMessage,
|
||||
settings.merged.general.devtools,
|
||||
showErrorDetails,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
|
||||
useKeypress(
|
||||
() => {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
return true;
|
||||
},
|
||||
{
|
||||
isActive: copyModeEnabled,
|
||||
// We need to receive keypresses first so they do not bubble to other
|
||||
// handlers.
|
||||
priority: KeypressPriority.Critical,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
if (settings.merged.ui.hideWindowTitle) return;
|
||||
@@ -1714,6 +1786,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
!!permissionConfirmationRequest ||
|
||||
!!customDialog ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
@@ -1819,6 +1892,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
permissionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
initError,
|
||||
@@ -1853,9 +1927,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
queueErrorMessage,
|
||||
showApprovalModeIndicator,
|
||||
currentModel,
|
||||
userTier,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
quota: {
|
||||
userTier,
|
||||
stats: quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
},
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -1884,7 +1961,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
copyModeEnabled,
|
||||
warningMessage,
|
||||
transientMessage,
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
@@ -1925,6 +2002,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
permissionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
initError,
|
||||
@@ -1959,6 +2037,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
queueErrorMessage,
|
||||
showApprovalModeIndicator,
|
||||
userTier,
|
||||
quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
contextFileNames,
|
||||
@@ -1993,7 +2072,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
apiKeyDefaultValue,
|
||||
authState,
|
||||
copyModeEnabled,
|
||||
warningMessage,
|
||||
transientMessage,
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
config,
|
||||
@@ -2050,7 +2129,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
@@ -2127,7 +2205,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('ApiAuthDialog', () => {
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence,
|
||||
|
||||
@@ -49,7 +49,6 @@ export function ApiAuthDialog({
|
||||
width: viewportWidth,
|
||||
height: 4,
|
||||
},
|
||||
isValidPath: () => false, // No path validation needed for API key
|
||||
inputFilter: (text) =>
|
||||
text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''),
|
||||
singleLine: true,
|
||||
|
||||
@@ -88,8 +88,10 @@ export function AuthDialog({
|
||||
const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthTypeEnv &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType)
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
defaultAuthType = defaultAuthTypeEnv as AuthType;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ export const useAuthCommand = (
|
||||
const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthType &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
!Object.values(AuthType).includes(defaultAuthType as AuthType)
|
||||
) {
|
||||
onAuthError(
|
||||
|
||||
@@ -213,6 +213,7 @@ const resumeCommand: SlashCommand = {
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
uiHistory.push({
|
||||
type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
|
||||
text,
|
||||
|
||||
@@ -49,6 +49,7 @@ async function finishAddingDirectories(
|
||||
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
errors.push(`Error refreshing memory: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export const initCommand: SlashCommand = {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result as SlashCommandActionReturn;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
showMemory,
|
||||
addMemory,
|
||||
listMemoryFiles,
|
||||
flattenMemory,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -33,7 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
refreshMemory: vi.fn(async (config) => {
|
||||
if (config.isJitContextEnabled()) {
|
||||
await config.getContextManager()?.refresh();
|
||||
const memoryContent = config.getUserMemory() || '';
|
||||
const memoryContent = original.flattenMemory(config.getUserMemory());
|
||||
const fileCount = config.getGeminiMdFileCount() || 0;
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -85,7 +86,7 @@ describe('memoryCommand', () => {
|
||||
mockGetGeminiMdFileCount = vi.fn();
|
||||
|
||||
vi.mocked(showMemory).mockImplementation((config) => {
|
||||
const memoryContent = config.getUserMemory() || '';
|
||||
const memoryContent = flattenMemory(config.getUserMemory());
|
||||
const fileCount = config.getGeminiMdFileCount() || 0;
|
||||
let content;
|
||||
if (memoryContent.length > 0) {
|
||||
|
||||
@@ -93,6 +93,7 @@ export const memoryCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
text: `Error refreshing memory: ${(error as Error).message}`,
|
||||
},
|
||||
Date.now(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('statsCommand', () => {
|
||||
selectedAuthType: '',
|
||||
tier: undefined,
|
||||
userEmail: 'mock@example.com',
|
||||
currentModel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,9 +64,20 @@ describe('statsCommand', () => {
|
||||
const mockQuota = { buckets: [] };
|
||||
const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota);
|
||||
const mockGetUserTierName = vi.fn().mockReturnValue('Basic');
|
||||
const mockGetModel = vi.fn().mockReturnValue('gemini-pro');
|
||||
const mockGetQuotaRemaining = vi.fn().mockReturnValue(85);
|
||||
const mockGetQuotaLimit = vi.fn().mockReturnValue(100);
|
||||
const mockGetQuotaResetTime = vi
|
||||
.fn()
|
||||
.mockReturnValue('2025-01-01T12:00:00Z');
|
||||
|
||||
mockContext.services.config = {
|
||||
refreshUserQuota: mockRefreshUserQuota,
|
||||
getUserTierName: mockGetUserTierName,
|
||||
getModel: mockGetModel,
|
||||
getQuotaRemaining: mockGetQuotaRemaining,
|
||||
getQuotaLimit: mockGetQuotaLimit,
|
||||
getQuotaResetTime: mockGetQuotaResetTime,
|
||||
} as unknown as Config;
|
||||
|
||||
await statsCommand.action(mockContext, '');
|
||||
@@ -75,6 +87,10 @@ describe('statsCommand', () => {
|
||||
expect.objectContaining({
|
||||
quotas: mockQuota,
|
||||
tier: 'Basic',
|
||||
currentModel: 'gemini-pro',
|
||||
pooledRemaining: 85,
|
||||
pooledLimit: 100,
|
||||
pooledResetTime: '2025-01-01T12:00:00Z',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -93,6 +109,9 @@ describe('statsCommand', () => {
|
||||
selectedAuthType: '',
|
||||
tier: undefined,
|
||||
userEmail: 'mock@example.com',
|
||||
currentModel: undefined,
|
||||
pooledRemaining: undefined,
|
||||
pooledLimit: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -44,6 +44,7 @@ async function defaultSessionView(context: CommandContext) {
|
||||
const wallDuration = now.getTime() - sessionStartTime.getTime();
|
||||
|
||||
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
|
||||
const currentModel = context.services.config?.getModel();
|
||||
|
||||
const statsItem: HistoryItemStats = {
|
||||
type: MessageType.STATS,
|
||||
@@ -51,12 +52,16 @@ async function defaultSessionView(context: CommandContext) {
|
||||
selectedAuthType,
|
||||
userEmail,
|
||||
tier,
|
||||
currentModel,
|
||||
};
|
||||
|
||||
if (context.services.config) {
|
||||
const quota = await context.services.config.refreshUserQuota();
|
||||
if (quota) {
|
||||
statsItem.quotas = quota;
|
||||
statsItem.pooledRemaining = context.services.config.getQuotaRemaining();
|
||||
statsItem.pooledLimit = context.services.config.getQuotaLimit();
|
||||
statsItem.pooledResetTime = context.services.config.getQuotaResetTime();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +94,19 @@ export const statsCommand: SlashCommand = {
|
||||
autoExecute: true,
|
||||
action: (context: CommandContext) => {
|
||||
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
|
||||
const currentModel = context.services.config?.getModel();
|
||||
const pooledRemaining = context.services.config?.getQuotaRemaining();
|
||||
const pooledLimit = context.services.config?.getQuotaLimit();
|
||||
const pooledResetTime = context.services.config?.getQuotaResetTime();
|
||||
context.ui.addItem({
|
||||
type: MessageType.MODEL_STATS,
|
||||
selectedAuthType,
|
||||
userEmail,
|
||||
tier,
|
||||
currentModel,
|
||||
pooledRemaining,
|
||||
pooledLimit,
|
||||
pooledResetTime,
|
||||
} as HistoryItemModelStats);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -123,6 +123,7 @@ function getNestedValue(
|
||||
for (const key of path) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== 'object') return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
@@ -144,8 +145,10 @@ function setNestedValue(
|
||||
if (current[key] === undefined || current[key] === null) {
|
||||
current[key] = {};
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current[key] = { ...(current[key] as Record<string, unknown>) };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -265,6 +268,7 @@ export function AgentConfigDialog({
|
||||
() =>
|
||||
AGENT_CONFIG_FIELDS.map((field) => {
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
@@ -300,6 +304,7 @@ export function AgentConfigDialog({
|
||||
displayValue,
|
||||
isGreyedOut: currentValue === undefined,
|
||||
scopeMessage: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
rawValue: rawValue as string | number | boolean | undefined,
|
||||
};
|
||||
}),
|
||||
@@ -320,6 +325,7 @@ export function AgentConfigDialog({
|
||||
if (!field || field.type !== 'boolean') return;
|
||||
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
@@ -329,6 +335,7 @@ export function AgentConfigDialog({
|
||||
const newValue = !effectiveValue;
|
||||
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
newValue,
|
||||
@@ -369,6 +376,7 @@ export function AgentConfigDialog({
|
||||
|
||||
// Update pending override locally
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
parsed,
|
||||
@@ -391,6 +399,7 @@ export function AgentConfigDialog({
|
||||
|
||||
// Remove the override (set to undefined)
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user