Compare commits

...

76 Commits

Author SHA1 Message Date
Gaurav Ghosh 7d32f2bf88 fix: suppress MCP server stderr from corrupting alternate buffer UI
Pipe stderr from npx chrome-devtools-mcp instead of inheriting it.
The server's banner warnings were leaking into the terminal and
corrupting the Ink-based UI in alternate buffer mode. Piped output
is forwarded to debugLogger so it remains visible with --debug.
2026-02-24 02:05:53 -08:00
Gaurav Ghosh c991e5b3dc fix: address PR #19284 review comments
- Remove redundant Promise.race in McpToolInvocation.execute (event listener leak)
- Propagate AbortSignal to all press_key calls (submitKey + typeCharByChar)
- Call this.close() on connectMcp failure (zombie process leak)
- Set showInDialog: false for all browser settings
- Remove debug log truncation in analyzeScreenshot
- Fix misleading --experimental-vision error message
- Replace any casts with typed TestableConfirmation interface in tests
- Update license year to 2026 in all browser agent files
- Merge duplicate imports in mcpToolWrapper
- Add sync comment to BrowserAgentCustomConfig
- Update subagents.md Chrome requirement wording
- Regenerate settings docs
2026-02-24 02:05:53 -08:00
Gaurav Ghosh c1560d99fd fix: update browser agent description to encourage full task delegation
Updated the browser_agent description from a primitive-focused listing
(navigating, filling, clicking) to a goal-oriented description that
emphasizes autonomy, multi-step reasoning, and dynamic feedback
interpretation. This encourages the parent agent to delegate entire
tasks in a single call rather than micromanaging individual browser
actions.
2026-02-23 13:29:39 -08:00
Gaurav Ghosh 377186d831 feat(browser): default persistent profile to ~/.gemini/cli-browser-profile 2026-02-23 12:25:46 -08:00
Gaurav Ghosh 64853dbfde refactor: Introduce dedicated browser agent configuration with session mode, headless, profile path, and visual model settings. 2026-02-23 12:06:23 -08:00
Gaurav Ghosh 52d9271e63 chore: regenerate settings schema and docs 2026-02-23 11:52:50 -08:00
Gaurav Ghosh 6732115859 fix: Add LlmRole.UTILITY_TOOL to analyzeScreenshot function calls. 2026-02-23 11:52:50 -08:00
Gaurav Ghosh 7718709f01 fix(browser): exclude visual prompt section when vision is disabled
The system prompt always included the VISUAL IDENTIFICATION section
telling the model about analyze_screenshot, even when visualModel was
not configured. This caused the model to attempt calling the tool
despite it not being registered.

- Convert BROWSER_SYSTEM_PROMPT to buildBrowserSystemPrompt(visionEnabled)
- Pass vision state from factory to definition builder
- Remove analyze_screenshot reference from click_at tool description
- Add tests for conditional prompt inclusion/exclusion
- Fix misleading test comment about tool count
2026-02-23 11:52:49 -08:00
Gaurav Ghosh 4e2856c4dd feat(browser): add submitKey param to type_text and improve connection errors
- Add submitKey parameter to type_text tool for pressing Enter/Tab/etc
  after typing, eliminating a separate model round-trip per value entry
- Update system prompt and tool hints to guide model toward type_text
  with submitKey instead of per-character press_key calls
- Refactor connection error handling into createConnectionError() with
  session-mode-aware remediation messages for profile locks, timeouts,
  and generic failures
- Update terminal failure prompts to pass through error remediation
  verbatim instead of hardcoding instructions
- Add tests for profile-lock, timeout, and generic connection errors
2026-02-23 11:52:49 -08:00
Gaurav Ghosh c7ec983d31 feat: document the experimental browser agent, its configuration, session modes, and security. 2026-02-23 11:52:49 -08:00
Gaurav Ghosh 067d0ecab3 fix: update chrome-devtools-mcp dependency, and add transport error handling. 2026-02-23 11:52:49 -08:00
Gaurav Ghosh fb1b2891cc feat(browser): gate vision on visualModel setting
Vision (screenshot analysis + coordinate-based interactions) is now
disabled by default. Set visualModel in browser_agent customConfig
to enable it, e.g. visualModel: 'gemini-2.5-computer-use-preview-10-2025'.
2026-02-23 11:52:48 -08:00
Gaurav Ghosh 2bc2945d14 feat(browser-agent): add type_text composite tool and improve prompt
- Add custom type_text tool that types a full string by internally
  calling press_key for each character, turning N model round-trips
  into 1. Dramatically speeds up text input in complex web apps.

- Move tool-specific usage rules from system prompt to individual
  tool descriptions via augmentToolDescription() for better
  organization and token efficiency.

- Add terminal failure handling instructions to system prompt
  (Chrome connection errors, browser crashes, repeated errors)
  with specific remediation steps.

- Add complex web app guidance (spreadsheets, rich editors) to
  system prompt, recommending type_text + keyboard navigation.

- Fix augmentToolDescription key ordering so more-specific keys
  (fill_form, click_at) match before shorter keys (fill, click).

- Remove non-existent tool references (scroll, type_text as MCP tool)
  and add click_at hint for vision tool.
2026-02-23 11:52:48 -08:00
Gaurav Ghosh 1c8a37379b fix(browser): correct session mode CLI flags and add connection validation
Fix chrome-devtools-mcp CLI flags:
- --existing (invalid) → --autoConnect for existing session mode
- --profile-path (invalid) → --userDataDir for custom profile path
- Default session mode changed from 'isolated' to 'persistent'

Add 'persistent' session mode (new default) which uses a persistent
Chrome profile at ~/.cache/chrome-devtools-mcp/chrome-profile.

Add connection timeout and actionable error for 'existing' mode when
Chrome remote debugging is not enabled.
2026-02-23 11:52:47 -08:00
Gaurav Ghosh 1620c7d82f feat(browser): implement visual agent for coordinate-based interactions
Implement the visual agent using the LocalAgentDefinition pattern:
- VisualAgentDefinition: Agent metadata for coordinate-based visual tasks
- delegateToVisualAgent.ts: Tool for semantic agent to delegate visual tasks
- Uses gemini-2.5-computer-use-preview-10-2025 model for Computer Use capability

The visual agent handles tasks requiring visual identification or precise
coordinate-based actions that cannot be done via the accessibility tree.
2026-02-23 11:52:47 -08:00
Gaurav Ghosh f4100baf6b feat(browser): implement browser agent as LocalAgentDefinition
Implement the browser agent using the LocalAgentDefinition pattern:
- BrowserAgentDefinition: Agent metadata and prompt configuration
- BrowserAgentInvocation: Handles individual browser agent invocations
- BrowserAgentFactory: Creates agent definitions with dynamic MCP tools
- BrowserManager: Manages chrome-devtools-mcp connection lifecycle

Uses getBrowserAgentConfig() to read settings from agents.overrides.browser_agent
2026-02-23 11:52:47 -08:00
Gaurav Ghosh 0b93c868e9 feat(browser): add browser agent settings schema
Add extensible browser agent configuration using the agents.overrides pattern:
- Extended AgentOverride interface with customConfig field for agent-specific settings
- Added BrowserAgentCustomConfig type for browser-specific configuration
- Added getAgentOverride() and getBrowserAgentConfig() methods to Config class
- Settings configured via agents.overrides.browser_agent.customConfig
- Updated settings schema with customConfig in AgentOverride definition

This establishes the foundational pattern for configuring the browser agent
through the standard agents.overrides infrastructure.
2026-02-23 11:52:46 -08:00
Aishanee Shah 7cfbb6fb71 feat(core): optimize tool descriptions and schemas for Gemini 3 (#19643) 2026-02-23 19:27:35 +00:00
Mehmet Gok a105768de8 docs(CONTRIBUTING): update React DevTools version to 6 (#20014)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-23 19:17:04 +00:00
Jerop Kipruto 347f3fe7e4 feat(policy): Support MCP Server Wildcards in Policy Engine (#20024) 2026-02-23 19:07:06 +00:00
Sandy Tao 25803e05fd fix(bundling): copy devtools package to bundle for runtime resolution (#19766) 2026-02-23 18:40:41 +00:00
Himanshu Soni 774ae220be fix(core): prevent state corruption in McpClientManager during collis (#19782) 2026-02-23 18:35:31 +00:00
Tommaso Sciortino 813e0c18ac Allow ask headers longer than 16 chars (#20041) 2026-02-23 18:26:59 +00:00
Gal Zahavi 3f6cec22e6 chore: restrict gemini-automted-issue-triage to only allow echo (#20047) 2026-02-23 18:24:34 +00:00
Sri Pasumarthi 3966f3c053 feat: Map tool kinds to explicit ACP.ToolKind values and update test … (#19547) 2026-02-23 18:22:05 +00:00
sinisterchill 2e3cbd6363 fix(core): prevent OAuth server crash on unexpected requests (#19668) 2026-02-23 18:03:31 +00:00
Adib234 8b1dc15182 fix(plan): allow plan mode writes on Windows and fix prompt paths (#19658) 2026-02-23 17:48:50 +00:00
owenofbrien fa9aee2bf0 Fix for silent failures in non-interactive mode (#19905) 2026-02-23 17:35:13 +00:00
Sam Roberts 6628cbb39d Updates command reference and /stats command. (#19794) 2026-02-23 17:13:24 +00:00
Sehoon Shon aa9163da60 feat(core): add policy chain support for Gemini 3.1 (#19991) 2026-02-23 15:13:48 +00:00
Sehoon Shon ec0f23ae03 fix(core): increase default retry attempts and add quota error backoff (#19949) 2026-02-23 15:13:34 +00:00
nityam ac04c388e0 Fix: Persist manual model selection on restart #19864 (#19891) 2026-02-23 03:44:00 +00:00
Abhi 621ddbe744 refactor(core): move session conversion logic to core (#19972) 2026-02-23 01:18:07 +00:00
Sehoon Shon c537fd5aec refactor(config): remove enablePromptCompletion from settings (#19974) 2026-02-22 19:10:20 -05:00
Shivangi Sharma a91bc60e18 fix(core): add uniqueness guard to edit tool (#19890)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-02-22 20:24:58 +00:00
Nick Salerni faa1ec3044 fix(core): prevent omission placeholder deletions in replace/write_file (#19870)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-02-22 19:58:31 +00:00
Bryan Morgan d96bd05d36 fix(core): allow any preview model in quota access check (#19867) 2026-02-22 12:53:24 +00:00
Adib234 84666e1bbc fix(plan): time share by approval mode dashboard reporting negative time shares (#19847) 2026-02-22 00:32:57 +00:00
N. Taylor Mullen a7d851146a feat(core): remove unnecessary login verbiage from Code Assist auth (#19861) 2026-02-21 21:55:11 +00:00
Abhi acb7f577de chore(lint): fix lint errors seen when running npm run lint (#19844) 2026-02-21 18:33:25 +00:00
Abhi d2d345f41a fix(cli): filter subagent sessions from resume history (#19698) 2026-02-21 17:41:27 +00:00
Christian Gunderman dfd7721e69 Disallow unsafe returns. (#19767) 2026-02-21 01:12:56 +00:00
matt korwel 09218572d0 refactor(core): remove unsafe type assertions in error utils (Phase 1.1) (#19750) 2026-02-21 01:00:57 +00:00
Christian Gunderman 5d98ed5820 Utilize pipelining of grep_search -> read_file to eliminate turns (#19574) 2026-02-21 00:36:10 +00:00
Jarrod Whelan 727f9b67b1 feat(cli): improve CTRL+O experience for both standard and alternate screen buffer (ASB) modes (#19010)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-02-21 00:26:11 +00:00
Adam Weidman 547f5d45f5 feat(core): migrate read_file to 1-based start_line/end_line parameters (#19526) 2026-02-20 22:59:18 +00:00
Christian Gunderman 58d637f919 Disallow and suppress unsafe assignment (#19736) 2026-02-20 22:28:55 +00:00
Sehoon Shon b746524a1b fix(cli): re-enable CLI banner (#19741) 2026-02-20 22:21:26 +00:00
Abhijit Balaji c5baf39dbd feat(policy): repurpose "Always Allow" persistence to workspace level (#19707) 2026-02-20 22:07:20 +00:00
Sehoon Shon b48970da15 fix(cli): use getDisplayString for manual model selection in dialog (#19726) 2026-02-20 22:03:32 +00:00
Jacob Richman 9a8e5d3940 fix(cli): extensions dialog UX polish (#19685) 2026-02-20 21:08:24 +00:00
Jacob Richman 089aec8b8d feat(cli): make JetBrains warning more specific (#19687) 2026-02-20 21:06:35 +00:00
Christian Gunderman b7555ab1e1 Fix unsafe assertions in code_assist folder. (#19706)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-20 20:44:23 +00:00
Emily Hedlund c04602f209 fix(core): restore auth consent in headless mode and add unit tests (#19689) 2026-02-20 20:31:43 +00:00
Emily Hedlund a01d7e9a05 security: implement deceptive URL detection and disclosure in tool confirmations (#19288) 2026-02-20 20:21:31 +00:00
Emily Hedlund 49b2e76ee1 Revert "feat(ui): add source indicators to slash commands" (#19695) 2026-02-20 20:08:49 +00:00
Emily Hedlund aed348a99c security: strip deceptive Unicode characters from terminal output (#19026)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-20 20:04:32 +00:00
Christian Gunderman 7cf4c05c66 Fixes 'input.on' is not a function error in Gemini CLI (#19691) 2026-02-20 20:03:57 +00:00
Sam Roberts cdf157e52a Update sidebar.json for to allow top nav tabs. (#19595) 2026-02-20 19:54:26 +00:00
Sehoon Shon 723f269df6 fix(core): treat 503 Service Unavailable as retryable quota error (#19642) 2026-02-20 19:51:53 +00:00
Spencer 239aa0909c fix(cli): allow perfect match @-path completions to submit on Enter (#19562) 2026-02-20 19:46:48 +00:00
matt korwel 6cfd29ef9b feat(plan): enforce read-only constraints in Plan Mode (#19433)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-02-20 19:33:04 +00:00
Sehoon Shon f97b04cc9a feat(models): support Gemini 3.1 Pro Preview and fixes (#19676) 2026-02-20 19:19:21 +00:00
Christian Gunderman 788a40c445 Send accepted/removed lines with ACCEPT_FILE telemetry. (#19670) 2026-02-20 19:07:43 +00:00
Adam Weidman ce03156c9f feat(a2a): Add API key authentication provider (#19548) 2026-02-20 18:55:36 +00:00
Spencer fe428936d5 feat(ui): improve startup warnings UX with dismissal and show-count limits (#19584) 2026-02-20 18:22:45 +00:00
Gal Zahavi d24f10b087 feat(cli): enhance folder trust with configuration discovery and security warnings (#19492)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-20 18:21:03 +00:00
Tommaso Sciortino d54702185b feat(cli): add support for numpad SS3 sequences (#19659) 2026-02-20 18:09:10 +00:00
Alisa 27b7fc04de Search updates (#19482)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-20 17:54:28 +00:00
╯‵Д′)╯彡┻━┻ (☕1e6) be03e0619f fix: use discoverOAuthFromWWWAuthenticate for reactive OAuth flow (#18760) (#19038) 2026-02-20 17:48:42 +00:00
christine betts 2bb7aaecd0 Add initial implementation of /extensions explore command (#19029) 2026-02-20 17:30:49 +00:00
matt korwel 0f855fc0c4 fix(security): rate limit web_fetch tool to mitigate DDoS via prompt injection (#19567)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-20 17:18:07 +00:00
Jenna Inouye c7e309efc9 Docs: Update release information regarding Gemini 3.1 (#19568) 2026-02-20 16:37:41 +00:00
Jerop Kipruto 429932c663 docs: refine Plan Mode documentation structure and workflow (#19644) 2026-02-20 15:56:49 +00:00
Emily Hedlund d08b1efc72 feat(ui): add source indicators to slash commands (#18839) 2026-02-20 15:54:59 +00:00
matt korwel c3b52b8206 chore: resolve build warnings and update dependencies (#18880) 2026-02-20 03:25:56 +00:00
349 changed files with 14191 additions and 2338 deletions
@@ -20,7 +20,8 @@ async function run(cmd) {
stdio: ['pipe', 'pipe', 'ignore'],
});
return stdout.trim();
} catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars
} catch (_e) {
// eslint-disable-line @typescript-eslint/no-unused-vars
return null;
}
}
@@ -155,7 +155,10 @@ jobs:
"telemetry": {
"enabled": true,
"target": "gcp"
}
},
"coreTools": [
"run_shell_command(echo)"
],
}
prompt: |-
## Role
+5 -6
View File
@@ -372,8 +372,7 @@ specific debug settings.
### React DevTools
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
used for the CLI's interface, is compatible with React DevTools version 4.x.
To debug the CLI's React-based UI, you can use React DevTools.
1. **Start the Gemini CLI in development mode:**
@@ -381,20 +380,20 @@ used for the CLI's interface, is compatible with React DevTools version 4.x.
DEV=true npm start
```
2. **Install and run React DevTools version 4.28.5 (or the latest compatible
4.x version):**
2. **Install and run React DevTools version 6 (which matches the CLI's
`react-devtools-core`):**
You can either install it globally:
```bash
npm install -g react-devtools@4.28.5
npm install -g react-devtools@6
react-devtools
```
Or run it directly using npx:
```bash
npx react-devtools@4.28.5
npx react-devtools@6
```
Your running CLI application should then connect to React DevTools.
+134 -121
View File
@@ -1,50 +1,61 @@
# 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
implementation strategy.
Plan Mode is a read-only environment for architecting robust solutions before
implementation. It allows you to:
> **Note: Plan Mode is currently an experimental feature.**
>
> Experimental features are subject to change. To use Plan Mode, enable it via
> `/settings` (search for `Plan`) or add the following to your `settings.json`:
>
> ```json
> {
> "experimental": {
> "plan": true
> }
> }
> ```
>
> Your feedback is invaluable as we refine this feature. If you have ideas,
- **Research:** Explore the project in a read-only state to prevent accidental
changes.
- **Design:** Understand problems, evaluate trade-offs, and choose a solution.
- **Plan:** Align on an execution strategy before any code is modified.
> **Note:** This is a preview feature currently under active development. Your
> feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
> - Use the `/bug` command within the CLI to file an issue.
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on
> GitHub.
> - Use the **/bug** command within Gemini CLI to file an issue.
- [Starting in Plan Mode](#starting-in-plan-mode)
- [Enabling Plan Mode](#enabling-plan-mode)
- [How to use Plan Mode](#how-to-use-plan-mode)
- [Entering Plan Mode](#entering-plan-mode)
- [The Planning Workflow](#the-planning-workflow)
- [Planning Workflow](#planning-workflow)
- [Exiting Plan Mode](#exiting-plan-mode)
- [Tool Restrictions](#tool-restrictions)
- [Customizing Planning with Skills](#customizing-planning-with-skills)
- [Customizing Policies](#customizing-policies)
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
## Starting in Plan Mode
## Enabling Plan Mode
You can configure Gemini CLI to start directly in Plan Mode by default:
To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the
following to your `settings.json`:
1. Type `/settings` in the CLI.
2. Search for `Default Approval Mode`.
3. Set the value to `Plan`.
```json
{
"experimental": {
"plan": true
}
}
```
Other ways to start in Plan Mode:
## How to use Plan Mode
- **CLI Flag:** `gemini --approval-mode=plan`
- **Manual Settings:** Manually update your `settings.json`:
### Entering Plan Mode
You can configure Gemini CLI to start in Plan Mode by default or enter it
manually during a session.
- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by
default:
1. Type `/settings` in the CLI.
2. Search for **Default Approval Mode**.
3. Set the value to **Plan**.
Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually
update:
```json
{
@@ -54,43 +65,44 @@ Other ways to start in Plan Mode:
}
```
## How to use Plan Mode
- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
(`Default` -> `Auto-Edit` -> `Plan`).
### Entering Plan Mode
> **Note:** Plan Mode is automatically removed from the rotation when Gemini
> CLI is actively processing or showing confirmation dialogs.
You can enter Plan Mode in three ways:
- **Command:** Type `/plan` in the input box.
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
(`Default` -> `Auto-Edit` -> `Plan`).
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI then
calls the [`enter_plan_mode`] tool to switch modes.
> **Note:** This tool is not available when Gemini CLI is in [YOLO mode].
> **Note:** Plan Mode is automatically removed from the rotation when the
> agent is actively processing or showing confirmation dialogs.
### Planning Workflow
2. **Command:** Type `/plan` in the input box.
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
then call the [`enter_plan_mode`] tool to switch modes.
- **Note:** This tool is not available when the CLI is in YOLO mode.
### The Planning Workflow
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. **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).
1. **Explore & Analyze:** Analyze requirements and use read-only tools to map
the codebase and validate assumptions. For complex tasks, identify at least
two viable implementation approaches.
2. **Consult:** Present a summary of the identified approaches via [`ask_user`]
to obtain a selection. For simple or canonical tasks, this step may be
skipped.
3. **Draft:** Once an approach is selected, write a detailed implementation
plan to the plans directory.
4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan
and formally request approval.
- **Approve:** Exit Plan Mode and start implementation.
- **Iterate:** Provide feedback to refine the plan.
For more complex or specialized planning tasks, you can
[customize the planning workflow with skills](#customizing-planning-with-skills).
### Exiting Plan Mode
To exit Plan Mode:
To exit Plan Mode, you can:
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the
finalized plan for your approval.
- **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
finalized plan for your approval.
## Tool Restrictions
@@ -103,30 +115,78 @@ These are the only allowed tools:
- **Interaction:** [`ask_user`]
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
`postgres_read_schema`) are allowed.
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
files in the `~/.gemini/tmp/<project>/<session-id>/plans/` directory.
- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md`
files in the `~/.gemini/tmp/<project>/<session-id>/plans/` directory or your
[custom plans directory](#custom-plan-directory-and-policies).
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
resources in a read-only manner)
### 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.
You can use [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, design and planning 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
- A **"Security Audit"** skill could prompt Gemini CLI to look for specific
vulnerabilities during codebase exploration.
- A **"Frontend Design"** skill could guide the agent to use specific UI
- A **"Frontend Design"** skill could guide Gemini CLI 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.
To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the
`<skill-name>` skill to plan..." or Gemini CLI 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 commands 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 subagents in Plan Mode
You can enable experimental research [subagents] 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 Gemini CLI 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]
docs.
### Custom Plan Directory and Policies
@@ -152,11 +212,10 @@ locations defined within a project's workspace cannot be used to escape and
overwrite sensitive files elsewhere. Any user-configured directory must reside
within the project boundary.
Because Plan Mode is read-only by default, using a custom directory requires
updating your [Policy Engine] configurations to allow `write_file` and `replace`
in that specific location. For example, to allow writing to the `.gemini/plans`
directory within your project, create a policy file at
`~/.gemini/policies/plan-custom-directory.toml`:
Using a custom directory requires updating your [policy engine] configurations
to allow `write_file` and `replace` in that specific location. For example, to
allow writing to the `.gemini/plans` directory within your project, create a
policy file at `~/.gemini/policies/plan-custom-directory.toml`:
```toml
[[rule]]
@@ -166,56 +225,9 @@ priority = 100
modes = ["plan"]
# Adjust the pattern to match your custom directory.
# This example matches any .md file in a .gemini/plans directory within the project.
argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\""
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
```
### 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
@@ -225,8 +237,9 @@ Guide].
[`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/reference/policy-engine.md
[subagents]: /docs/core/subagents.md
[policy engine]: /docs/reference/policy-engine.md
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
[`ask_user`]: /docs/tools/ask-user.md
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
+10 -11
View File
@@ -22,17 +22,16 @@ they appear in the UI.
### General
| 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 Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| 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` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
| 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 Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
### Output
+31
View File
@@ -38,6 +38,37 @@ folder, a dialog will automatically appear, prompting you to make a choice:
Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you
will only be asked once per folder.
## Understanding folder contents: The discovery phase
Before you make a choice, the Gemini CLI performs a **discovery phase** to scan
the folder for potential configurations. This information is displayed in the
trust dialog to help you make an informed decision.
The discovery UI lists the following categories of items found in the project:
- **Commands**: Custom `.toml` command definitions that add new functionality.
- **MCP Servers**: Configured Model Context Protocol servers that the CLI will
attempt to connect to.
- **Hooks**: System or custom hooks that can intercept and modify CLI behavior.
- **Skills**: Local agent skills that provide specialized capabilities.
- **Setting overrides**: Any project-specific configurations that override your
global user settings.
### Security warnings and errors
The trust dialog also highlights critical information that requires your
attention:
- **Security Warnings**: The CLI will explicitly flag potentially dangerous
settings, such as auto-approving certain tools or disabling the security
sandbox.
- **Discovery Errors**: If the CLI encounters issues while scanning the folder
(e.g., a malformed `settings.json` file), these errors will be displayed
prominently.
By reviewing these details, you can ensure that you only grant trust to projects
that you know are safe.
## Why trust matters: The impact of an untrusted workspace
When a folder is **untrusted**, the Gemini CLI runs in a restricted "safe mode"
+116
View File
@@ -80,6 +80,122 @@ Gemini CLI comes with the following built-in subagents:
invoked by the user.
- **Configuration:** Enabled by default. No specific configuration options.
### Browser Agent (experimental)
- **Name:** `browser_agent`
- **Purpose:** Automate web browser tasks — navigating websites, filling forms,
clicking buttons, and extracting information from web pages — using the
accessibility tree.
- **When to use:** "Go to example.com and fill out the contact form," "Extract
the pricing table from this page," "Click the login button and enter my
credentials."
> **Note:** This is a preview feature currently under active development.
#### Prerequisites
The browser agent requires:
- **Chrome** version 144 or later (any recent stable release will work).
- **Node.js** with `npx` available (used to launch the
[`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp)
server).
#### Enabling the browser agent
The browser agent is disabled by default. Enable it in your `settings.json`:
```json
{
"agents": {
"overrides": {
"browser_agent": {
"enabled": true
}
}
}
}
```
#### Session modes
The `sessionMode` setting controls how Chrome is launched and managed. Set it
under `agents.browser`:
```json
{
"agents": {
"overrides": {
"browser_agent": {
"enabled": true
}
},
"browser": {
"sessionMode": "persistent"
}
}
}
```
The available modes are:
| Mode | Description |
| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `persistent` | **(Default)** Launches Chrome with a persistent profile stored at `~/.gemini/cli-browser-profile/`. Cookies, history, and settings are preserved between sessions. |
| `isolated` | Launches Chrome with a temporary profile that is deleted after each session. Use this for clean-state automation. |
| `existing` | Attaches to an already-running Chrome instance. You must enable remote debugging first by navigating to `chrome://inspect/#remote-debugging` in Chrome. No new browser process is launched. |
#### Configuration reference
All browser-specific settings go under `agents.browser` in your `settings.json`.
| Setting | Type | Default | Description |
| :------------ | :-------- | :------------- | :---------------------------------------------------------------------------------------------- |
| `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. |
| `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). |
| `profilePath` | `string` | — | Custom path to a browser profile directory. |
| `visualModel` | `string` | — | Model override for the visual agent (for example, `"gemini-2.5-computer-use-preview-10-2025"`). |
#### Security
The browser agent enforces the following security restrictions:
- **Blocked URL patterns:** `file://`, `javascript:`, `data:text/html`,
`chrome://extensions`, and `chrome://settings/passwords` are always blocked.
- **Sensitive action confirmation:** Actions like form filling, file uploads,
and form submissions require user confirmation through the standard policy
engine.
#### Visual agent
By default, the browser agent interacts with pages through the accessibility
tree using element `uid` values. For tasks that require visual identification
(for example, "click the yellow button" or "find the red error message"), you
can enable the visual agent by setting a `visualModel`:
```json
{
"agents": {
"overrides": {
"browser_agent": {
"enabled": true
}
},
"browser": {
"visualModel": "gemini-2.5-computer-use-preview-10-2025"
}
}
}
```
When enabled, the agent gains access to the `analyze_screenshot` tool, which
captures a screenshot and sends it to the vision model for analysis. The model
returns coordinates and element descriptions that the browser agent uses with
the `click_at` tool for precise, coordinate-based interactions.
> **Note:** The visual agent requires API key or Vertex AI authentication. It is
> not available when using Google Login.
## Creating custom subagents
You can create your own subagents to automate specific workflows or enforce
+17 -3
View File
@@ -2,6 +2,21 @@
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have
> access to Gemini 3.1, use the `/model` command and select **Manual**. If you
> have access, you will see `gemini-3.1-pro-preview`.
>
> If you have access to Gemini 3.1, it will be included in model routing when
> you select **Auto (Gemini 3)**. You can also launch the Gemini 3.1 model
> directly using the `-m` flag:
>
> ```
> gemini -m gemini-3.1-pro-preview
> ```
>
> Learn more about [models](../cli/model.md) and
> [model routing](../cli/model-routing.md).
## How to get started with Gemini 3 on Gemini CLI
Get started by upgrading Gemini CLI to the latest version:
@@ -12,9 +27,8 @@ npm install -g @google/gemini-cli@latest
After youve confirmed your version is 0.21.1 or later:
1. Use the `/settings` command in Gemini CLI.
2. Toggle **Preview Features** to `true`.
3. Run `/model` and select **Auto (Gemini 3)**.
1. Run `/model`.
2. Select **Auto (Gemini 3)**.
For more information, see [Gemini CLI model selection](../cli/model.md).
+10
View File
@@ -64,6 +64,16 @@ and more.
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
## Check usage and quota
You can check your current token usage and quota information using the
`/stats model` command. This command provides a snapshot of your current
session's token usage, as well as your overall quota and usage for the supported
models.
For more information on the `/stats` command and its subcommands, see the
[Command Reference](../../reference/commands.md#stats).
## Next steps
- Follow the [File management](../cli/tutorials/file-management.md) guide to
+54 -7
View File
@@ -32,6 +32,8 @@ Slash commands provide meta-level control over the CLI itself.
conversation state interactively, or resuming a previous state from a later
session.
- **Sub-commands:**
- **`debug`**
- **Description:** Export the most recent API request as a JSON payload.
- **`delete <tag>`**
- **Description:** Deletes a saved conversation checkpoint.
- **`list`**
@@ -128,8 +130,29 @@ Slash commands provide meta-level control over the CLI itself.
### `/extensions`
- **Description:** Lists all active extensions in the current Gemini CLI
session. See [Gemini CLI Extensions](../extensions/index.md).
- **Description:** Manage extensions. See
[Gemini CLI Extensions](../extensions/index.md).
- **Sub-commands:**
- **`config`**:
- **Description:** Configure extension settings.
- **`disable`**:
- **Description:** Disable an extension.
- **`enable`**:
- **Description:** Enable an extension.
- **`explore`**:
- **Description:** Open extensions page in your browser.
- **`install`**:
- **Description:** Install an extension from a git repo or local path.
- **`link`**:
- **Description:** Link an extension from a local path.
- **`list`**:
- **Description:** List active extensions.
- **`restart`**:
- **Description:** Restart all extensions.
- **`uninstall`**:
- **Description:** Uninstall an extension.
- **`update`**:
- **Description:** Update extensions. Usage: update <extension-names>|--all
### `/help` (or `/?`)
@@ -184,6 +207,10 @@ Slash commands provide meta-level control over the CLI itself.
servers that support OAuth authentication.
- **`desc`**
- **Description:** List configured MCP servers and tools with descriptions.
- **`disable`**
- **Description:** Disable an MCP server.
- **`enable`**
- **Description:** Enable a disabled MCP server.
- **`list`** or **`ls`**:
- **Description:** List configured MCP servers and tools. This is the
default action if no subcommand is specified.
@@ -221,7 +248,21 @@ Slash commands provide meta-level control over the CLI itself.
### `/model`
- **Description:** Opens a dialog to choose your Gemini model.
- **Description:** Manage model configuration.
- **Sub-commands:**
- **`manage`**:
- **Description:** Opens a dialog to configure the model.
- **`set`**:
- **Description:** Set the model to use.
- **Usage:** `/model set <model-name> [--persist]`
### `/permissions`
- **Description:** Manage folder trust settings and other permissions.
- **Sub-commands:**
- **`trust`**:
- **Description:** Manage folder trust settings.
- **Usage:** `/permissions trust [<directory-path>]`
### `/plan`
@@ -331,10 +372,16 @@ Slash commands provide meta-level control over the CLI itself.
### `/stats`
- **Description:** Display detailed statistics for the current Gemini CLI
session, including token usage, cached token savings (when available), and
session duration. Note: Cached token information is only displayed when cached
tokens are being used, which occurs with API key authentication but not with
OAuth authentication at this time.
session.
- **Sub-commands:**
- **`session`**:
- **Description:** Show session-specific usage statistics, including
duration, tool calls, and performance metrics. This is the default view.
- **`model`**:
- **Description:** Show model-specific usage statistics, including token
counts and quota information.
- **`tools`**:
- **Description:** Show tool-specific usage statistics.
### `/terminal-setup`
+21 -6
View File
@@ -137,12 +137,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`general.enablePromptCompletion`** (boolean):
- **Description:** Enable AI-powered prompt completion suggestions while
typing.
- **Default:** `false`
- **Requires restart:** Yes
- **`general.retryFetchErrors`** (boolean):
- **Description:** Retry on "exception TypeError: fetch failed sending
request" errors.
@@ -647,6 +641,27 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `{}`
- **Requires restart:** Yes
- **`agents.browser.sessionMode`** (enum):
- **Description:** Session mode: 'persistent', 'isolated', or 'existing'.
- **Default:** `"persistent"`
- **Values:** `"persistent"`, `"isolated"`, `"existing"`
- **Requires restart:** Yes
- **`agents.browser.headless`** (boolean):
- **Description:** Run browser in headless mode.
- **Default:** `false`
- **Requires restart:** Yes
- **`agents.browser.profilePath`** (string):
- **Description:** Path to browser profile directory for session persistence.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`agents.browser.visualModel`** (string):
- **Description:** Model override for the visual agent.
- **Default:** `undefined`
- **Requires restart:** Yes
#### `context`
- **`context.fileName`** (string | string[]):
+42 -14
View File
@@ -64,9 +64,11 @@ primary conditions are the tool's name and its arguments.
The `toolName` in the rule must match the name of the tool being called.
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
wildcard. A `toolName` of `my-server__*` will match any tool from the
`my-server` MCP.
- **Wildcards**: You can use wildcards to match multiple tools.
- `*`: Matches **any tool** (built-in or MCP).
- `server__*`: Matches any tool from a specific MCP server.
- `*__toolName`: Matches a specific tool name across **all** MCP servers.
- `*__*`: Matches **any tool from any MCP server**.
#### Arguments pattern
@@ -144,9 +146,9 @@ A rule matches a tool call if all of its conditions are met:
1. **Tool name**: The `toolName` in the rule must match the name of the tool
being called.
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
wildcard. A `toolName` of `my-server__*` will match any tool from the
`my-server` MCP.
- **Wildcards**: You can use wildcards like `*`, `server__*`, or
`*__toolName` to match multiple tools. See [Tool Name](#tool-name) for
details.
2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments
are converted to a stable JSON string, which is then tested against the
provided regular expression. If the arguments don't match the pattern, the
@@ -272,13 +274,12 @@ priority = 100
### Special syntax for MCP tools
You can create rules that target tools from Model-hosting-protocol (MCP) servers
using the `mcpName` field or a wildcard pattern.
You can create rules that target tools from Model Context Protocol (MCP) servers
using the `mcpName` field or composite wildcard patterns.
**1. Using `mcpName`**
**1. Targeting a specific tool on a server**
To target a specific tool from a specific server, combine `mcpName` and
`toolName`.
Combine `mcpName` and `toolName` to target a single operation.
```toml
# Allows the `search` tool on the `my-jira-server` MCP
@@ -289,10 +290,10 @@ decision = "allow"
priority = 200
```
**2. Using a wildcard**
**2. Targeting all tools on a specific server**
To create a rule that applies to _all_ tools on a specific MCP server, specify
only the `mcpName`.
Specify only the `mcpName` to apply a rule to every tool provided by that
server.
```toml
# Denies all tools from the `untrusted-server` MCP
@@ -303,6 +304,33 @@ priority = 500
deny_message = "This server is not trusted by the admin."
```
**3. Targeting all MCP servers**
Use `mcpName = "*"` to create a rule that applies to **all** tools from **any**
registered MCP server. This is useful for setting category-wide defaults.
```toml
# Ask user for any tool call from any MCP server
[[rule]]
mcpName = "*"
decision = "ask_user"
priority = 10
```
**4. Targeting a tool name across all servers**
Use `mcpName = "*"` with a specific `toolName` to target that operation
regardless of which server provides it.
```toml
# Allow the `search` tool across all connected MCP servers
[[rule]]
mcpName = "*"
toolName = "search"
decision = "allow"
priority = 50
```
## Default policies
The Gemini CLI ships with a set of default policies to provide a safe
+12 -5
View File
@@ -135,6 +135,18 @@ Flow video editor). These plans do not apply to the API usage which powers the
Gemini CLI. Supporting these plans is under active consideration for future
support.
## Check usage and quota
You can check your current token usage and quota information using the
`/stats model` command. This command provides a snapshot of your current
session's token usage, as well as your overall quota and usage for the supported
models.
For more information on the `/stats` command and its subcommands, see the
[Command Reference](../../reference/commands.md#stats).
A summary of model usage is also presented on exit at the end of a session.
## Tips to avoid high costs
When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
@@ -151,8 +163,3 @@ costs.
models directly.
- Vertex AI: This is the enterprise-grade platform for building, deploying, and
managing Gemini models with specific security and control requirements.
## Understanding your usage
A summary of model usage is available through the `/stats` command and presented
on exit at the end of a session.
+199 -165
View File
@@ -1,199 +1,233 @@
[
{
"label": "Get started",
"items": [
{ "label": "Overview", "slug": "docs" },
{ "label": "Quickstart", "slug": "docs/get-started" },
{ "label": "Installation", "slug": "docs/get-started/installation" },
{ "label": "Authentication", "slug": "docs/get-started/authentication" },
{ "label": "Examples", "slug": "docs/get-started/examples" },
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
{ "label": "Gemini 3 on Gemini CLI", "slug": "docs/get-started/gemini-3" }
]
},
{
"label": "Use Gemini CLI",
"label": "docs_tab",
"items": [
{
"label": "File management",
"slug": "docs/cli/tutorials/file-management"
"label": "Get started",
"items": [
{ "label": "Overview", "slug": "docs" },
{ "label": "Quickstart", "slug": "docs/get-started" },
{ "label": "Installation", "slug": "docs/get-started/installation" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Examples", "slug": "docs/get-started/examples" },
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
{
"label": "Gemini 3 on Gemini CLI",
"slug": "docs/get-started/gemini-3"
}
]
},
{
"label": "Get started with Agent skills",
"slug": "docs/cli/tutorials/skills-getting-started"
"label": "Use Gemini CLI",
"items": [
{
"label": "File management",
"slug": "docs/cli/tutorials/file-management"
},
{
"label": "Get started with Agent skills",
"slug": "docs/cli/tutorials/skills-getting-started"
},
{
"label": "Manage context and memory",
"slug": "docs/cli/tutorials/memory-management"
},
{
"label": "Execute shell commands",
"slug": "docs/cli/tutorials/shell-commands"
},
{
"label": "Manage sessions and history",
"slug": "docs/cli/tutorials/session-management"
},
{
"label": "Plan tasks with todos",
"slug": "docs/cli/tutorials/task-planning"
},
{
"label": "Web search and fetch",
"slug": "docs/cli/tutorials/web-tools"
},
{
"label": "Set up an MCP server",
"slug": "docs/cli/tutorials/mcp-setup"
},
{ "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" }
]
},
{
"label": "Manage context and memory",
"slug": "docs/cli/tutorials/memory-management"
"label": "Features",
"items": [
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{
"label": "Extensions",
"slug": "docs/extensions/index"
},
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
{
"label": "Subagents",
"badge": "🧪",
"slug": "docs/core/subagents"
},
{
"label": "Remote subagents",
"badge": "🧪",
"slug": "docs/core/remote-agents"
},
{ "label": "Rewind", "slug": "docs/cli/rewind" },
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
{ "label": "Settings", "slug": "docs/cli/settings" },
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
{ "label": "Token caching", "slug": "docs/cli/token-caching" }
]
},
{
"label": "Execute shell commands",
"slug": "docs/cli/tutorials/shell-commands"
"label": "Configuration",
"items": [
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
{
"label": "Enterprise configuration",
"slug": "docs/cli/enterprise"
},
{
"label": "Ignore files (.geminiignore)",
"slug": "docs/cli/gemini-ignore"
},
{
"label": "Model configuration",
"slug": "docs/cli/generation-settings"
},
{
"label": "Project context (GEMINI.md)",
"slug": "docs/cli/gemini-md"
},
{ "label": "Settings", "slug": "docs/cli/settings" },
{
"label": "System prompt override",
"slug": "docs/cli/system-prompt"
},
{ "label": "Themes", "slug": "docs/cli/themes" },
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
]
},
{
"label": "Manage sessions and history",
"slug": "docs/cli/tutorials/session-management"
},
{
"label": "Plan tasks with todos",
"slug": "docs/cli/tutorials/task-planning"
},
{
"label": "Web search and fetch",
"slug": "docs/cli/tutorials/web-tools"
},
{
"label": "Set up an MCP server",
"slug": "docs/cli/tutorials/mcp-setup"
},
{ "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" }
]
},
{
"label": "Features",
"items": [
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{
"label": "Extensions",
"slug": "docs/extensions/index"
},
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Help", "link": "/docs/reference/commands/#help-or" },
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{
"label": "Memory management",
"link": "/docs/reference/commands/#memory"
},
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
{ "label": "Subagents", "badge": "🧪", "slug": "docs/core/subagents" },
{
"label": "Remote subagents",
"badge": "🧪",
"slug": "docs/core/remote-agents"
},
{ "label": "Rewind", "slug": "docs/cli/rewind" },
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
{ "label": "Settings", "slug": "docs/cli/settings" },
{
"label": "Shell",
"link": "/docs/reference/commands/#shells-or-bashes"
"items": [
{
"label": "Overview",
"slug": "docs/extensions"
},
{
"label": "User guide: Install and manage",
"link": "/docs/extensions/#manage-extensions"
},
{
"label": "Developer guide: Build extensions",
"slug": "docs/extensions/writing-extensions"
},
{
"label": "Developer guide: Best practices",
"slug": "docs/extensions/best-practices"
},
{
"label": "Developer guide: Releasing",
"slug": "docs/extensions/releasing"
},
{
"label": "Developer guide: Reference",
"slug": "docs/extensions/reference"
}
]
},
{
"label": "Stats",
"link": "/docs/reference/commands/#stats"
},
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
{ "label": "Tools", "link": "/docs/reference/commands/#tools" }
]
},
{
"label": "Configuration",
"items": [
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
{ "label": "Enterprise configuration", "slug": "docs/cli/enterprise" },
{
"label": "Ignore files (.geminiignore)",
"slug": "docs/cli/gemini-ignore"
},
{
"label": "Model configuration",
"slug": "docs/cli/generation-settings"
},
{ "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" },
{ "label": "Settings", "slug": "docs/cli/settings" },
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
{ "label": "Themes", "slug": "docs/cli/themes" },
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
]
},
{
"label": "Extensions",
"items": [
{
"label": "Overview",
"slug": "docs/extensions"
},
{
"label": "User guide: Install and manage",
"link": "/docs/extensions/#manage-extensions"
},
{
"label": "Developer guide: Build extensions",
"slug": "docs/extensions/writing-extensions"
},
{
"label": "Developer guide: Best practices",
"slug": "docs/extensions/best-practices"
},
{
"label": "Developer guide: Releasing",
"slug": "docs/extensions/releasing"
},
{
"label": "Developer guide: Reference",
"slug": "docs/extensions/reference"
"label": "Development",
"items": [
{ "label": "Contribution guide", "slug": "docs/contributing" },
{ "label": "Integration testing", "slug": "docs/integration-tests" },
{
"label": "Issue and PR automation",
"slug": "docs/issue-and-pr-automation"
},
{ "label": "Local development", "slug": "docs/local-development" },
{ "label": "NPM package structure", "slug": "docs/npm" }
]
}
]
},
{
"label": "Reference",
"label": "reference_tab",
"items": [
{ "label": "Command reference", "slug": "docs/reference/commands" },
{
"label": "Configuration reference",
"slug": "docs/reference/configuration"
},
{
"label": "Keyboard shortcuts",
"slug": "docs/reference/keyboard-shortcuts"
},
{ "label": "Memory import processor", "slug": "docs/reference/memport" },
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
{ "label": "Tools API", "slug": "docs/reference/tools-api" }
"label": "Reference",
"items": [
{ "label": "Command reference", "slug": "docs/reference/commands" },
{
"label": "Configuration reference",
"slug": "docs/reference/configuration"
},
{
"label": "Keyboard shortcuts",
"slug": "docs/reference/keyboard-shortcuts"
},
{
"label": "Memory import processor",
"slug": "docs/reference/memport"
},
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
{ "label": "Tools API", "slug": "docs/reference/tools-api" }
]
}
]
},
{
"label": "Resources",
"label": "resources_tab",
"items": [
{ "label": "FAQ", "slug": "docs/resources/faq" },
{
"label": "Quota and pricing",
"slug": "docs/resources/quota-and-pricing"
},
{ "label": "Terms and privacy", "slug": "docs/resources/tos-privacy" },
{ "label": "Troubleshooting", "slug": "docs/resources/troubleshooting" },
{ "label": "Uninstall", "slug": "docs/resources/uninstall" }
"label": "Resources",
"items": [
{ "label": "FAQ", "slug": "docs/resources/faq" },
{
"label": "Quota and pricing",
"slug": "docs/resources/quota-and-pricing"
},
{
"label": "Terms and privacy",
"slug": "docs/resources/tos-privacy"
},
{
"label": "Troubleshooting",
"slug": "docs/resources/troubleshooting"
},
{ "label": "Uninstall", "slug": "docs/resources/uninstall" }
]
}
]
},
{
"label": "Development",
"label": "releases_tab",
"items": [
{ "label": "Contribution guide", "slug": "docs/contributing" },
{ "label": "Integration testing", "slug": "docs/integration-tests" },
{
"label": "Issue and PR automation",
"slug": "docs/issue-and-pr-automation"
},
{ "label": "Local development", "slug": "docs/local-development" },
{ "label": "NPM package structure", "slug": "docs/npm" }
]
},
{
"label": "Releases",
"items": [
{ "label": "Release notes", "slug": "docs/changelogs/" },
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
"label": "Releases",
"items": [
{ "label": "Release notes", "slug": "docs/changelogs/" },
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
]
}
]
}
]
+3
View File
@@ -52,6 +52,9 @@ These tools help the model manage its plan and interact with you.
complex plans.
- **[Agent Skills](../cli/skills.md) (`activate_skill`):** Loads specialized
procedural expertise when needed.
- **[Browser agent](../core/subagents.md#browser-agent-experimental)
(`browser_agent`):** Automates web browser tasks through the accessibility
tree.
- **Internal docs (`get_internal_docs`):** Accesses Gemini CLI's own
documentation to help answer your questions.
+3 -1
View File
@@ -56,7 +56,7 @@ export default tseslint.config(
},
{
// Import specific config
files: ['packages/cli/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package
files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages
plugins: {
import: importPlugin,
},
@@ -200,6 +200,8 @@ export default tseslint.config(
ignores: ['**/*.test.ts', '**/*.test.tsx'],
rules: {
'@typescript-eslint/no-unsafe-type-assertion': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
},
},
{
+170
View File
@@ -0,0 +1,170 @@
/**
* @license
* Copyright 202 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest, TestRig } from './test-helper.js';
import {
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
describe('grep_search_functionality', () => {
const TEST_PREFIX = 'Grep Search Functionality: ';
evalTest('USUALLY_PASSES', {
name: 'should find a simple string in a file',
files: {
'test.txt': `hello
world
hello world`,
},
prompt: 'Find "world" in test.txt',
assert: async (rig: TestRig, result: string) => {
await rig.waitForToolCall('grep_search');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/L2: world/, /L3: hello world/],
testName: `${TEST_PREFIX}simple search`,
});
},
});
evalTest('USUALLY_PASSES', {
name: 'should perform a case-sensitive search',
files: {
'test.txt': `Hello
hello`,
},
prompt: 'Find "Hello" in test.txt, case-sensitively.',
assert: async (rig: TestRig, result: string) => {
const wasToolCalled = await rig.waitForToolCall(
'grep_search',
undefined,
(args) => {
const params = JSON.parse(args);
return params.case_sensitive === true;
},
);
expect(
wasToolCalled,
'Expected grep_search to be called with case_sensitive: true',
).toBe(true);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/L1: Hello/],
forbiddenContent: [/L2: hello/],
testName: `${TEST_PREFIX}case-sensitive search`,
});
},
});
evalTest('USUALLY_PASSES', {
name: 'should return only file names when names_only is used',
files: {
'file1.txt': 'match me',
'file2.txt': 'match me',
},
prompt: 'Find the files containing "match me".',
assert: async (rig: TestRig, result: string) => {
const wasToolCalled = await rig.waitForToolCall(
'grep_search',
undefined,
(args) => {
const params = JSON.parse(args);
return params.names_only === true;
},
);
expect(
wasToolCalled,
'Expected grep_search to be called with names_only: true',
).toBe(true);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/file1.txt/, /file2.txt/],
forbiddenContent: [/L1:/],
testName: `${TEST_PREFIX}names_only search`,
});
},
});
evalTest('USUALLY_PASSES', {
name: 'should search only within the specified include glob',
files: {
'file.js': 'my_function();',
'file.ts': 'my_function();',
},
prompt: 'Find "my_function" in .js files.',
assert: async (rig: TestRig, result: string) => {
const wasToolCalled = await rig.waitForToolCall(
'grep_search',
undefined,
(args) => {
const params = JSON.parse(args);
return params.include === '*.js';
},
);
expect(
wasToolCalled,
'Expected grep_search to be called with include: "*.js"',
).toBe(true);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/file.js/],
forbiddenContent: [/file.ts/],
testName: `${TEST_PREFIX}include glob search`,
});
},
});
evalTest('USUALLY_PASSES', {
name: 'should search within a specific subdirectory',
files: {
'src/main.js': 'unique_string_1',
'lib/main.js': 'unique_string_2',
},
prompt: 'Find "unique_string" in the src directory.',
assert: async (rig: TestRig, result: string) => {
const wasToolCalled = await rig.waitForToolCall(
'grep_search',
undefined,
(args) => {
const params = JSON.parse(args);
return params.dir_path === 'src';
},
);
expect(
wasToolCalled,
'Expected grep_search to be called with dir_path: "src"',
).toBe(true);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/unique_string_1/],
forbiddenContent: [/unique_string_2/],
testName: `${TEST_PREFIX}subdirectory search`,
});
},
});
evalTest('USUALLY_PASSES', {
name: 'should report no matches correctly',
files: {
'file.txt': 'nothing to see here',
},
prompt: 'Find "nonexistent" in file.txt',
assert: async (rig: TestRig, result: string) => {
await rig.waitForToolCall('grep_search');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/No matches found/],
testName: `${TEST_PREFIX}no matches`,
});
},
});
});
+1 -1
View File
@@ -26,7 +26,7 @@ class MockClient implements acp.Client {
};
}
describe('ACP Environment and Auth', () => {
describe.skip('ACP Environment and Auth', () => {
let rig: TestRig;
let child: ChildProcess | undefined;
@@ -0,0 +1,12 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/1"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/2"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/3"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/4"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/5"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/6"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/7"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/8"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/9"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/10"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/11"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":500,"totalTokenCount":600}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 1 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 2 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 3 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 4 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 5 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 6 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 7 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 8 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 9 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 10 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Some requests were rate limited: Rate limit exceeded for host. Please wait 60 seconds before trying again."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1000,"candidatesTokenCount":50,"totalTokenCount":1050}}]}
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
describe('web-fetch rate limiting', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => {
if (rig) {
await rig.cleanup();
}
});
it('should rate limit multiple requests to the same host', async () => {
rig.setup('web-fetch rate limit', {
settings: { tools: { core: ['web_fetch'] } },
fakeResponsesPath: join(
import.meta.dirname,
'concurrency-limit.responses',
),
});
const result = await rig.run({
args: `Fetch 11 pages from example.com`,
});
// We expect to find at least one tool call that failed with a rate limit error.
const toolLogs = rig.readToolLogs();
const rateLimitedCalls = toolLogs.filter(
(log) =>
log.toolRequest.name === 'web_fetch' &&
log.toolRequest.error?.includes('Rate limit exceeded'),
);
expect(rateLimitedCalls.length).toBeGreaterThan(0);
expect(result).toContain('Rate limit exceeded');
});
});
+288 -216
View File
@@ -1402,19 +1402,22 @@
"link": true
},
"node_modules/@google/genai": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz",
"integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==",
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.42.0.tgz",
"integrity": "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.3.0",
"p-retry": "^4.6.2",
"protobufjs": "^7.5.4",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.20.1"
"@modelcontextprotocol/sdk": "^1.25.2"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
@@ -1422,10 +1425,18 @@
}
}
},
"node_modules/@google/genai/node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"dev": true,
"license": "MIT"
},
"node_modules/@google/genai/node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
@@ -1441,6 +1452,7 @@
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
@@ -1455,6 +1467,7 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
@@ -1473,6 +1486,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=14"
@@ -1482,6 +1496,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
@@ -1495,6 +1510,7 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
@@ -1509,6 +1525,20 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/@google/genai/node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
@@ -2979,16 +3009,6 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -5133,46 +5153,6 @@
"win32"
]
},
"node_modules/@vscode/vsce/node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -5212,33 +5192,6 @@
"node": ">=4"
}
},
"node_modules/@vscode/vsce/node_modules/path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@vscode/vsce/node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@vscode/vsce/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -5816,6 +5769,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
@@ -10478,6 +10432,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-network-error": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-node-process": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
@@ -12908,6 +12874,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-json": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz",
@@ -13447,9 +13428,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz",
"integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -14164,81 +14145,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rollup": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz",
@@ -15706,43 +15612,6 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/test-exclude/node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -15759,23 +15628,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/test-exclude/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
@@ -17310,7 +17162,7 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
"@google/genai": "1.41.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"ansi-escapes": "^7.3.0",
@@ -17370,6 +17222,29 @@
"node": ">=20"
}
},
"packages/cli/node_modules/@google/genai": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz",
"integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.3.0",
"p-retry": "^7.1.1",
"protobufjs": "^7.5.4",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.25.2"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"packages/cli/node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
@@ -17385,6 +17260,93 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/cli/node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"packages/cli/node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"packages/cli/node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"packages/cli/node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"packages/cli/node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"packages/cli/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"packages/cli/node_modules/string-width": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
@@ -17410,7 +17372,7 @@
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google/genai": "1.30.0",
"@google/genai": "1.41.0",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
"@modelcontextprotocol/sdk": "^1.23.0",
@@ -17442,7 +17404,7 @@
"fdir": "^6.4.6",
"fzf": "^0.5.2",
"glob": "^12.0.0",
"google-auth-library": "^9.11.0",
"google-auth-library": "^10.5.0",
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
@@ -17488,6 +17450,29 @@
"node-pty": "^1.0.0"
}
},
"packages/core/node_modules/@google/genai": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz",
"integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.3.0",
"p-retry": "^7.1.1",
"protobufjs": "^7.5.4",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.25.2"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"packages/core/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -17518,6 +17503,75 @@
}
}
},
"packages/core/node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"packages/core/node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"packages/core/node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"packages/core/node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"packages/core/node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"packages/core/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -17542,6 +17596,24 @@
"node": ">=16"
}
},
"packages/core/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"packages/core/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+3 -1
View File
@@ -68,7 +68,9 @@
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
}
},
"glob": "^12.0.0",
"node-domexception": "^1.0.0"
},
"bin": {
"gemini": "bundle/gemini.js"
+7 -5
View File
@@ -14,19 +14,21 @@ import {
type Mock,
} from 'vitest';
import { Task } from './task.js';
import type {
ToolCall,
Config,
ToolCallRequestInfo,
GitService,
CompletedToolCall,
} from '@google/gemini-cli-core';
import {
GeminiEventType,
type Config,
type ToolCallRequestInfo,
type GitService,
type CompletedToolCall,
ApprovalMode,
ToolConfirmationOutcome,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
import { CoderAgentEvent } from '../types.js';
import type { ToolCall } from '@google/gemini-cli-core';
const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn());
+7 -6
View File
@@ -13,6 +13,7 @@ import {
getAllMCPServerStatuses,
MCPServerStatus,
isNodeError,
getErrorMessage,
parseAndFormatApiError,
safeLiteralReplace,
DEFAULT_GUI_EDITOR,
@@ -30,8 +31,7 @@ import {
EDIT_TOOL_NAMES,
processRestorableToolCalls,
} from '@google/gemini-cli-core';
import type { RequestContext } from '@a2a-js/sdk/server';
import { type ExecutionEventBus } from '@a2a-js/sdk/server';
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
import type {
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
@@ -728,16 +728,17 @@ export class Task {
// 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';
const errorMessage = errorEvent.value?.error
? getErrorMessage(errorEvent.value.error)
: 'Unknown error from LLM stream';
logger.error(
'[Task] Received error event from LLM stream:',
errorMessage,
);
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
if (errorEvent.value) {
errMessage = parseAndFormatApiError(errorEvent.value);
if (errorEvent.value?.error) {
errMessage = parseAndFormatApiError(errorEvent.value.error);
}
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
this.setTaskStateAndPublishUpdate(
@@ -69,6 +69,7 @@ export class RestoreCommand implements Command {
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const toolCallData = JSON.parse(data);
const ToolCallDataSchema = getToolCallDataSchema();
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
+5 -3
View File
@@ -8,17 +8,19 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as dotenv from 'dotenv';
import type { TelemetryTarget } from '@google/gemini-cli-core';
import type {
TelemetryTarget,
ConfigParameters,
ExtensionLoader,
} from '@google/gemini-cli-core';
import {
AuthType,
Config,
type ConfigParameters,
FileDiscoveryService,
ApprovalMode,
loadServerHierarchicalMemory,
GEMINI_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL,
type ExtensionLoader,
startupProfiler,
PREVIEW_GEMINI_MODEL,
homedir,
+3 -2
View File
@@ -7,8 +7,8 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { MCPServerConfig } from '@google/gemini-cli-core';
import {
type MCPServerConfig,
debugLogger,
GEMINI_DIR,
getErrorMessage,
@@ -122,6 +122,7 @@ export function loadSettings(workspaceDir: string): Settings {
function resolveEnvVarsInString(value: string): string {
const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
return value.replace(envVarRegex, (match, varName1, varName2) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const varName = varName1 || varName2;
if (process && process.env && typeof process.env[varName] === 'string') {
return process.env[varName];
@@ -146,7 +147,7 @@ function resolveEnvVarsInObject<T>(obj: T): T {
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
}
+4 -5
View File
@@ -4,12 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@google/gemini-cli-core';
import {
GeminiEventType,
ApprovalMode,
type ToolCallConfirmationDetails,
import type {
Config,
ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core';
import type {
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
+8 -3
View File
@@ -7,8 +7,8 @@
import express from 'express';
import type { AgentCard, Message } from '@a2a-js/sdk';
import type { TaskStore } from '@a2a-js/sdk/server';
import {
type TaskStore,
DefaultRequestHandler,
InMemoryTaskStore,
DefaultExecutionEventBus,
@@ -25,9 +25,12 @@ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
import { loadExtensions } from '../config/extension.js';
import { commandRegistry } from '../commands/command-registry.js';
import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core';
import {
debugLogger,
SimpleExtensionLoader,
GitService,
} from '@google/gemini-cli-core';
import type { Command, CommandArgument } from '../commands/types.js';
import { GitService } from '@google/gemini-cli-core';
type CommandResponse = {
name: string;
@@ -88,6 +91,7 @@ async function handleExecuteCommand(
},
) {
logger.info('[CoreAgent] Received /executeCommand request: ', req.body);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { command, args } = req.body;
try {
if (typeof command !== 'string') {
@@ -211,6 +215,7 @@ export async function createApp() {
const agentSettings = req.body.agentSettings as
| AgentSettings
| undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const contextId = req.body.contextId || uuidv4();
const wrapper = await agentExecutor.createTask(
taskId,
@@ -246,6 +246,7 @@ export class GCSTaskStore implements TaskStore {
}
const [compressedMetadata] = await metadataFile.download();
const jsonData = gunzipSync(compressedMetadata).toString();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const loadedMetadata = JSON.parse(jsonData);
logger.info(`Task ${taskId} metadata loaded from GCS.`);
@@ -282,12 +283,14 @@ export class GCSTaskStore implements TaskStore {
return {
id: taskId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
contextId: loadedMetadata._contextId || uuidv4(),
kind: 'task',
status: {
state: persistedState._taskState,
timestamp: new Date().toISOString(),
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
metadata: loadedMetadata,
history: [],
artifacts: [],
+16 -1
View File
@@ -36,7 +36,21 @@ process.on('uncaughtException', (error) => {
});
main().catch(async (error) => {
await runExitCleanup();
// Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => {
writeToStderr('Cleanup timed out, forcing exit...\n');
process.exit(1);
}, 5000);
try {
await runExitCleanup();
} catch (cleanupError) {
writeToStderr(
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
);
} finally {
clearTimeout(cleanupTimeout);
}
if (error instanceof FatalError) {
let errorMessage = error.message;
@@ -46,6 +60,7 @@ main().catch(async (error) => {
writeToStderr(errorMessage + '\n');
process.exit(error.exitCode);
}
writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
+1 -1
View File
@@ -31,7 +31,7 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
"@google/genai": "1.41.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"ansi-escapes": "^7.3.0",
@@ -47,6 +47,7 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async (
message,
initial: false,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.confirm;
};
-1
View File
@@ -848,7 +848,6 @@ export async function loadCliConfig(
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
@@ -866,6 +866,7 @@ Would you like to attempt to install via "git clone" instead?`,
try {
const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const rawHooks = JSON.parse(hooksContent);
if (
@@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => {
'Failed to fetch extensions: Not Found',
);
});
it('should not return irrelevant results', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => [
...mockExtensions,
{
id: 'dataplex',
extensionName: 'dataplex',
extensionDescription: 'Connect to Dataplex Universal Catalog...',
fullName: 'google-cloud/dataplex',
rank: 6,
stars: 6,
url: '',
repoDescription: '',
lastUpdated: '',
extensionVersion: '1.0.0',
avatarUrl: '',
hasMCP: false,
hasContext: false,
isGoogleOwned: true,
licenseKey: '',
hasHooks: false,
hasCustomCommands: false,
hasSkills: false,
},
{
id: 'conductor',
extensionName: 'conductor',
extensionDescription: 'A conductor extension that actually matches.',
fullName: 'someone/conductor',
rank: 100,
stars: 100,
url: '',
repoDescription: '',
lastUpdated: '',
extensionVersion: '1.0.0',
avatarUrl: '',
hasMCP: false,
hasContext: false,
isGoogleOwned: false,
licenseKey: '',
hasHooks: false,
hasCustomCommands: false,
hasSkills: false,
},
],
});
const results = await client.searchExtensions('conductor');
const ids = results.map((r) => r.id);
expect(ids).not.toContain('dataplex');
expect(ids).toContain('conductor');
});
});
@@ -79,9 +79,11 @@ export class ExtensionRegistryClient {
const fzf = new AsyncFzf(allExtensions, {
selector: (ext: RegistryExtension) =>
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
fuzzy: 'v2',
fuzzy: true,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const results = await fzf.find(query);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return results.map((r: { item: RegistryExtension }) => r.item);
}
@@ -108,7 +110,6 @@ export class ExtensionRegistryClient {
// 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
ExtensionRegistryClient.fetchPromise = null;
throw error;
}
@@ -179,6 +179,7 @@ export class ExtensionEnablementManager {
readConfig(): AllExtensionsEnablementConfig {
try {
const content = fs.readFileSync(this.configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(content);
} catch (error) {
if (
@@ -156,6 +156,7 @@ export async function promptForSetting(
name: 'value',
message: `${setting.name}\n${setting.description}`,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.value;
}
@@ -58,6 +58,7 @@ export function recursivelyHydrateStrings<T>(
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
recursivelyHydrateStrings(item, values),
) as unknown as T;
}
@@ -132,6 +132,35 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.ASK_USER);
});
it('should handle global MCP wildcard (*) in settings', async () => {
const settings: Settings = {
mcp: {
allowed: ['*'],
},
};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
);
const engine = new PolicyEngine(config);
// ANY tool with a server name should be allowed
expect(
(await engine.check({ name: 'mcp-server__tool' }, 'mcp-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'another-server__tool' }, 'another-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
// Built-in tools should NOT be allowed by the MCP wildcard
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => {
const settings: Settings = {
mcp: {
@@ -339,6 +368,8 @@ describe('Policy Engine Integration Tests', () => {
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md',
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md',
'/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory
'C:\\Users\\user\\.gemini\\tmp\\project-id\\session-id\\plans\\plan.md',
'D:\\gemini-cli\\.gemini\\tmp\\project-id\\session-1\\plans\\plan.md', // no session ID
];
for (const file_path of validPaths) {
@@ -364,7 +395,8 @@ describe('Policy Engine Integration Tests', () => {
const invalidPaths = [
'/project/src/file.ts', // Workspace
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal (Unix)
'C:\\Users\\user\\.gemini\\tmp\\id\\session\\plans\\..\\..\\..\\Windows\\System32\\config\\SAM', // Path traversal (Windows)
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
];
+2 -1
View File
@@ -41,8 +41,9 @@ export async function createPolicyEngineConfig(
export function createPolicyUpdater(
policyEngine: PolicyEngine,
messageBus: MessageBus,
storage: Storage,
) {
return createCorePolicyUpdater(policyEngine, messageBus);
return createCorePolicyUpdater(policyEngine, messageBus, storage);
}
export interface WorkspacePolicyState {
+54 -10
View File
@@ -287,16 +287,6 @@ const SETTINGS_SCHEMA = {
},
},
},
enablePromptCompletion: {
type: 'boolean',
label: 'Enable Prompt Completion',
category: 'General',
requiresRestart: true,
default: false,
description:
'Enable AI-powered prompt completion suggestions while typing.',
showInDialog: true,
},
retryFetchErrors: {
type: 'boolean',
label: 'Retry Fetch Errors',
@@ -974,6 +964,60 @@ const SETTINGS_SCHEMA = {
ref: 'AgentOverride',
},
},
browser: {
type: 'object',
label: 'Browser Agent',
category: 'Advanced',
requiresRestart: true,
default: {},
description: 'Settings specific to the browser agent.',
showInDialog: false,
properties: {
sessionMode: {
type: 'enum',
label: 'Browser Session Mode',
category: 'Advanced',
requiresRestart: true,
default: 'persistent',
description:
"Session mode: 'persistent', 'isolated', or 'existing'.",
showInDialog: false,
options: [
{ value: 'persistent', label: 'Persistent' },
{ value: 'isolated', label: 'Isolated' },
{ value: 'existing', label: 'Existing' },
],
},
headless: {
type: 'boolean',
label: 'Browser Headless',
category: 'Advanced',
requiresRestart: true,
default: false,
description: 'Run browser in headless mode.',
showInDialog: false,
},
profilePath: {
type: 'string',
label: 'Browser Profile Path',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description:
'Path to browser profile directory for session persistence.',
showInDialog: false,
},
visualModel: {
type: 'string',
label: 'Browser Visual Model',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Model override for the visual agent.',
showInDialog: false,
},
},
},
},
},
@@ -131,7 +131,6 @@ describe('Settings Repro', () => {
},
general: {
debugKeystrokeLogging: false,
enablePromptCompletion: false,
preferredEditor: 'vim',
vimMode: false,
},
+6 -2
View File
@@ -38,6 +38,8 @@ import { appEvents, AppEvent } from './utils/events.js';
import {
type Config,
type ResumedSessionData,
type StartupWarning,
WarningPriority,
debugLogger,
coreEvents,
AuthType,
@@ -1193,7 +1195,9 @@ describe('startInteractiveUI', () => {
},
},
} as LoadedSettings;
const mockStartupWarnings = ['warning1'];
const mockStartupWarnings: StartupWarning[] = [
{ id: 'w1', message: 'warning1', priority: WarningPriority.High },
];
const mockWorkspaceRoot = '/root';
const mockInitializationResult = {
authError: null,
@@ -1226,7 +1230,7 @@ describe('startInteractiveUI', () => {
async function startTestInteractiveUI(
config: Config,
settings: LoadedSettings,
startupWarnings: string[],
startupWarnings: StartupWarning[],
workspaceRoot: string,
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
+33 -16
View File
@@ -11,6 +11,7 @@ import { loadCliConfig, parseArguments } from './config/config.js';
import * as cliConfig from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
import { createHash } from 'node:crypto';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
@@ -37,6 +38,8 @@ import {
cleanupExpiredSessions,
} from './utils/sessionCleanup.js';
import {
type StartupWarning,
WarningPriority,
type Config,
type ResumedSessionData,
type OutputPayload,
@@ -99,6 +102,7 @@ import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
@@ -180,7 +184,7 @@ ${reason.stack}`
export async function startInteractiveUI(
config: Config,
settings: LoadedSettings,
startupWarnings: string[],
startupWarnings: StartupWarning[],
workspaceRoot: string = process.cwd(),
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
@@ -235,17 +239,19 @@ export async function startInteractiveUI(
>
<TerminalProvider>
<ScrollProvider>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</OverflowProvider>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
@@ -580,7 +586,7 @@ export async function main() {
const policyEngine = config.getPolicyEngine();
const messageBus = config.getMessageBus();
createPolicyUpdater(policyEngine, messageBus);
createPolicyUpdater(policyEngine, messageBus, config.storage);
// Register SessionEnd hook to fire on graceful exit
// This runs before telemetry shutdown in runExitCleanup()
@@ -668,9 +674,20 @@ export async function main() {
}
let input = config.getQuestion();
const startupWarnings = [
...(await getStartupWarnings()),
...(await getUserStartupWarnings(settings.merged)),
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(settings),
config.getScreenReader(),
);
const rawStartupWarnings = await getStartupWarnings();
const startupWarnings: StartupWarning[] = [
...rawStartupWarnings.map((message) => ({
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
message,
priority: WarningPriority.High,
})),
...(await getUserStartupWarnings(settings.merged, undefined, {
isAlternateBuffer: useAlternateBuffer,
})),
];
// Handle --resume flag
+3 -3
View File
@@ -13,6 +13,7 @@ import type {
import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js';
import {
convertSessionToClientHistory,
GeminiEventType,
FatalInputError,
promptIdContext,
@@ -35,7 +36,6 @@ import type { Content, Part } from '@google/genai';
import readline from 'node:readline';
import stripAnsi from 'strip-ansi';
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
@@ -220,9 +220,9 @@ export async function runNonInteractive({
// Initialize chat. Resume if resume data is passed.
if (resumedSessionData) {
await geminiClient.resumeChat(
convertSessionToHistoryFormats(
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
).clientHistory,
),
resumedSessionData,
);
}
+2 -1
View File
@@ -217,13 +217,14 @@ export class AppRig {
}
private stubRefreshAuth() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
const gcConfig = this.config as any;
gcConfig.refreshAuth = async (authMethod: AuthType) => {
gcConfig.modelAvailabilityService.reset();
const newContentGeneratorConfig = {
authType: authMethod,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
proxy: gcConfig.getProxy(),
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
};
@@ -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, @typescript-eslint/no-unsafe-type-assertion
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
const { isNot } = this as any;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
@@ -45,7 +45,7 @@ export const createMockCommandContext = (
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
logger: {
log: vi.fn(),
logMessage: vi.fn(),
@@ -54,7 +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
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
ui: {
addItem: vi.fn(),
clear: vi.fn(),
@@ -94,11 +94,14 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const merge = (target: any, source: any): any => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const output = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const sourceValue = source[key];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const targetValue = output[key];
if (
@@ -106,9 +109,11 @@ export const createMockCommandContext = (
Object.prototype.toString.call(sourceValue) === '[object Object]' &&
Object.prototype.toString.call(targetValue) === '[object Object]'
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
output[key] = merge(targetValue, sourceValue);
} else {
// If not, we do a direct assignment. This preserves Date objects and others.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
output[key] = sourceValue;
}
}
@@ -116,5 +121,6 @@ export const createMockCommandContext = (
return output;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return merge(defaultMocks, overrides);
};
@@ -128,7 +128,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
setShellExecutionConfig: vi.fn(),
getEnablePromptCompletion: vi.fn().mockReturnValue(false),
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
+88 -32
View File
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render as inkRenderDirect, type Instance as InkInstance } from 'ink';
import {
render as inkRenderDirect,
type Instance as InkInstance,
type RenderOptions,
} from 'ink';
import { EventEmitter } from 'node:events';
import { Box } from 'ink';
import type React from 'react';
@@ -31,6 +35,13 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
import {
OverflowProvider,
useOverflowActions,
useOverflowState,
type OverflowActions,
type OverflowState,
} from '../ui/contexts/OverflowContext.js';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
@@ -68,6 +79,25 @@ type TerminalState = {
rows: number;
};
type RenderMetrics = Parameters<NonNullable<RenderOptions['onRender']>>[0];
interface InkRenderMetrics extends RenderMetrics {
output: string;
staticOutput?: string;
}
function isInkRenderMetrics(
metrics: RenderMetrics,
): metrics is InkRenderMetrics {
const m = metrics as Record<string, unknown>;
return (
typeof m === 'object' &&
m !== null &&
'output' in m &&
typeof m['output'] === 'string'
);
}
class XtermStdout extends EventEmitter {
private state: TerminalState;
private pendingWrites = 0;
@@ -312,6 +342,8 @@ export type RenderInstance = {
lastFrame: (options?: { allowEmpty?: boolean }) => string;
terminal: Terminal;
waitUntilReady: () => Promise<void>;
capturedOverflowState: OverflowState | undefined;
capturedOverflowActions: OverflowActions | undefined;
};
const instances: InkInstance[] = [];
@@ -320,7 +352,10 @@ const instances: InkInstance[] = [];
export const render = (
tree: React.ReactElement,
terminalWidth?: number,
): RenderInstance => {
): Omit<
RenderInstance,
'capturedOverflowState' | 'capturedOverflowActions'
> => {
const cols = terminalWidth ?? 100;
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
// value was used (e.g. 40 rows). The alternatives to make things worse are
@@ -357,8 +392,10 @@ export const render = (
debug: false,
exitOnCtrlC: false,
patchConsole: false,
onRender: (metrics: { output: string; staticOutput?: string }) => {
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
onRender: (metrics: RenderMetrics) => {
if (isInkRenderMetrics(metrics)) {
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
}
},
});
});
@@ -537,6 +574,16 @@ const mockUIActions: UIActions = {
handleNewAgentsSelect: vi.fn(),
};
let capturedOverflowState: OverflowState | undefined;
let capturedOverflowActions: OverflowActions | undefined;
const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
capturedOverflowState = useOverflowState();
capturedOverflowActions = useOverflowActions();
return <>{children}</>;
};
export const renderWithProviders = (
component: React.ReactElement,
{
@@ -638,6 +685,9 @@ export const renderWithProviders = (
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
.flatMap((item) => item.tools);
capturedOverflowState = undefined;
capturedOverflowActions = undefined;
const renderResult = render(
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
@@ -650,35 +700,39 @@ export const renderWithProviders = (
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
<OverflowProvider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<ContextCapture>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ContextCapture>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</OverflowProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
@@ -693,6 +747,8 @@ export const renderWithProviders = (
return {
...renderResult,
capturedOverflowState,
capturedOverflowActions,
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
simulateClick(renderResult.stdin, col, row, button),
};
+2 -1
View File
@@ -46,6 +46,7 @@ export const createMockSettings = (
workspace,
isTrusted,
errors,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
merged: mergedOverride,
...settingsOverrides
} = overrides;
@@ -75,7 +76,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
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
(loaded as any)[key] = overrides[key];
}
}
+343 -16
View File
@@ -26,6 +26,8 @@ import {
CoreEvent,
type UserFeedbackPayload,
type ResumedSessionData,
type StartupWarning,
WarningPriority,
AuthType,
type AgentDefinition,
CoreToolCallStatus,
@@ -103,6 +105,11 @@ import {
type UIActions,
} from './contexts/UIActionsContext.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
import { OverflowProvider } from './contexts/OverflowContext.js';
import {
useOverflowActions,
type OverflowActions,
} from './contexts/OverflowContext.js';
// Mock useStdout to capture terminal title writes
vi.mock('ink', async (importOriginal) => {
@@ -118,9 +125,11 @@ vi.mock('ink', async (importOriginal) => {
// so we can assert against them in our tests.
let capturedUIState: UIState;
let capturedUIActions: UIActions;
let capturedOverflowActions: OverflowActions;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedUIActions = useContext(UIActionsContext)!;
capturedOverflowActions = useOverflowActions()!;
return null;
}
@@ -227,7 +236,10 @@ import {
disableMouseEvents,
} from '@google/gemini-cli-core';
import { type ExtensionManager } from '../config/extension-manager.js';
import { WARNING_PROMPT_DURATION_MS } from './constants.js';
import {
WARNING_PROMPT_DURATION_MS,
EXPAND_HINT_DURATION_MS,
} from './constants.js';
describe('AppContainer State Management', () => {
let mockConfig: Config;
@@ -248,18 +260,20 @@ describe('AppContainer State Management', () => {
config?: Config;
version?: string;
initResult?: InitializationResult;
startupWarnings?: string[];
startupWarnings?: StartupWarning[];
resumedSessionData?: ResumedSessionData;
} = {}) => (
<SettingsContext.Provider value={settings}>
<KeypressProvider config={config}>
<AppContainer
config={config}
version={version}
initializationResult={initResult}
startupWarnings={startupWarnings}
resumedSessionData={resumedSessionData}
/>
<OverflowProvider>
<AppContainer
config={config}
version={version}
initializationResult={initResult}
startupWarnings={startupWarnings}
resumedSessionData={resumedSessionData}
/>
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
@@ -501,7 +515,18 @@ describe('AppContainer State Management', () => {
});
it('renders with startup warnings', async () => {
const startupWarnings = ['Warning 1', 'Warning 2'];
const startupWarnings: StartupWarning[] = [
{
id: 'w1',
message: 'Warning 1',
priority: WarningPriority.High,
},
{
id: 'w2',
message: 'Warning 2',
priority: WarningPriority.High,
},
];
let unmount: () => void;
await act(async () => {
@@ -2674,12 +2699,14 @@ describe('AppContainer State Management', () => {
const getTree = (settings: LoadedSettings) => (
<SettingsContext.Provider value={settings}>
<KeypressProvider config={mockConfig}>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
<TestChild />
<OverflowProvider>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
<TestChild />
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
@@ -3290,6 +3317,306 @@ describe('AppContainer State Management', () => {
});
});
describe('Submission Handling', () => {
it('resets expansion state on submission when not in alternate buffer', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
settings: {
...mockSettings,
merged: {
...mockSettings.merged,
ui: { ...mockSettings.merged.ui, useAlternateBuffer: false },
},
} as LoadedSettings,
}).unmount;
});
await waitFor(() => expect(capturedUIActions).toBeTruthy());
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
expect(capturedUIState.constrainHeight).toBe(false);
// Reset mock stdout to clear any initial writes
mocks.mockStdout.write.mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
// Should be reset
expect(capturedUIState.constrainHeight).toBe(true);
// Should refresh static (which clears terminal in non-alternate buffer)
expect(mocks.mockStdout.write).toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
unmount!();
});
it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
settings: {
...mockSettings,
merged: {
...mockSettings.merged,
ui: { ...mockSettings.merged.ui, useAlternateBuffer: true },
},
} as LoadedSettings,
}).unmount;
});
await waitFor(() => expect(capturedUIActions).toBeTruthy());
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
expect(capturedUIState.constrainHeight).toBe(false);
// Reset mock stdout
mocks.mockStdout.write.mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
// Should be reset
expect(capturedUIState.constrainHeight).toBe(true);
// Should NOT refresh static's clearTerminal in alternate buffer
expect(mocks.mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
unmount!();
});
});
describe('Overflow Hint Handling', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Trigger overflow
act(() => {
capturedOverflowActions.addOverflowingId('test-id');
});
await waitFor(() => {
// Should show hint because we are in Standard Mode (default settings) and have overflow
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// Advance just before the timeout
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Advance to hit the timeout mark
act(() => {
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
});
it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => {
let unmount: () => void;
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
stdin = result.stdin;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Initial state is constrainHeight = true
expect(capturedUIState.constrainHeight).toBe(true);
// Trigger overflow so the hint starts showing
act(() => {
capturedOverflowActions.addOverflowingId('test-id');
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// Advance half the duration
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Simulate Ctrl+O
act(() => {
stdin.write('\x0f'); // \x0f is Ctrl+O
});
await waitFor(() => {
// constrainHeight should toggle
expect(capturedUIState.constrainHeight).toBe(false);
});
// Advance enough that the original timer would have expired if it hadn't reset
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000);
});
// We expect it to still be true because Ctrl+O should have reset the timer
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Advance remaining time to reach the new timeout
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
});
it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => {
let unmount: () => void;
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
stdin = result.stdin;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Initial state is constrainHeight = true
expect(capturedUIState.constrainHeight).toBe(true);
// Trigger overflow so the hint starts showing
act(() => {
capturedOverflowActions.addOverflowingId('test-id');
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// Advance half the duration
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// First toggle 'on' (expanded)
act(() => {
stdin.write('\x0f'); // Ctrl+O
});
await waitFor(() => {
expect(capturedUIState.constrainHeight).toBe(false);
});
// Wait 1 second
act(() => {
vi.advanceTimersByTime(1000);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Second toggle 'off' (collapsed)
act(() => {
stdin.write('\x0f'); // Ctrl+O
});
await waitFor(() => {
expect(capturedUIState.constrainHeight).toBe(true);
});
// Wait 1 second
act(() => {
vi.advanceTimersByTime(1000);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Third toggle 'on' (expanded)
act(() => {
stdin.write('\x0f'); // Ctrl+O
});
await waitFor(() => {
expect(capturedUIState.constrainHeight).toBe(false);
});
// Now we wait just before the timeout from the LAST toggle.
// It should still be true.
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Wait 0.1s more to hit exactly the timeout since the last toggle.
// It should hide now.
act(() => {
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
});
it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithAlternateBuffer = {
merged: {
...alternateSettings,
ui: {
...alternateSettings.ui,
useAlternateBuffer: true,
},
},
} as unknown as LoadedSettings;
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
settings: settingsWithAlternateBuffer,
});
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Trigger overflow
act(() => {
capturedOverflowActions.addOverflowingId('test-id');
});
// Should NOT show hint because we are in Alternate Buffer Mode
expect(capturedUIState.showIsExpandableHint).toBe(false);
unmount!();
});
});
describe('Permission Handling', () => {
it('shows permission dialog when checkPermissions returns paths', async () => {
const { checkPermissions } = await import(
+123 -8
View File
@@ -41,6 +41,7 @@ import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import {
type StartupWarning,
type EditorType,
type Config,
type IdeInfo,
@@ -94,6 +95,10 @@ import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import {
useOverflowActions,
useOverflowState,
} from './contexts/OverflowContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
@@ -150,6 +155,7 @@ import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
EXPAND_HINT_DURATION_MS,
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
@@ -186,7 +192,7 @@ function isToolAwaitingConfirmation(
interface AppContainerProps {
config: Config;
startupWarnings?: string[];
startupWarnings?: StartupWarning[];
version: string;
initializationResult: InitializationResult;
resumedSessionData?: ResumedSessionData;
@@ -213,6 +219,7 @@ const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
const { config, initializationResult, resumedSessionData } = props;
const settings = useSettings();
const { reset } = useOverflowActions()!;
const notificationsEnabled = isNotificationsEnabled(settings);
const historyManager = useHistory({
@@ -261,6 +268,54 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [showIsExpandableHint, setShowIsExpandableHint] = useState(false);
const expandHintTimerRef = useRef<NodeJS.Timeout | null>(null);
const overflowState = useOverflowState();
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
/**
* Manages the visibility and x-second timer for the expansion hint.
*
* This effect triggers the timer countdown whenever an overflow is detected
* or the user manually toggles the expansion state with Ctrl+O. We use a stable
* boolean dependency (hasOverflowState) to ensure the timer only resets on
* genuine state transitions, preventing it from infinitely resetting during
* active text streaming.
*/
useEffect(() => {
if (isAlternateBuffer) {
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
return;
}
if (hasOverflowState) {
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
}
}, [hasOverflowState, isAlternateBuffer, constrainHeight]);
/**
* Safe cleanup to ensure the expansion hint timer is cancelled when the
* component unmounts, preventing memory leaks.
*/
useEffect(
() => () => {
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
},
[],
);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
@@ -1188,6 +1243,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleFinalSubmit = useCallback(
async (submittedValue: string) => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
if (!constrainHeight) {
setConstrainHeight(true);
if (!isAlternateBuffer) {
refreshStatic();
}
}
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
@@ -1246,15 +1314,32 @@ Logging in with Google... Restarting Gemini CLI to continue.
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
config,
constrainHeight,
setConstrainHeight,
isAlternateBuffer,
refreshStatic,
reset,
handleHintSubmit,
],
);
const handleClearScreen = useCallback(() => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
historyManager.clearItems();
clearConsoleMessagesState();
refreshStatic();
}, [historyManager, clearConsoleMessagesState, refreshStatic]);
}, [
historyManager,
clearConsoleMessagesState,
refreshStatic,
reset,
setShowIsExpandableHint,
]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
@@ -1424,7 +1509,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
windowMs: WARNING_PROMPT_DURATION_MS,
onRepeat: handleExitRepeat,
});
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
>();
@@ -1436,15 +1521,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
const {
isFolderTrustDialogOpen,
discoveryResults: folderDiscoveryResults,
handleFolderTrustSelect,
isRestarting,
} = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
const policyUpdateConfirmationRequest =
config.getPolicyUpdateConfirmationRequest();
const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState(
!!policyUpdateConfirmationRequest,
);
const {
needsRestart: ideNeedsRestart,
restartReason: ideTrustRestartReason,
@@ -1651,6 +1739,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (!constrainHeight) {
enteringConstrainHeightMode = true;
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
// If the user manually collapses the view, show the hint and reset the x-second timer.
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
}
if (!isAlternateBuffer) {
refreshStatic();
}
}
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
@@ -1694,6 +1795,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
!enteringConstrainHeightMode
) {
setConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
if (!isAlternateBuffer) {
refreshStatic();
}
return true;
} else if (
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
@@ -2074,8 +2186,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const fetchBannerTexts = async () => {
const [defaultBanner, warningBanner] = await Promise.all([
// TODO: temporarily disabling the banner, it will be re-added.
'',
config.getBannerTextNoCapacityIssues(),
config.getBannerTextCapacityIssues(),
]);
@@ -2145,6 +2256,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
folderDiscoveryResults,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
isTrustedFolder,
@@ -2214,6 +2326,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
hintMode:
config.isModelSteeringEnabled() &&
isToolExecuting([
@@ -2269,6 +2382,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen,
folderDiscoveryResults,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
isTrustedFolder,
@@ -2339,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundShells,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
],
);
@@ -20,6 +20,7 @@ import {
import {
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
CommandKind,
} from './types.js';
import open from 'open';
@@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises';
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
import { type ConfigLogger } from '../../commands/extensions/utils.js';
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js';
import React from 'react';
function showMessageIfNoExtensions(
@@ -265,7 +267,28 @@ async function restartAction(
}
}
async function exploreAction(context: CommandContext) {
async function exploreAction(
context: CommandContext,
): Promise<SlashCommandActionReturn | void> {
const settings = context.services.settings.merged;
const useRegistryUI = settings.experimental?.extensionRegistry;
if (useRegistryUI) {
const extensionManager = context.services.config?.getExtensionLoader();
if (extensionManager instanceof ExtensionManager) {
return {
type: 'custom_dialog' as const,
component: React.createElement(ExtensionRegistryView, {
onSelect: (extension) => {
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
},
onClose: () => context.ui.removeComponent(),
extensionManager,
}),
};
}
}
const extensionsUrl = 'https://geminicli.com/extensions/';
// Only check for NODE_ENV for explicit test mode, not for unit test framework
@@ -23,6 +23,7 @@ import {
RewindEvent,
type ChatRecordingService,
type GeminiClient,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
/**
@@ -54,9 +55,8 @@ async function rewindConversation(
}
// Convert to UI and Client formats
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
conversation.messages,
);
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
const clientHistory = convertSessionToClientHistory(conversation.messages);
client.setHistory(clientHistory as Content[]);
+4 -1
View File
@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { getDisplayString } from '@google/gemini-cli-core';
interface AboutBoxProps {
cliVersion: string;
@@ -79,7 +80,9 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{modelVersion}</Text>
<Text color={theme.text.primary}>
{getDisplayString(modelVersion)}
</Text>
</Box>
</Box>
<Box flexDirection="row">
@@ -164,6 +164,7 @@ export const DialogManager = ({
<FolderTrustDialog
onSelect={uiActions.handleFolderTrustSelect}
isRestarting={uiState.isRestarting}
discoveryResults={uiState.folderDiscoveryResults}
/>
);
}
@@ -18,6 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({
const mockedExit = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedRows = vi.hoisted(() => ({ current: 24 }));
vi.mock('node:process', async () => {
const actual =
@@ -29,11 +30,20 @@ vi.mock('node:process', async () => {
};
});
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: () => ({ columns: 80, terminalHeight: mockedRows.current }),
}));
describe('FolderTrustDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
mockedCwd.mockReturnValue('/home/user/project');
mockedRows.current = 24;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should render the dialog with title and description', async () => {
@@ -42,13 +52,159 @@ describe('FolderTrustDialog', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain('Do you trust this folder?');
expect(lastFrame()).toContain('Do you trust the files in this folder?');
expect(lastFrame()).toContain(
'Trusting a folder allows Gemini to execute commands it suggests.',
'Trusting a folder allows Gemini CLI to load its local configurations',
);
unmount();
});
it('should truncate discovery results when they exceed maxDiscoveryHeight', async () => {
// maxDiscoveryHeight = 24 - 15 = 9.
const discoveryResults = {
commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`),
mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`),
hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`),
skills: Array.from({ length: 10 }, (_, i) => `skill${i}`),
settings: Array.from({ length: 10 }, (_, i) => `setting${i}`),
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
{
width: 80,
useAlternateBuffer: false,
uiState: { constrainHeight: true, terminalHeight: 24 },
},
);
await waitUntilReady();
expect(lastFrame()).toContain('This folder contains:');
expect(lastFrame()).toContain('hidden');
unmount();
});
it('should adjust maxHeight based on terminal rows', async () => {
mockedRows.current = 14; // maxHeight = 14 - 10 = 4
const discoveryResults = {
commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'],
mcps: [],
hooks: [],
skills: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
{
width: 80,
useAlternateBuffer: false,
uiState: { constrainHeight: true, terminalHeight: 14 },
},
);
await waitUntilReady();
// With maxHeight=4, the intro text (4 lines) will take most of the space.
// The discovery results will likely be hidden.
expect(lastFrame()).toContain('hidden');
unmount();
});
it('should use minimum maxHeight of 4', async () => {
mockedRows.current = 8; // 8 - 10 = -2, should use 4
const discoveryResults = {
commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'],
mcps: [],
hooks: [],
skills: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
{
width: 80,
useAlternateBuffer: false,
uiState: { constrainHeight: true, terminalHeight: 10 },
},
);
await waitUntilReady();
expect(lastFrame()).toContain('hidden');
unmount();
});
it('should toggle expansion when global Ctrl+O is handled', async () => {
const discoveryResults = {
commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`),
mcps: [],
hooks: [],
skills: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount } = renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
{
width: 80,
useAlternateBuffer: false,
// Initially constrained
uiState: { constrainHeight: true, terminalHeight: 24 },
},
);
// Initial state: truncated
await waitFor(() => {
expect(lastFrame()).toContain('Do you trust the files in this folder?');
// In standard terminal mode, the expansion hint is handled globally by ToastDisplay
// via AppContainer, so it should not be present in the dialog's local frame.
expect(lastFrame()).not.toContain('Press Ctrl+O');
expect(lastFrame()).toContain('hidden');
});
// We can't easily simulate global Ctrl+O toggle in this unit test
// because it's handled in AppContainer.
// But we can re-render with constrainHeight: false.
const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } =
renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
{
width: 80,
useAlternateBuffer: false,
uiState: { constrainHeight: false, terminalHeight: 24 },
},
);
await waitFor(() => {
expect(lastFrameExpanded()).not.toContain('hidden');
expect(lastFrameExpanded()).toContain('- cmd9');
expect(lastFrameExpanded()).toContain('- cmd4');
});
unmount();
unmountExpanded();
});
it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => {
const onSelect = vi.fn();
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
@@ -164,5 +320,153 @@ describe('FolderTrustDialog', () => {
expect(lastFrame()).toContain('Trust parent folder ()');
unmount();
});
it('should display discovery results when provided', async () => {
mockedRows.current = 40; // Increase height to show all results
const discoveryResults = {
commands: ['cmd1', 'cmd2'],
mcps: ['mcp1'],
hooks: ['hook1'],
skills: ['skill1'],
settings: ['general', 'ui'],
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toContain('This folder contains:');
expect(lastFrame()).toContain('• Commands (2):');
expect(lastFrame()).toContain('- cmd1');
expect(lastFrame()).toContain('- cmd2');
expect(lastFrame()).toContain('• MCP Servers (1):');
expect(lastFrame()).toContain('- mcp1');
expect(lastFrame()).toContain('• Hooks (1):');
expect(lastFrame()).toContain('- hook1');
expect(lastFrame()).toContain('• Skills (1):');
expect(lastFrame()).toContain('- skill1');
expect(lastFrame()).toContain('• Setting overrides (2):');
expect(lastFrame()).toContain('- general');
expect(lastFrame()).toContain('- ui');
unmount();
});
it('should display security warnings when provided', async () => {
const discoveryResults = {
commands: [],
mcps: [],
hooks: [],
skills: [],
settings: [],
discoveryErrors: [],
securityWarnings: ['Dangerous setting detected!'],
};
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Security Warnings:');
expect(lastFrame()).toContain('Dangerous setting detected!');
unmount();
});
it('should display discovery errors when provided', async () => {
const discoveryResults = {
commands: [],
mcps: [],
hooks: [],
skills: [],
settings: [],
discoveryErrors: ['Failed to load custom commands'],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Discovery Errors:');
expect(lastFrame()).toContain('Failed to load custom commands');
unmount();
});
it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', async () => {
const discoveryResults = {
commands: Array.from({ length: 20 }, (_, i) => `cmd${i}`),
mcps: [],
hooks: [],
skills: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
{
width: 80,
useAlternateBuffer: true,
uiState: { constrainHeight: false, terminalHeight: 15 },
},
);
await waitUntilReady();
// In alternate buffer + expanded, the title should be visible (StickyHeader)
expect(lastFrame()).toContain('Do you trust the files in this folder?');
// And it should NOT use MaxSizedBox truncation
expect(lastFrame()).not.toContain('hidden');
unmount();
});
it('should strip ANSI codes from discovery results', async () => {
const ansiRed = '\u001b[31m';
const ansiReset = '\u001b[39m';
const discoveryResults = {
commands: [`${ansiRed}cmd-with-ansi${ansiReset}`],
mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`],
hooks: [`${ansiRed}hook-with-ansi${ansiReset}`],
skills: [`${ansiRed}skill-with-ansi${ansiReset}`],
settings: [`${ansiRed}setting-with-ansi${ansiReset}`],
discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`],
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
};
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
{ width: 100, uiState: { terminalHeight: 40 } },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('cmd-with-ansi');
expect(output).toContain('mcp-with-ansi');
expect(output).toContain('hook-with-ansi');
expect(output).toContain('skill-with-ansi');
expect(output).toContain('setting-with-ansi');
expect(output).toContain('error-with-ansi');
expect(output).toContain('warning-with-ansi');
unmount();
});
});
});
@@ -8,14 +8,25 @@ import { Box, Text } from 'ink';
import type React from 'react';
import { useEffect, useState, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import stripAnsi from 'strip-ansi';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { Scrollable } from './shared/Scrollable.js';
import { useKeypress } from '../hooks/useKeypress.js';
import * as process from 'node:process';
import * as path from 'node:path';
import { relaunchApp } from '../../utils/processUtils.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { ExitCodes } from '@google/gemini-cli-core';
import {
ExitCodes,
type FolderDiscoveryResults,
} from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
@@ -26,13 +37,19 @@ export enum FolderTrustChoice {
interface FolderTrustDialogProps {
onSelect: (choice: FolderTrustChoice) => void;
isRestarting?: boolean;
discoveryResults?: FolderDiscoveryResults | null;
}
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
isRestarting,
discoveryResults,
}) => {
const [exiting, setExiting] = useState(false);
const { terminalHeight, terminalWidth, constrainHeight } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isExpanded = !constrainHeight;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
@@ -87,33 +104,197 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
},
];
return (
<Box flexDirection="column" width="100%">
const hasDiscovery =
discoveryResults &&
(discoveryResults.commands.length > 0 ||
discoveryResults.mcps.length > 0 ||
discoveryResults.hooks.length > 0 ||
discoveryResults.skills.length > 0 ||
discoveryResults.settings.length > 0);
const hasWarnings =
discoveryResults && discoveryResults.securityWarnings.length > 0;
const hasErrors =
discoveryResults &&
discoveryResults.discoveryErrors &&
discoveryResults.discoveryErrors.length > 0;
const dialogWidth = terminalWidth - 2;
const borderColor = theme.status.warning;
// Header: 3 lines
// Options: options.length + 2 lines for margins
// Footer: 1 line
// Safety margin: 2 lines
const overhead = 3 + options.length + 2 + 1 + 2;
const scrollableHeight = Math.max(4, terminalHeight - overhead);
const groups = [
{ label: 'Commands', items: discoveryResults?.commands ?? [] },
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] },
{ label: 'Skills', items: discoveryResults?.skills ?? [] },
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
].filter((g) => g.items.length > 0);
const discoveryContent = (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={theme.text.primary}>
Trusting a folder allows Gemini CLI to load its local configurations,
including custom commands, hooks, MCP servers, agent skills, and
settings. These configurations could execute code on your behalf or
change the behavior of the CLI.
</Text>
</Box>
{hasErrors && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.error} bold>
Discovery Errors:
</Text>
{discoveryResults.discoveryErrors.map((error, i) => (
<Box key={i} marginLeft={2}>
<Text color={theme.status.error}> {stripAnsi(error)}</Text>
</Box>
))}
</Box>
)}
{hasWarnings && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.warning} bold>
Security Warnings:
</Text>
{discoveryResults.securityWarnings.map((warning, i) => (
<Box key={i} marginLeft={2}>
<Text color={theme.status.warning}> {stripAnsi(warning)}</Text>
</Box>
))}
</Box>
)}
{hasDiscovery && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary} bold>
This folder contains:
</Text>
{groups.map((group) => (
<Box key={group.label} flexDirection="column" marginLeft={2}>
<Text color={theme.text.primary} bold>
{group.label} ({group.items.length}):
</Text>
{group.items.map((item, idx) => (
<Box key={idx} marginLeft={2}>
<Text color={theme.text.primary}>- {stripAnsi(item)}</Text>
</Box>
))}
</Box>
))}
</Box>
)}
</Box>
);
const title = (
<Text bold color={theme.text.primary}>
Do you trust the files in this folder?
</Text>
);
const selectOptions = (
<RadioButtonSelect
items={options}
onSelect={onSelect}
isFocused={!isRestarting}
/>
);
const renderContent = () => {
if (isAlternateBuffer) {
return (
<Box flexDirection="column" width={dialogWidth}>
<StickyHeader
width={dialogWidth}
isFirst={true}
borderColor={borderColor}
borderDimColor={false}
>
{title}
</StickyHeader>
<Box
flexDirection="column"
borderLeft={true}
borderRight={true}
borderColor={borderColor}
borderStyle="round"
borderTop={false}
borderBottom={false}
width={dialogWidth}
>
<Scrollable
hasFocus={!isRestarting}
height={scrollableHeight}
width={dialogWidth - 2}
>
<Box flexDirection="column" paddingX={1}>
{discoveryContent}
</Box>
</Scrollable>
<Box paddingX={1} marginY={1}>
{selectOptions}
</Box>
</Box>
<Box
height={0}
width={dialogWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderColor={borderColor}
borderStyle="round"
/>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
borderColor={borderColor}
padding={1}
marginLeft={1}
marginRight={1}
width="100%"
>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
Do you trust this folder?
</Text>
<Text color={theme.text.primary}>
Trusting a folder allows Gemini to execute commands it suggests.
This is a security feature to prevent accidental execution in
untrusted directories.
</Text>
</Box>
<Box marginBottom={1}>{title}</Box>
<RadioButtonSelect
items={options}
onSelect={onSelect}
isFocused={!isRestarting}
/>
<MaxSizedBox
maxHeight={isExpanded ? undefined : Math.max(4, terminalHeight - 12)}
overflowDirection="bottom"
>
{discoveryContent}
</MaxSizedBox>
<Box marginTop={1}>{selectOptions}</Box>
</Box>
);
};
const content = (
<Box flexDirection="column" width="100%">
<Box flexDirection="column" marginLeft={1} marginRight={1}>
{renderContent()}
</Box>
<Box paddingX={2} marginBottom={1}>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
{isRestarting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
@@ -131,4 +312,10 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
)}
</Box>
);
return isAlternateBuffer ? (
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
};
@@ -46,6 +46,7 @@ interface HistoryItemDisplayProps {
isPending: boolean;
commands?: readonly SlashCommand[];
availableTerminalHeightGemini?: number;
isExpandable?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -55,6 +56,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isPending,
commands,
availableTerminalHeightGemini,
isExpandable,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -180,6 +182,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth={terminalWidth}
borderTop={itemForDisplay.borderTop}
borderBottom={itemForDisplay.borderBottom}
isExpandable={isExpandable}
/>
)}
{itemForDisplay.type === 'compression' && (
@@ -1065,7 +1065,7 @@ describe('InputPrompt', () => {
unmount();
});
it('should NOT submit on Enter when an @-path is a perfect match', async () => {
it('should submit on Enter when an @-path is a perfect match', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
@@ -1085,13 +1085,38 @@ describe('InputPrompt', () => {
});
await waitFor(() => {
// Should handle autocomplete but NOT submit
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
// Should submit directly
expect(props.onSubmit).toHaveBeenCalledWith('@file.txt');
});
unmount();
});
it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'file.txt', value: 'file.txt' }],
activeSuggestionIndex: 0,
isPerfectMatch: true,
completionMode: CompletionMode.AT,
});
props.buffer.text = '@file.txt';
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
// Simulate Shift+Enter using CSI u sequence
stdin.write('\x1b[13;2u');
});
// Should NOT submit, should call newline instead
expect(props.onSubmit).not.toHaveBeenCalled();
expect(props.buffer.newline).toHaveBeenCalled();
unmount();
});
it('should auto-execute commands with autoExecute: true on Enter', async () => {
const aboutCommand: SlashCommand = {
name: 'about',
@@ -2285,6 +2310,36 @@ describe('InputPrompt', () => {
unmount();
});
it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => {
// isTerminalPasteTrusted will be false due to beforeEach setup.
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
isPerfectMatch: true,
completionMode: CompletionMode.AT,
});
props.buffer.text = '@file.txt';
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
// Simulate an unsafe paste of a perfect match
await act(async () => {
stdin.write(`\x1b[200~@file.txt\x1b[201~`);
});
// Simulate an Enter key press immediately after paste
await act(async () => {
stdin.write('\r');
});
// Verify that onSubmit was NOT called due to recent paste protection
expect(props.onSubmit).not.toHaveBeenCalled();
// It should call newline() instead
expect(props.buffer.newline).toHaveBeenCalled();
unmount();
});
it('should allow submission after unsafe paste protection timeout', async () => {
// isTerminalPasteTrusted will be false due to beforeEach setup.
props.buffer.text = 'pasted text';
@@ -890,14 +890,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// We prioritize execution unless the user is explicitly selecting a different suggestion.
if (
completion.isPerfectMatch &&
completion.completionMode !== CompletionMode.AT &&
keyMatchers[Command.RETURN](key) &&
keyMatchers[Command.SUBMIT](key) &&
recentUnsafePasteTime === null &&
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
) {
handleSubmit(buffer.text);
return true;
}
// Newline insertion
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return true;
}
if (completion.showSuggestions) {
if (completion.suggestions.length > 1) {
if (keyMatchers[Command.COMPLETION_UP](key)) {
@@ -1078,12 +1084,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
// Newline insertion
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return true;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
@@ -493,7 +493,8 @@ describe('MainContent', () => {
isAlternateBuffer: true,
embeddedShellFocused: true,
constrainHeight: true,
shouldShowLine1: true,
shouldShowLine1: false,
staticAreaMaxItemHeight: 15,
},
{
name: 'ASB mode - Unfocused shell',
@@ -501,6 +502,7 @@ describe('MainContent', () => {
embeddedShellFocused: false,
constrainHeight: true,
shouldShowLine1: false,
staticAreaMaxItemHeight: 15,
},
{
name: 'Normal mode - Constrained height',
@@ -508,13 +510,15 @@ describe('MainContent', () => {
embeddedShellFocused: false,
constrainHeight: true,
shouldShowLine1: false,
staticAreaMaxItemHeight: 15,
},
{
name: 'Normal mode - Unconstrained height',
isAlternateBuffer: false,
embeddedShellFocused: false,
constrainHeight: false,
shouldShowLine1: false,
shouldShowLine1: true,
staticAreaMaxItemHeight: 15,
},
];
@@ -525,6 +529,7 @@ describe('MainContent', () => {
embeddedShellFocused,
constrainHeight,
shouldShowLine1,
staticAreaMaxItemHeight,
}) => {
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
const ptyId = 123;
@@ -554,6 +559,7 @@ describe('MainContent', () => {
},
],
availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines
staticAreaMaxItemHeight,
terminalHeight: 50,
terminalWidth: 100,
mainAreaWidth: 100,
+60 -22
View File
@@ -47,32 +47,61 @@ export const MainContent = () => {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
const lastUserPromptIndex = useMemo(() => {
for (let i = uiState.history.length - 1; i >= 0; i--) {
const type = uiState.history[i].type;
if (type === 'user' || type === 'user_shell') {
return i;
}
}
return -1;
}, [uiState.history]);
const historyItems = useMemo(
() =>
uiState.history.map((h) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
)),
uiState.history.map((h, index) => {
const isExpandable = index > lastUserPromptIndex;
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
uiState.constrainHeight || !isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
isExpandable={isExpandable}
/>
);
}),
[
uiState.history,
mainAreaWidth,
staticAreaMaxItemHeight,
uiState.slashCommands,
uiState.constrainHeight,
lastUserPromptIndex,
],
);
const staticHistoryItems = useMemo(
() => historyItems.slice(0, lastUserPromptIndex + 1),
[historyItems, lastUserPromptIndex],
);
const lastResponseHistoryItems = useMemo(
() => historyItems.slice(lastUserPromptIndex + 1),
[historyItems, lastUserPromptIndex],
);
const pendingItems = useMemo(
() => (
<Box flexDirection="column">
@@ -80,14 +109,12 @@ export const MainContent = () => {
<HistoryItemDisplay
key={i}
availableTerminalHeight={
(uiState.constrainHeight && !isAlternateBuffer) ||
isAlternateBuffer
? availableTerminalHeight
: undefined
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isExpandable={true}
/>
))}
{showConfirmationQueue && confirmingTool && (
@@ -98,8 +125,7 @@ export const MainContent = () => {
[
pendingHistoryItems,
uiState.constrainHeight,
isAlternateBuffer,
availableTerminalHeight,
staticAreaMaxItemHeight,
mainAreaWidth,
showConfirmationQueue,
confirmingTool,
@@ -109,10 +135,14 @@ export const MainContent = () => {
const virtualizedData = useMemo(
() => [
{ type: 'header' as const },
...uiState.history.map((item) => ({ type: 'history' as const, item })),
...uiState.history.map((item, index) => ({
type: 'history' as const,
item,
isExpandable: index > lastUserPromptIndex,
})),
{ type: 'pending' as const },
],
[uiState.history],
[uiState.history, lastUserPromptIndex],
);
const renderItem = useCallback(
@@ -129,12 +159,17 @@ export const MainContent = () => {
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={undefined}
availableTerminalHeight={
uiState.constrainHeight || !item.isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.item.id}
item={item.item}
isPending={false}
commands={uiState.slashCommands}
isExpandable={item.isExpandable}
/>
);
} else {
@@ -147,6 +182,8 @@ export const MainContent = () => {
mainAreaWidth,
uiState.slashCommands,
pendingItems,
uiState.constrainHeight,
staticAreaMaxItemHeight,
],
);
@@ -176,7 +213,8 @@ export const MainContent = () => {
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...historyItems,
...staticHistoryItems,
...lastResponseHistoryItems,
]}
>
{(item) => item}
@@ -9,11 +9,17 @@ import { act } from 'react';
import { ModelDialog } from './ModelDialog.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { createMockSettings } from '../../test-utils/settings.js';
import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
AuthType,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
@@ -42,12 +48,14 @@ describe('<ModelDialog />', () => {
const mockGetModel = vi.fn();
const mockOnClose = vi.fn();
const mockGetHasAccessToPreviewModel = vi.fn();
const mockGetGemini31LaunchedSync = vi.fn();
interface MockConfig extends Partial<Config> {
setModel: (model: string, isTemporary?: boolean) => void;
getModel: () => string;
getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean;
getGemini31LaunchedSync: () => boolean;
}
const mockConfig: MockConfig = {
@@ -55,12 +63,14 @@ describe('<ModelDialog />', () => {
getModel: mockGetModel,
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false,
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
};
beforeEach(() => {
vi.resetAllMocks();
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetGemini31LaunchedSync.mockReturnValue(false);
// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
@@ -70,9 +80,21 @@ describe('<ModelDialog />', () => {
});
});
const renderComponent = async (configValue = mockConfig as Config) => {
const renderComponent = async (
configValue = mockConfig as Config,
authType = AuthType.LOGIN_WITH_GOOGLE,
) => {
const settings = createMockSettings({
security: {
auth: {
selectedType: authType,
},
},
});
const result = renderWithProviders(<ModelDialog onClose={mockOnClose} />, {
config: configValue,
settings,
});
await result.waitUntilReady();
return result;
@@ -87,7 +109,15 @@ describe('<ModelDialog />', () => {
unmount();
});
it('switches to "manual" view when "Manual" is selected', async () => {
it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => {
mockGetDisplayString.mockImplementation((val: string) => {
if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model';
if (val === DEFAULT_GEMINI_FLASH_MODEL) return 'Formatted Flash Model';
if (val === DEFAULT_GEMINI_FLASH_LITE_MODEL)
return 'Formatted Lite Model';
return val;
});
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
@@ -107,9 +137,9 @@ describe('<ModelDialog />', () => {
// Should now show manual options
await waitFor(() => {
const output = lastFrame();
expect(output).toContain(DEFAULT_GEMINI_MODEL);
expect(output).toContain(DEFAULT_GEMINI_FLASH_MODEL);
expect(output).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
expect(output).toContain('Formatted Pro Model');
expect(output).toContain('Formatted Flash Model');
expect(output).toContain('Formatted Lite Model');
});
unmount();
});
@@ -241,4 +271,103 @@ describe('<ModelDialog />', () => {
});
unmount();
});
it('shows the preferred manual model in the main view option using getDisplayString', async () => {
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL);
mockGetDisplayString.mockImplementation((val: string) => {
if (val === DEFAULT_GEMINI_MODEL) return 'My Custom Model Display';
if (val === 'auto-gemini-2.5') return 'Auto (Gemini 2.5)';
return val;
});
const { lastFrame, unmount } = await renderComponent();
expect(lastFrame()).toContain('Manual (My Custom Model Display)');
unmount();
});
describe('Preview Models', () => {
beforeEach(() => {
mockGetHasAccessToPreviewModel.mockReturnValue(true);
});
it('shows Auto (Preview) in main view when access is granted', async () => {
const { lastFrame, unmount } = await renderComponent();
expect(lastFrame()).toContain('Auto (Preview)');
unmount();
});
it('shows Gemini 3 models in manual view when Gemini 3.1 is NOT launched', async () => {
mockGetGemini31LaunchedSync.mockReturnValue(false);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(PREVIEW_GEMINI_MODEL);
expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL);
unmount();
});
it('shows Gemini 3.1 models in manual view when Gemini 3.1 IS launched', async () => {
mockGetGemini31LaunchedSync.mockReturnValue(true);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent(mockConfig as Config, AuthType.USE_VERTEX_AI);
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(PREVIEW_GEMINI_3_1_MODEL);
expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL);
unmount();
});
it('uses custom tools model when Gemini 3.1 IS launched and auth is Gemini API Key', async () => {
mockGetGemini31LaunchedSync.mockReturnValue(true);
const { stdin, waitUntilReady, unmount } = await renderComponent(
mockConfig as Config,
AuthType.USE_GEMINI,
);
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
// Select Gemini 3.1 (first item in preview section)
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(mockSetModel).toHaveBeenCalledWith(
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
true,
);
});
unmount();
});
});
});
+32 -12
View File
@@ -9,6 +9,7 @@ import { useCallback, useContext, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL,
@@ -18,11 +19,14 @@ import {
ModelSlashCommandEvent,
logModelSlashCommand,
getDisplayString,
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface ModelDialogProps {
onClose: () => void;
@@ -30,6 +34,7 @@ interface ModelDialogProps {
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
const settings = useSettings();
const [view, setView] = useState<'main' | 'manual'>('main');
const [persistMode, setPersistMode] = useState(false);
@@ -37,6 +42,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
const manualModelSelected = useMemo(() => {
const manualModels = [
@@ -44,6 +53,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
];
if (manualModels.includes(preferredModel)) {
@@ -83,7 +94,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
{
value: 'Manual',
title: manualModelSelected
? `Manual (${manualModelSelected})`
? `Manual (${getDisplayString(manualModelSelected)})`
: 'Manual',
description: 'Manually select a model',
key: 'Manual',
@@ -94,49 +105,58 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
list.unshift({
value: PREVIEW_GEMINI_MODEL_AUTO,
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
description:
'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
description: useGemini31
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
key: PREVIEW_GEMINI_MODEL_AUTO,
});
}
return list;
}, [shouldShowPreviewModels, manualModelSelected]);
}, [shouldShowPreviewModels, manualModelSelected, useGemini31]);
const manualOptions = useMemo(() => {
const list = [
{
value: DEFAULT_GEMINI_MODEL,
title: DEFAULT_GEMINI_MODEL,
title: getDisplayString(DEFAULT_GEMINI_MODEL),
key: DEFAULT_GEMINI_MODEL,
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: DEFAULT_GEMINI_FLASH_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
key: DEFAULT_GEMINI_FLASH_MODEL,
},
{
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
},
];
if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
: PREVIEW_GEMINI_MODEL;
const previewProValue = useCustomToolModel
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
list.unshift(
{
value: PREVIEW_GEMINI_MODEL,
title: PREVIEW_GEMINI_MODEL,
key: PREVIEW_GEMINI_MODEL,
value: previewProValue,
title: getDisplayString(previewProModel),
key: previewProModel,
},
{
value: PREVIEW_GEMINI_FLASH_MODEL,
title: PREVIEW_GEMINI_FLASH_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
key: PREVIEW_GEMINI_FLASH_MODEL,
},
);
}
return list;
}, [shouldShowPreviewModels]);
}, [shouldShowPreviewModels, useGemini31, useCustomToolModel]);
const options = view === 'main' ? mainOptions : manualOptions;
@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render, persistentStateMock } from '../../test-utils/render.js';
import {
persistentStateMock,
renderWithProviders,
} from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import type { LoadedSettings } from '../../config/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { Notifications } from './Notifications.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -13,6 +18,7 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { useIsScreenReaderEnabled } from 'ink';
import * as fs from 'node:fs/promises';
import { act } from 'react';
import { WarningPriority } from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('../contexts/AppContext.js');
@@ -61,22 +67,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual,
GEMINI_DIR: '.gemini',
homedir: () => '/mock/home',
WarningPriority: {
Low: 'low',
High: 'high',
},
Storage: {
...actual.Storage,
getGlobalTempDir: () => '/mock/temp',
getGlobalSettingsPath: () => '/mock/home/.gemini/settings.json',
},
};
});
vi.mock('../../config/settings.js', () => ({
DEFAULT_MODEL_CONFIGS: {},
LoadedSettings: class {
constructor() {
// this.merged = {};
}
},
}));
describe('Notifications', () => {
const mockUseAppContext = vi.mocked(useAppContext);
const mockUseUIState = vi.mocked(useUIState);
@@ -84,9 +86,14 @@ describe('Notifications', () => {
const mockFsAccess = vi.mocked(fs.access);
const mockFsUnlink = vi.mocked(fs.unlink);
let settings: LoadedSettings;
beforeEach(() => {
vi.clearAllMocks();
persistentStateMock.reset();
settings = createMockSettings({
ui: { useAlternateBuffer: true },
});
mockUseAppContext.mockReturnValue({
startupWarnings: [],
version: '1.0.0',
@@ -100,60 +107,195 @@ describe('Notifications', () => {
});
it('renders nothing when no notifications', async () => {
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Notifications />,
{
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])(
'renders startup warnings: %s',
async (warnings) => {
mockUseAppContext.mockReturnValue({
startupWarnings: warnings,
version: '1.0.0',
} as AppState);
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
await waitUntilReady();
const output = lastFrame();
warnings.forEach((warning) => {
expect(output).toContain(warning);
});
unmount();
},
);
it.each([
[[{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High }]],
[
[
{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High },
{ id: 'w2', message: 'Warning 2', priority: WarningPriority.High },
],
],
])('renders startup warnings: %s', async (warnings) => {
const appState = {
startupWarnings: warnings,
version: '1.0.0',
} as AppState;
mockUseAppContext.mockReturnValue(appState);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Notifications />,
{
appState,
settings,
width: 100,
},
);
await waitUntilReady();
const output = lastFrame();
warnings.forEach((warning) => {
expect(output).toContain(warning.message);
});
unmount();
});
it('increments show count for low priority warnings', async () => {
const warnings = [
{ id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low },
];
const appState = {
startupWarnings: warnings,
version: '1.0.0',
} as AppState;
mockUseAppContext.mockReturnValue(appState);
const { waitUntilReady, unmount } = renderWithProviders(<Notifications />, {
appState,
settings,
width: 100,
});
await waitUntilReady();
expect(persistentStateMock.set).toHaveBeenCalledWith(
'startupWarningCounts',
{ 'low-1': 1 },
);
unmount();
});
it('filters out low priority warnings that exceeded max show count', async () => {
const warnings = [
{ id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low },
{
id: 'high-1',
message: 'High priority 1',
priority: WarningPriority.High,
},
];
const appState = {
startupWarnings: warnings,
version: '1.0.0',
} as AppState;
mockUseAppContext.mockReturnValue(appState);
persistentStateMock.setData({
startupWarningCounts: { 'low-1': 3 },
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Notifications />,
{
appState,
settings,
width: 100,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Low priority 1');
expect(output).toContain('High priority 1');
unmount();
});
it('dismisses warnings on keypress', async () => {
const warnings = [
{
id: 'high-1',
message: 'High priority 1',
priority: WarningPriority.High,
},
];
const appState = {
startupWarnings: warnings,
version: '1.0.0',
} as AppState;
mockUseAppContext.mockReturnValue(appState);
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<Notifications />,
{
appState,
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('High priority 1');
await act(async () => {
stdin.write('a');
});
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).not.toContain('High priority 1');
unmount();
});
it('renders init error', async () => {
mockUseUIState.mockReturnValue({
const uiState = {
initError: 'Something went wrong',
streamingState: 'idle',
updateInfo: null,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
} as unknown as UIState;
mockUseUIState.mockReturnValue(uiState);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Notifications />,
{
uiState,
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does not render init error when streaming', async () => {
mockUseUIState.mockReturnValue({
const uiState = {
initError: 'Something went wrong',
streamingState: 'responding',
updateInfo: null,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
} as unknown as UIState;
mockUseUIState.mockReturnValue(uiState);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Notifications />,
{
uiState,
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders update notification', async () => {
mockUseUIState.mockReturnValue({
const uiState = {
initError: null,
streamingState: 'idle',
updateInfo: { message: 'Update available' },
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
} as unknown as UIState;
mockUseUIState.mockReturnValue(uiState);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Notifications />,
{
uiState,
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -164,7 +306,13 @@ describe('Notifications', () => {
persistentStateMock.setData({ hasSeenScreenReaderNudge: false });
mockFsAccess.mockRejectedValue(new Error('No legacy file'));
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Notifications />,
{
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('screen reader-friendly view');
@@ -182,7 +330,10 @@ describe('Notifications', () => {
persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined });
mockFsAccess.mockResolvedValue(undefined);
const { waitUntilReady, unmount } = render(<Notifications />);
const { waitUntilReady, unmount } = renderWithProviders(<Notifications />, {
settings,
width: 100,
});
await act(async () => {
await waitUntilReady();
@@ -201,7 +352,13 @@ describe('Notifications', () => {
mockUseIsScreenReaderEnabled.mockReturnValue(true);
persistentStateMock.setData({ hasSeenScreenReaderNudge: true });
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Notifications />,
{
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
@@ -5,15 +5,22 @@
*/
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useAppContext } from '../contexts/AppContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
import { StreamingState } from '../types.js';
import { UpdateNotification } from './UpdateNotification.js';
import { persistentState } from '../../utils/persistentState.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { KeypressPriority } from '../contexts/KeypressContext.js';
import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core';
import {
GEMINI_DIR,
Storage,
homedir,
WarningPriority,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
@@ -25,12 +32,13 @@ const screenReaderNudgeFilePath = path.join(
'seen_screen_reader_nudge.json',
);
const MAX_STARTUP_WARNING_SHOW_COUNT = 3;
export const Notifications = () => {
const { startupWarnings } = useAppContext();
const { initError, streamingState, updateInfo } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const showStartupWarnings = startupWarnings.length > 0;
const showInitError =
initError && streamingState !== StreamingState.Responding;
@@ -38,6 +46,57 @@ export const Notifications = () => {
persistentState.get('hasSeenScreenReaderNudge'),
);
const [dismissed, setDismissed] = useState(false);
// Track if we have already incremented the show count in this session
const hasIncrementedRef = useRef(false);
// Filter warnings based on persistent state count if low priority
const visibleWarnings = useMemo(() => {
if (dismissed) return [];
const counts = persistentState.get('startupWarningCounts') || {};
return startupWarnings.filter((w) => {
if (w.priority === WarningPriority.Low) {
const count = counts[w.id] || 0;
return count < MAX_STARTUP_WARNING_SHOW_COUNT;
}
return true;
});
}, [startupWarnings, dismissed]);
const showStartupWarnings = visibleWarnings.length > 0;
// Increment counts for low priority warnings when shown
useEffect(() => {
if (visibleWarnings.length > 0 && !hasIncrementedRef.current) {
const counts = { ...(persistentState.get('startupWarningCounts') || {}) };
let changed = false;
visibleWarnings.forEach((w) => {
if (w.priority === WarningPriority.Low) {
counts[w.id] = (counts[w.id] || 0) + 1;
changed = true;
}
});
if (changed) {
persistentState.set('startupWarningCounts', counts);
}
hasIncrementedRef.current = true;
}
}, [visibleWarnings]);
const handleKeyPress = useCallback(() => {
if (showStartupWarnings) {
setDismissed(true);
}
return false;
}, [showStartupWarnings]);
useKeypress(handleKeyPress, {
isActive: showStartupWarnings,
priority: KeypressPriority.Critical,
});
useEffect(() => {
const checkLegacyScreenReaderNudge = async () => {
if (hasSeenScreenReaderNudge !== undefined) return;
@@ -89,13 +148,13 @@ export const Notifications = () => {
{updateInfo && <UpdateNotification message={updateInfo.message} />}
{showStartupWarnings && (
<Box marginY={1} flexDirection="column">
{startupWarnings.map((warning, index) => (
{visibleWarnings.map((warning, index) => (
<Box key={index} flexDirection="row">
<Box width={3}>
<Text color={theme.status.warning}> </Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.status.warning}>{warning}</Text>
<Text color={theme.status.warning}>{warning.message}</Text>
</Box>
</Box>
))}
@@ -1620,7 +1620,6 @@ describe('SettingsDialog', () => {
vimMode: true,
enableAutoUpdate: false,
debugKeystrokeLogging: true,
enablePromptCompletion: true,
},
ui: {
hideWindowTitle: true,
@@ -1766,7 +1765,6 @@ describe('SettingsDialog', () => {
vimMode: false,
enableAutoUpdate: true,
debugKeystrokeLogging: false,
enablePromptCompletion: false,
},
ui: {
hideWindowTitle: false,
@@ -39,8 +39,8 @@ import {
} from '../../config/settingsSchema.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
import { useTextBuffer } from './shared/text-buffer.js';
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
import {
BaseSettingsDialog,
type SettingsDialogItem,
@@ -115,6 +115,7 @@ export function SettingsDialog({
}
const doSearch = async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const results = await fzfInstance.find(searchQuery);
if (!active) return;
@@ -207,20 +208,10 @@ export function SettingsDialog({
return max;
}, [selectedScope, settings]);
// Get mainAreaWidth for search buffer viewport
const { mainAreaWidth } = useUIState();
const viewportWidth = mainAreaWidth - 8;
// Search input buffer
const searchBuffer = useTextBuffer({
const searchBuffer = useSearchBuffer({
initialText: '',
initialCursorOffset: 0,
viewport: {
width: viewportWidth,
height: 1,
},
singleLine: true,
onChange: (text) => setSearchQuery(text),
onChange: setSearchQuery,
});
// Generate items for BaseSettingsDialog
@@ -29,7 +29,6 @@ describe('ShowMoreLines', () => {
it.each([
[new Set(), StreamingState.Idle, true], // No overflow
[new Set(['1']), StreamingState.Idle, false], // Not constraining height
[new Set(['1']), StreamingState.Responding, true], // Streaming
])(
'renders nothing when: overflow=%s, streaming=%s, constrain=%s',
async (overflowingIds, streamingState, constrainHeight) => {
@@ -46,9 +45,28 @@ describe('ShowMoreLines', () => {
},
);
it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])(
'renders message when overflowing and state is %s',
it('renders nothing in STANDARD mode even if overflowing', async () => {
mockUseAlternateBuffer.mockReturnValue(false);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
<ShowMoreLines constrainHeight={true} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it.each([
[StreamingState.Idle],
[StreamingState.WaitingForConfirmation],
[StreamingState.Responding],
])(
'renders message in ASB mode when overflowing and state is %s',
async (streamingState) => {
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
@@ -57,8 +75,39 @@ describe('ShowMoreLines', () => {
<ShowMoreLines constrainHeight={true} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
},
);
it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => {
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
<ShowMoreLines constrainHeight={true} isOverflowing={true} />,
);
await waitUntilReady();
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => {
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
<ShowMoreLines constrainHeight={true} isOverflowing={false} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
@@ -9,31 +9,42 @@ import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { theme } from '../semantic-colors.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
interface ShowMoreLinesProps {
constrainHeight: boolean;
isOverflowing?: boolean;
}
export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => {
export const ShowMoreLines = ({
constrainHeight,
isOverflowing: isOverflowingProp,
}: ShowMoreLinesProps) => {
const isAlternateBuffer = useAlternateBuffer();
const overflowState = useOverflowState();
const streamingState = useStreamingContext();
const isOverflowing =
isOverflowingProp ??
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
if (
overflowState === undefined ||
overflowState.overflowingIds.size === 0 ||
!isAlternateBuffer ||
!isOverflowing ||
!constrainHeight ||
!(
streamingState === StreamingState.Idle ||
streamingState === StreamingState.WaitingForConfirmation
streamingState === StreamingState.WaitingForConfirmation ||
streamingState === StreamingState.Responding
)
) {
return null;
}
return (
<Box paddingX={1}>
<Text color={theme.text.secondary} wrap="truncate">
Press ctrl-o to show more lines
<Box paddingX={1} marginBottom={1}>
<Text color={theme.text.accent} wrap="truncate">
Press Ctrl+O to show more lines
</Text>
</Box>
);
@@ -23,11 +23,13 @@ import {
import { computeSessionStats } from '../utils/computeStats.js';
import {
type RetrieveUserQuotaResponse,
VALID_GEMINI_MODELS,
isActiveModel,
getDisplayString,
isAutoModel,
AuthType,
} from '@google/gemini-cli-core';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import type { QuotaStats } from '../types.js';
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
@@ -82,9 +84,13 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
const buildModelRows = (
models: Record<string, ModelMetrics>,
quotas?: RetrieveUserQuotaResponse,
useGemini3_1 = false,
useCustomToolModel = false,
) => {
const getBaseModelName = (name: string) => name.replace('-001', '');
const usedModelNames = new Set(Object.keys(models).map(getBaseModelName));
const usedModelNames = new Set(
Object.keys(models).map(getBaseModelName).map(getDisplayString),
);
// 1. Models with active usage
const activeRows = Object.entries(models).map(([name, metrics]) => {
@@ -93,7 +99,7 @@ const buildModelRows = (
const inputTokens = metrics.tokens.input;
return {
key: name,
modelName,
modelName: getDisplayString(modelName),
requests: metrics.api.totalRequests,
cachedTokens: cachedTokens.toLocaleString(),
inputTokens: inputTokens.toLocaleString(),
@@ -109,12 +115,12 @@ const buildModelRows = (
?.filter(
(b) =>
b.modelId &&
VALID_GEMINI_MODELS.has(b.modelId) &&
!usedModelNames.has(b.modelId),
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
!usedModelNames.has(getDisplayString(b.modelId)),
)
.map((bucket) => ({
key: bucket.modelId!,
modelName: bucket.modelId!,
modelName: getDisplayString(bucket.modelId!),
requests: '-',
cachedTokens: '-',
inputTokens: '-',
@@ -135,6 +141,8 @@ const ModelUsageTable: React.FC<{
pooledRemaining?: number;
pooledLimit?: number;
pooledResetTime?: string;
useGemini3_1?: boolean;
useCustomToolModel?: boolean;
}> = ({
models,
quotas,
@@ -144,8 +152,10 @@ const ModelUsageTable: React.FC<{
pooledRemaining,
pooledLimit,
pooledResetTime,
useGemini3_1,
useCustomToolModel,
}) => {
const rows = buildModelRows(models, quotas);
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
if (rows.length === 0) {
return null;
@@ -403,7 +413,11 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
const { models, tools, files } = metrics;
const computed = computeSessionStats(metrics);
const settings = useSettings();
const config = useConfig();
const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false;
const useCustomToolModel =
useGemini3_1 &&
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
const pooledRemaining = quotaStats?.remaining;
const pooledLimit = quotaStats?.limit;
const pooledResetTime = quotaStats?.resetTime;
@@ -544,6 +558,8 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
pooledRemaining={pooledRemaining}
pooledLimit={pooledLimit}
pooledResetTime={pooledResetTime}
useGemini3_1={useGemini3_1}
useCustomToolModel={useCustomToolModel}
/>
{renderFooter()}
</Box>
@@ -35,12 +35,22 @@ describe('ToastDisplay', () => {
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
queueErrorMessage: null,
showIsExpandableHint: false,
};
it('returns false for default state', () => {
expect(shouldShowToast(baseState as UIState)).toBe(false);
});
it('returns true when showIsExpandableHint is true', () => {
expect(
shouldShowToast({
...baseState,
showIsExpandableHint: true,
} as UIState),
).toBe(true);
});
it('returns true when ctrlCPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState),
@@ -170,4 +180,22 @@ describe('ToastDisplay', () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders expansion hint when showIsExpandableHint is true', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
showIsExpandableHint: true,
constrainHeight: true,
});
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+O to show more lines');
});
it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
showIsExpandableHint: true,
constrainHeight: false,
});
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+O to collapse lines');
});
});
@@ -17,7 +17,8 @@ export function shouldShowToast(uiState: UIState): boolean {
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage)
Boolean(uiState.queueErrorMessage) ||
uiState.showIsExpandableHint
);
}
@@ -73,5 +74,14 @@ export const ToastDisplay: React.FC = () => {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
<Text color={theme.text.accent}>
Press Ctrl+O to {action} lines for the most recent response
</Text>
);
}
return null;
};
@@ -161,7 +161,7 @@ describe('ToolConfirmationQueue', () => {
</Box>,
{
config: mockConfig,
useAlternateBuffer: false,
useAlternateBuffer: true,
uiState: {
terminalWidth: 80,
terminalHeight: 20,
@@ -173,10 +173,11 @@ describe('ToolConfirmationQueue', () => {
await waitUntilReady();
await waitFor(() =>
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
unmount();
});
@@ -324,7 +325,7 @@ describe('ToolConfirmationQueue', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Press ctrl-o to show more lines');
expect(output).not.toContain('Press CTRL-O to show more lines');
expect(output).toMatchSnapshot();
unmount();
});
@@ -71,13 +71,12 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
// - 2 lines for the rounded border
// - 2 lines for the Header (text + margin)
// - 2 lines for Tool Identity (text + margin)
const availableContentHeight =
constrainHeight && !isAlternateBuffer
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
: undefined;
const availableContentHeight = constrainHeight
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
: undefined;
return (
<OverflowProvider>
const content = (
<>
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
<StickyHeader
width={mainAreaWidth}
@@ -152,6 +151,13 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
/>
</Box>
<ShowMoreLines constrainHeight={constrainHeight} />
</OverflowProvider>
</>
);
return isAlternateBuffer ? (
/* Shadow the global provider to maintain isolation in ASB mode. */
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
};
@@ -43,7 +43,6 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
@@ -90,7 +89,6 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
@@ -18,20 +18,8 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
"
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
@@ -27,33 +27,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback...
Enter to submit · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
@@ -81,33 +54,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests
Enter to submit · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
" Error reading plan: File not found
"
@@ -194,33 +140,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback...
Enter to submit · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
@@ -248,33 +167,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests
Enter to submit · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
" Error reading plan: File not found
"
@@ -6,27 +6,17 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ Line 8 │
│ Line 9 │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 16
│ Line 17
│ Line 18
│ Line 19
│ Line 20
│ │
│ Line 15
│ Line 16
│ Line 17
│ Line 18
│ Line 19
│ Line 20
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines
"
@@ -38,15 +28,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ Line 6
│ Line 7
│ Line 8
│ Line 9
│ Line 10
│ Line 11 █ │
│ Line 12 █ │
│ Line 13 █ │
│ Line 14 █ │
│ Line 10
│ Line 11
│ Line 12
│ Line 13
│ Line 14
│ Line 15 █ │
│ Line 16 █ │
│ Line 17 █ │
@@ -63,12 +49,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
Line 6
│ Line 7 │
│ Line 8 │
│ Line 9 │
│ Line 10 │
│ Line 11 │
... first 11 lines hidden ...
│ Line 12 │
│ Line 13 │
│ Line 14 │
@@ -88,6 +69,11 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ Line 8 │
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false* │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion true* │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -16,7 +16,6 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines
"
`;
@@ -107,7 +106,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines
Press Ctrl+O to show more lines
"
`;
@@ -12,6 +12,7 @@ import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
interface GeminiMessageProps {
text: string;
@@ -31,7 +32,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
const prefixWidth = prefix.length;
const isAlternateBuffer = useAlternateBuffer();
return (
const content = (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
@@ -61,4 +62,11 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
</Box>
</Box>
);
return isAlternateBuffer ? (
/* Shadow the global provider to maintain isolation in ASB mode. */
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
};
@@ -7,6 +7,7 @@
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { getDisplayString } from '@google/gemini-cli-core';
interface ModelMessageProps {
model: string;
@@ -15,7 +16,7 @@ interface ModelMessageProps {
export const ModelMessage: React.FC<ModelMessageProps> = ({ model }) => (
<Box marginLeft={2}>
<Text color={theme.ui.comment} italic>
Responding with {model}
Responding with {getDisplayString(model)}
</Text>
</Box>
);
@@ -191,7 +191,7 @@ describe('<ShellToolMessage />', () => {
true,
],
[
'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined',
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
undefined,
ACTIVE_SHELL_MAX_LINES,
false,
@@ -219,5 +219,75 @@ describe('<ShellToolMessage />', () => {
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
expect(frame).toMatchSnapshot();
});
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
const { lastFrame } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Executing,
},
{ useAlternateBuffer: false },
);
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
});
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
const { lastFrame, waitUntilReady } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: true,
},
{
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines because constrainHeight is false and isExpandable is true
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
expect(lastFrame()).toMatchSnapshot();
});
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
const { lastFrame, waitUntilReady } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: false,
},
{
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
// Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(15);
});
expect(lastFrame()).toMatchSnapshot();
});
});
});
@@ -15,24 +15,21 @@ import {
ToolStatusIndicator,
ToolInfo,
TrailingIndicator,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
isThisShellFocused as checkIsShellFocused,
useFocusHint,
FocusHint,
} from './ToolShared.js';
import type { ToolMessageProps } from './ToolMessage.js';
import {
ACTIVE_SHELL_MAX_LINES,
COMPLETED_SHELL_MAX_LINES,
} from '../../constants.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type Config } from '@google/gemini-cli-core';
import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js';
export interface ShellToolMessageProps extends ToolMessageProps {
config?: Config;
isExpandable?: boolean;
}
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
@@ -61,9 +58,15 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderColor,
borderDimColor,
isExpandable,
}) => {
const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState();
const {
activePtyId: activeShellPtyId,
embeddedShellFocused,
constrainHeight,
} = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isThisShellFocused = checkIsShellFocused(
name,
status,
@@ -155,59 +158,23 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
maxLines={getShellMaxLines(
maxLines={calculateShellMaxLines({
status,
isAlternateBuffer,
isThisShellFocused,
availableTerminalHeight,
)}
constrainHeight,
isExpandable,
})}
/>
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
/>
</Box>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
/>
)}
</Box>
</>
);
};
/**
* Calculates the maximum number of lines to display for shell output.
*
* For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES.
* For active processes, it returns the available terminal height if in alternate buffer mode
* and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES.
*
* This function ensures a finite number of lines is always returned to prevent performance issues.
*/
function getShellMaxLines(
status: CoreToolCallStatus,
isAlternateBuffer: boolean,
isThisShellFocused: boolean,
availableTerminalHeight: number | undefined,
): number {
if (
status === CoreToolCallStatus.Success ||
status === CoreToolCallStatus.Error ||
status === CoreToolCallStatus.Cancelled
) {
return COMPLETED_SHELL_MAX_LINES;
}
if (availableTerminalHeight === undefined) {
return ACTIVE_SHELL_MAX_LINES;
}
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
if (isAlternateBuffer && isThisShellFocused) {
return maxLinesBasedOnHeight;
}
return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES);
}
@@ -8,6 +8,7 @@ import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
SerializableConfirmationDetails,
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
@@ -87,6 +88,122 @@ describe('ToolConfirmationMessage', () => {
unmount();
});
it('should display WarningMessage for deceptive URLs in info type', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'https://täst.com',
urls: ['https://täst.com'],
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Deceptive URL(s) detected');
expect(output).toContain('Original: https://täst.com');
expect(output).toContain(
'Actual Host (Punycode): https://xn--tst-qla.com/',
);
unmount();
});
it('should display WarningMessage for deceptive URLs in exec type commands', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: 'curl https://еxample.com',
rootCommand: 'curl',
rootCommands: ['curl'],
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Deceptive URL(s) detected');
expect(output).toContain('Original: https://еxample.com/');
expect(output).toContain(
'Actual Host (Punycode): https://xn--xample-2of.com/',
);
unmount();
});
it('should exclude shell delimiters from extracted URLs in exec type commands', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: 'curl https://еxample.com;ls',
rootCommand: 'curl',
rootCommands: ['curl'],
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Deceptive URL(s) detected');
// It should extract "https://еxample.com" and NOT "https://еxample.com;ls"
expect(output).toContain('Original: https://еxample.com/');
// The command itself still contains 'ls', so we check specifically that 'ls' is not part of the URL line.
expect(output).not.toContain('Original: https://еxample.com/;ls');
unmount();
});
it('should aggregate multiple deceptive URLs into a single WarningMessage', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'Fetch both',
urls: ['https://еxample.com', 'https://täst.com'],
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Deceptive URL(s) detected');
expect(output).toContain('Original: https://еxample.com/');
expect(output).toContain('Original: https://täst.com/');
unmount();
});
it('should display multiple commands for exec type when provided', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
@@ -372,4 +489,35 @@ describe('ToolConfirmationMessage', () => {
unmount();
});
});
it('should strip BiDi characters from MCP tool and server names', async () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test\u202Eserver',
toolName: 'test\u202Dtool',
toolDisplayName: 'Test Tool',
onConfirm: vi.fn(),
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
// BiDi characters \u202E and \u202D should be stripped
expect(output).toContain('MCP Server: testserver');
expect(output).toContain('Tool: testtool');
expect(output).toContain('Allow execution of MCP tool "testtool"');
expect(output).toContain('from server "testserver"?');
expect(output).toMatchSnapshot();
unmount();
});
});
@@ -21,7 +21,10 @@ import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import { sanitizeForDisplay } from '../../utils/textUtils.js';
import {
sanitizeForDisplay,
stripUnsafeCharacters,
} from '../../utils/textUtils.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
@@ -34,6 +37,12 @@ import {
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
import { WarningMessage } from './WarningMessage.js';
import {
getDeceptiveUrlDetails,
toUnicodeUrl,
type DeceptiveUrlDetails,
} from '../../utils/urlSecurityUtils.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -99,6 +108,37 @@ export const ToolConfirmationMessage: React.FC<
[handleConfirm],
);
const deceptiveUrlWarnings = useMemo(() => {
const urls: string[] = [];
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
urls.push(...confirmationDetails.urls);
} else if (confirmationDetails.type === 'exec') {
const commands =
confirmationDetails.commands && confirmationDetails.commands.length > 0
? confirmationDetails.commands
: [confirmationDetails.command];
for (const cmd of commands) {
const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g);
if (matches) urls.push(...matches);
}
}
const uniqueUrls = Array.from(new Set(urls));
return uniqueUrls
.map(getDeceptiveUrlDetails)
.filter((d): d is DeceptiveUrlDetails => d !== null);
}, [confirmationDetails]);
const deceptiveUrlWarningText = useMemo(() => {
if (deceptiveUrlWarnings.length === 0) return null;
return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings
.map(
(w) =>
` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`,
)
.join('\n\n')}`;
}, [deceptiveUrlWarnings]);
const getOptions = useCallback(() => {
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
@@ -259,11 +299,21 @@ export const ToolConfirmationMessage: React.FC<
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
const { question, bodyContent, options } = useMemo(() => {
const { question, bodyContent, options, securityWarnings } = useMemo<{
question: string;
bodyContent: React.ReactNode;
options: Array<RadioSelectItem<ToolConfirmationOutcome>>;
securityWarnings: React.ReactNode;
}>(() => {
let bodyContent: React.ReactNode | null = null;
let securityWarnings: React.ReactNode | null = null;
let question = '';
const options = getOptions();
if (deceptiveUrlWarningText) {
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
}
if (confirmationDetails.type === 'ask_user') {
bodyContent = (
<AskUserDialog
@@ -278,7 +328,12 @@ export const ToolConfirmationMessage: React.FC<
availableHeight={availableBodyContentHeight()}
/>
);
return { question: '', bodyContent, options: [] };
return {
question: '',
bodyContent,
options: [],
securityWarnings: null,
};
}
if (confirmationDetails.type === 'exit_plan_mode') {
@@ -304,7 +359,7 @@ export const ToolConfirmationMessage: React.FC<
availableHeight={availableBodyContentHeight()}
/>
);
return { question: '', bodyContent, options: [] };
return { question: '', bodyContent, options: [], securityWarnings: null };
}
if (confirmationDetails.type === 'edit') {
@@ -324,15 +379,15 @@ export const ToolConfirmationMessage: React.FC<
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`;
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
bodyContent = (
<DiffRenderer
diffContent={confirmationDetails.fileDiff}
filename={confirmationDetails.fileName}
diffContent={stripUnsafeCharacters(confirmationDetails.fileDiff)}
filename={sanitizeForDisplay(confirmationDetails.fileName)}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={terminalWidth}
/>
@@ -433,10 +488,10 @@ export const ToolConfirmationMessage: React.FC<
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>URLs to fetch:</Text>
{infoProps.urls.map((url) => (
<Text key={url}>
{infoProps.urls.map((urlString) => (
<Text key={urlString}>
{' '}
- <RenderInline text={url} />
- <RenderInline text={toUnicodeUrl(urlString)} />
</Text>
))}
</Box>
@@ -449,19 +504,24 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
<Box flexDirection="column">
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</Box>
);
}
return { question, bodyContent, options };
return { question, bodyContent, options, securityWarnings };
}, [
confirmationDetails,
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
]);
if (confirmationDetails.type === 'edit') {
@@ -505,6 +565,12 @@ export const ToolConfirmationMessage: React.FC<
</MaxSizedBox>
</Box>
{securityWarnings && (
<Box flexShrink={0} marginBottom={1}>
{securityWarnings}
</Box>
)}
<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
@@ -6,6 +6,7 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
HistoryItem,
@@ -678,4 +679,194 @@ describe('<ToolGroupMessage />', () => {
},
);
});
describe('Manual Overflow Detection', () => {
it('detects overflow for string results exceeding available height', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6} // Very small height
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('detects overflow for array results exceeding available height', async () => {
// resultDisplay when array is expected to be AnsiLine[]
// AnsiLine is AnsiToken[]
const toolCalls = [
createToolCall({
resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]),
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => {
const toolCalls = [
createToolCall({
name: 'run_shell_command',
status: CoreToolCallStatus.Executing,
ptyId: 1,
resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit)
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={100} // Plenty of terminal height
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
activePtyId: 1,
embeddedShellFocused: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('does not show expansion hint when content is within limits', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'small result',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={20}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
it('hides expansion hint when constrainHeight is false', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => {
// In this test, the tool output is SHORT (no local overflow).
// We will inject a dummy ID into the global overflow state.
// ToolGroupMessage should still NOT show the hint because it calculates
// overflow locally and passes it as a prop.
const toolCalls = [
createToolCall({
resultDisplay: 'short result',
}),
];
const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } =
renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={100}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
// Manually trigger a global overflow
act(() => {
expect(capturedOverflowActions).toBeDefined();
capturedOverflowActions!.addOverflowingId('unrelated-global-id');
});
// The hint should NOT appear because ToolGroupMessage is isolated by its prop logic
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
});
});
@@ -17,10 +17,15 @@ import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { shouldHideToolCall } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import {
calculateShellMaxLines,
calculateToolContentMaxLines,
} from '../../utils/toolLayoutUtils.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
interface ToolGroupMessageProps {
@@ -31,6 +36,7 @@ interface ToolGroupMessageProps {
onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
isExpandable?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
@@ -43,6 +49,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
terminalWidth,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
isExpandable,
}) => {
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
@@ -67,6 +74,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
backgroundShells,
pendingHistoryItems,
} = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const { borderColor, borderDimColor } = useMemo(
() =>
@@ -106,14 +114,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const staticHeight = /* border */ 2;
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
return null;
}
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
@@ -134,21 +134,91 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
return (
// This box doesn't have a border even though it conceptually does because
// we need to allow the sticky headers to render the borders themselves so
// that the top border can be sticky.
/*
* ToolGroupMessage calculates its own overflow state locally and passes
* it as a prop to ShowMoreLines. This isolates it from global overflow
* reports in ASB mode, while allowing it to contribute to the global
* 'Toast' hint in Standard mode.
*
* Because of this prop-based isolation and the explicit mode-checks in
* AppContainer, we do not need to shadow the OverflowProvider here.
*/
const hasOverflow = useMemo(() => {
if (!availableTerminalHeightPerToolMessage) return false;
return visibleToolCalls.some((tool) => {
const isShellToolCall = isShellTool(tool.name);
const isFocused = isThisShellFocused(
tool.name,
tool.status,
tool.ptyId,
activePtyId,
embeddedShellFocused,
);
let maxLines: number | undefined;
if (isShellToolCall) {
maxLines = calculateShellMaxLines({
status: tool.status,
isAlternateBuffer,
isThisShellFocused: isFocused,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
constrainHeight,
isExpandable,
});
}
// Standard tools and Shell tools both eventually use ToolResultDisplay's logic.
// ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget.
const contentMaxLines = calculateToolContentMaxLines({
availableTerminalHeight: availableTerminalHeightPerToolMessage,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
if (!contentMaxLines) return false;
if (typeof tool.resultDisplay === 'string') {
const text = tool.resultDisplay;
const hasTrailingNewline = text.endsWith('\n');
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
const lineCount = contentText.split('\n').length;
return lineCount > contentMaxLines;
}
if (Array.isArray(tool.resultDisplay)) {
return tool.resultDisplay.length > contentMaxLines;
}
return false;
});
}, [
visibleToolCalls,
availableTerminalHeightPerToolMessage,
activePtyId,
embeddedShellFocused,
isAlternateBuffer,
constrainHeight,
isExpandable,
]);
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
return null;
}
const content = (
<Box
flexDirection="column"
/*
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
marginBottom={borderBottomOverride === false ? 0 : 1}
>
{visibleToolCalls.map((tool, index) => {
const isFirst = index === 0;
@@ -165,6 +235,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: isFirst,
borderColor,
borderDimColor,
isExpandable,
};
return (
@@ -179,34 +250,34 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
) : (
<ToolMessage {...commonProps} />
)}
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
{tool.outputFile && (
{tool.outputFile && (
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
})}
{
/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={0}
@@ -222,8 +293,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
)
}
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
<ShowMoreLines constrainHeight={constrainHeight} />
<ShowMoreLines
constrainHeight={constrainHeight && !!isExpandable}
isOverflowing={hasOverflow}
/>
)}
</Box>
);
return content;
};
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { waitFor } from '../../../test-utils/async.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => {
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => {
/**
* Logic:
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
* 2. ASB mode reserves 1 + 6 = 7 lines.
* 3. Line budget = 10 - 7 = 3 lines.
* 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE.
*/
const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call-1',
name: 'test-tool',
description: 'a test tool',
status: CoreToolCallStatus.Success,
resultDisplay,
confirmationDetails: undefined,
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={13}
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
uiState: {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: true,
},
);
// In ASB mode, the hint should appear because hasOverflow is now correctly calculated.
await waitFor(() =>
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
});
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => {
/**
* Logic:
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
* 2. Standard mode reserves 1 + 2 = 3 lines.
* 3. Line budget = 10 - 3 = 7 lines.
* 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE.
*/
const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call-1',
name: 'test-tool',
description: 'a test tool',
status: CoreToolCallStatus.Success,
resultDisplay,
confirmationDetails: undefined,
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={13}
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
uiState: {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: false,
},
);
// Verify truncation is occurring (standard mode uses MaxSizedBox)
await waitFor(() => expect(lastFrame()).toContain('hidden ...'));
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
// the logic inside ToolGroupMessage is now synchronized.
});
});
@@ -277,21 +277,47 @@ describe('ToolResultDisplay', () => {
inverse: false,
},
],
[
{
text: 'Line 4',
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
[
{
text: 'Line 5',
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
availableTerminalHeight={20}
maxLines={2}
maxLines={3}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).not.toContain('Line 2');
expect(output).not.toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
unmount();
});
@@ -19,10 +19,7 @@ import { Scrollable } from '../shared/Scrollable.js';
import { ScrollableList } from '../shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
const MIN_LINES_SHOWN = 2; // show at least this many lines
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
// Large threshold to ensure we don't cause performance issues for very large
// outputs that will get truncated further MaxSizedBox anyway.
@@ -53,16 +50,11 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
let availableHeight = availableTerminalHeight
? Math.max(
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
)
: undefined;
if (maxLines && availableHeight) {
availableHeight = Math.min(availableHeight, maxLines);
}
const availableHeight = calculateToolContentMaxLines({
availableTerminalHeight,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
const combinedPaddingAndBorderWidth = 4;
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
@@ -81,7 +73,8 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
[],
);
const truncatedResultDisplay = React.useMemo(() => {
const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => {
let hiddenLines = 0;
// Only truncate string output if not in alternate buffer mode to ensure
// we can scroll through the full output.
if (typeof resultDisplay === 'string' && !isAlternateBuffer) {
@@ -94,14 +87,29 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
const lines = contentText.split('\n');
if (lines.length > maxLines) {
// We will have a label from MaxSizedBox. Reserve space for it.
const targetLines = Math.max(1, maxLines - 1);
hiddenLines = lines.length - targetLines;
text =
lines.slice(-maxLines).join('\n') +
lines.slice(-targetLines).join('\n') +
(hasTrailingNewline ? '\n' : '');
}
}
return text;
return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines };
}
return resultDisplay;
if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) {
if (resultDisplay.length > maxLines) {
// We will have a label from MaxSizedBox. Reserve space for it.
const targetLines = Math.max(1, maxLines - 1);
return {
truncatedResultDisplay: resultDisplay.slice(-targetLines),
hiddenLinesCount: resultDisplay.length - targetLines,
};
}
}
return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 };
}, [resultDisplay, isAlternateBuffer, maxLines]);
if (!truncatedResultDisplay) return null;
@@ -229,7 +237,11 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
return (
<Box width={childWidth} flexDirection="column">
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={childWidth}
additionalHiddenLinesCount={hiddenLinesCount}
>
{content}
</MaxSizedBox>
</Box>
@@ -39,6 +39,7 @@ describe('ToolResultDisplay Overflow', () => {
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
@@ -46,26 +47,28 @@ describe('ToolResultDisplay Overflow', () => {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: false,
useAlternateBuffer: true,
},
);
// ResizeObserver might take a tick
await waitFor(() =>
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
const frame = lastFrame();
expect(frame).toBeDefined();
if (frame) {
expect(frame).toContain('Press ctrl-o to show more lines');
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
// Ensure it's AFTER the bottom border
const linesOfOutput = frame.split('\n');
const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
l.includes('╰─'),
);
const hintIndex = linesOfOutput.findIndex((l) =>
l.includes('Press ctrl-o to show more lines'),
l.toLowerCase().includes('press ctrl+o to show more lines'),
);
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
expect(frame).toMatchSnapshot();
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 1`] = `
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
│ │
@@ -22,6 +22,113 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
"
`;
exports[`<ShellToolMessage /> > Height Constraints > fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ Line 8 │
│ Line 9 │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15 │
│ Line 16 │
│ Line 17 │
│ Line 18 │
│ Line 19 │
│ Line 20 │
│ Line 21 │
│ Line 22 │
│ Line 23 │
│ Line 24 │
│ Line 25 │
│ Line 26 │
│ Line 27 │
│ Line 28 │
│ Line 29 │
│ Line 30 │
│ Line 31 │
│ Line 32 │
│ Line 33 │
│ Line 34 │
│ Line 35 │
│ Line 36 │
│ Line 37 │
│ Line 38 │
│ Line 39 │
│ Line 40 │
│ Line 41 │
│ Line 42 │
│ Line 43 │
│ Line 44 │
│ Line 45 │
│ Line 46 │
│ Line 47 │
│ Line 48 │
│ Line 49 │
│ Line 50 │
│ Line 51 │
│ Line 52 │
│ Line 53 │
│ Line 54 │
│ Line 55 │
│ Line 56 │
│ Line 57 │
│ Line 58 │
│ Line 59 │
│ Line 60 │
│ Line 61 │
│ Line 62 │
│ Line 63 │
│ Line 64 │
│ Line 65 │
│ Line 66 │
│ Line 67 │
│ Line 68 │
│ Line 69 │
│ Line 70 │
│ Line 71 │
│ Line 72 │
│ Line 73 │
│ Line 74 │
│ Line 75 │
│ Line 76 │
│ Line 77 │
│ Line 78 │
│ Line 79 │
│ Line 80 │
│ Line 81 │
│ Line 82 │
│ Line 83 │
│ Line 84 │
│ Line 85 │
│ Line 86 │
│ Line 87 │
│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
│ Line 94 │
│ Line 95 │
│ Line 96 │
│ Line 97 │
│ Line 98 │
│ Line 99 │
│ Line 100 │
"
`;
exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
@@ -37,6 +144,28 @@ exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalH
"
`;
exports[`<ShellToolMessage /> > Height Constraints > stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 86 │
│ Line 87 │
│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
│ Line 94 │
│ Line 95 │
│ Line 96 │
│ Line 97 │
│ Line 98 ▄ │
│ Line 99 █ │
│ Line 100 █ │
"
`;
exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
@@ -161,7 +290,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
│ Line 98 █ │
│ Line 99 █ │
│ Line 100 █ │
│ │
"
`;
@@ -170,7 +298,6 @@ exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode whi
│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Test result │
│ │
"
`;
@@ -35,6 +35,18 @@ Do you want to proceed?
"
`;
exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = `
"MCP Server: testserver
Tool: testtool
Allow execution of MCP tool "testtool" from server "testserver"?
● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -55,13 +55,13 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
│──────────────────────────────────────────────────────────────────────────│
line5
│ │ █
│ ✓ tool-2 Description 2 │ █
│ │ █
│ line1 │ █
│ line2 │ █
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
@@ -111,12 +111,12 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with output
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
"──────────────────────────────────────────────────────────────────────────
"──────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-2 Description 2 │
│ │
│ line1 │
│ │
│ line1 │
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;

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